Running Laravel in Docker for production requires more than a single Dockerfile. You need OPcache tuned for peak throughput, an Nginx container that only serves static assets from the host and proxies everything else to PHP-FPM, a separate queue worker container with a supervisor, and a Redis container for cache and sessions. This guide assembles all of it.
>Multi-stage Dockerfile
Dockerfile
# ── Stage 1: composer dependencies ──────────────────────────────────────
FROM composer:2.7 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist --optimize-autoloader
# ── Stage 2: production image ─────────────────────────────────────────────
FROM php:8.3-fpm-alpine AS app
# System deps
RUN apk add --no-cache bash shadow libpng-dev libjpeg-turbo-dev libwebp-dev zip unzip oniguruma-dev icu-dev libxml2-dev
# PHP extensions
RUN docker-php-ext-configure gd --with-jpeg --with-webp && docker-php-ext-install -j$(nproc) pdo_mysql pgsql pdo_pgsql gd bcmath intl opcache pcntl zip
# Redis extension
RUN pecl install redis && docker-php-ext-enable redis
# OPcache — production settings
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
# Create non-root user
RUN addgroup -g 1000 laravel && adduser -u 1000 -G laravel -s /bin/sh -D laravel
WORKDIR /var/www/html
# Copy vendor from stage 1
COPY --from=vendor /app/vendor ./vendor
# Copy application
COPY --chown=laravel:laravel . .
# Bootstrap
RUN php artisan config:cache && php artisan route:cache && php artisan view:cache && php artisan event:cache
USER laravel
EXPOSE 9000
CMD ["php-fpm"]>OPcache configuration
ini
; docker/php/opcache.ini
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; ← disable in production
opcache.save_comments=1
opcache.jit_buffer_size=64M
opcache.jit=tracing>Nginx configuration
nginx
# docker/nginx/default.conf
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
# Static assets served directly
location ~* .(css|js|png|jpg|webp|avif|woff2|svg|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# PHP via FastCGI
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ .php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 300;
}
}>docker-compose.yml
yaml
# docker-compose.yml
services:
app:
build: { context: ., target: app }
restart: unless-stopped
volumes:
- ./storage:/var/www/html/storage
env_file: .env.production
depends_on: [db, redis]
healthcheck:
test: ["CMD", "php", "-r", "echo 1;"]
interval: 30s
retries: 3
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
ports: ["80:80", "443:443"]
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./public:/var/www/html/public:ro
depends_on: [app]
worker:
build: { context: ., target: app }
restart: unless-stopped
command: php artisan queue:work redis --queue=default,high --tries=3 --timeout=90
env_file: .env.production
depends_on: [db, redis]
scheduler:
build: { context: ., target: app }
restart: unless-stopped
command: sh -c "while true; do php artisan schedule:run; sleep 60; done"
env_file: .env.production
depends_on: [db, redis]
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: laravel
POSTGRES_USER: laravel
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
volumes: [pgdata:/var/lib/postgresql/data]
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes
volumes: [redisdata:/data]
volumes:
pgdata:
redisdata:>Deploy to VPS
bash
# Pull latest, rebuild, migrate with zero-downtime
docker compose pull
docker compose build --no-cache app
docker compose up -d --no-deps app worker scheduler
# Run migrations (after new containers are up)
docker compose exec app php artisan migrate --force
# Watch logs
docker compose logs -f app worker