Laravel Docker Configuration: Dev & Production
Vignesh Saravanan · Aug 16, 2026
Running Laravel in Docker gives you an identical environment on every machine — no more "it works on my laptop". The tricky part is keeping development fast (hot reload, Xdebug) while shipping a lean, optimized image to production (built assets, no dev deps, queue workers). The answer is a multi-stage Dockerfile plus two compose overrides. Here's the full setup I use.
The Multi-Stage Dockerfile
A single Dockerfile with a base stage that both dev and prod build from. The base installs PHP extensions, Node, and Composer once:
FROM php:8.3-fpm AS base
WORKDIR /var/www/html/laravel-app
RUN apt-get update && apt-get install -y \
git curl unzip zip \
libonig-dev libzip-dev libpng-dev \
libjpeg62-turbo-dev libfreetype6-dev \
libicu-dev supervisor \
&& rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo_mysql mbstring bcmath zip gd exif pcntl opcache intl
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
EXPOSE 9000
CMD ["php-fpm"]Dev Stage: Xdebug Only in Dev
The dev target installs Xdebug but does not enable it globally. It's loaded only via the config mounted by docker-compose.dev.yml — production never mounts that file, so prod runs without any Xdebug overhead.
FROM base AS dev
# Build Xdebug but DO NOT enable it globally.
RUN pecl install xdebug \
&& rm -rf /tmp/pearProd Stage: Lean and Optimized
The prod target copies manifests first so Docker layer caching is preserved, installs production-only dependencies, builds frontend assets, and hands control to Supervisor (PHP-FPM + queue workers) as PID 1.
FROM base AS prod
COPY composer.json composer.lock package.json package-lock.json .
COPY app/Helpers/ ./app/Helpers/
# --no-scripts: skip artisan package:discover (no .env during build)
RUN composer install --no-dev --optimize-autoloader --no-scripts \
&& npm ci
COPY . .
RUN rm -f bootstrap/cache/packages.php bootstrap/cache/services.php \
&& php artisan package:discover --ansi || rm -f bootstrap/cache/packages.php
RUN npm run build
COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
RUN chown -R www-data:www-data /var/www/html/laravel-app
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]Base Compose File
The shared docker-compose.yml defines the app, nginx, MySQL (with a healthcheck), Redis, and phpMyAdmin:
services:
app:
build:
context: .
dockerfile: Dockerfile
target: dev
image: laravel-app
container_name: laravel-app
restart: unless-stopped
working_dir: /var/www/html/laravel-app
networks: [laravel]
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
nginx:
image: nginx:alpine
container_name: laravel-nginx
restart: unless-stopped
depends_on: [app]
networks: [laravel]
db:
image: mysql:8.0
container_name: laravel-db
restart: unless-stopped
ports:
- "127.0.0.1:3307:3306"
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- dbdata:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--protocol=tcp"]
start_period: 300s
interval: 10s
timeout: 5s
retries: 10
networks: [laravel]
redis:
image: redis:alpine
container_name: laravel-redis
restart: unless-stopped
networks: [laravel]
phpmyadmin:
image: phpmyadmin/phpmyadmin
container_name: laravel-phpmyadmin
restart: unless-stopped
ports:
- "127.0.0.1:8082:80"
environment:
PMA_HOST: db
PMA_PORT: 3306
PMA_USER: ${DB_USERNAME}
PMA_PASSWORD: ${DB_PASSWORD}
depends_on: [db]
networks: [laravel]
networks:
laravel:
driver: bridge
volumes:
dbdata:
name: laravel-app_dbdata
driver: localDev Overrides
docker-compose.dev.yml bind-mounts the source code so changes appear instantly, mounts the Xdebug INI, maps nginx to port 8000, and spins up a Vite container for HMR:
services:
app:
build:
target: dev
volumes:
- ./:/var/www/html/laravel-app
- ./docker/php/dev/php.ini:/usr/local/etc/php/conf.d/local.ini
- ./docker/php/dev/php-xdebug.ini:/usr/local/etc/php/conf.d/php-xdebug.ini
environment:
APP_ENV: local
APP_DEBUG: "true"
nginx:
ports:
- "8000:80"
volumes:
- ./:/var/www/html/laravel-app
- ./docker/nginx/dev/default.conf:/etc/nginx/conf.d/default.conf
db:
ports:
- "127.0.0.1:3307:3306"
redis:
ports:
- "6380:6379"
vite:
image: node:20
container_name: laravel-vite
working_dir: /var/www/html/laravel-app
command: sh -c "[ -d node_modules ] || npm ci && npm run dev -- --host 0.0.0.0"
volumes:
- ./:/var/www/html/laravel-app
ports:
- "5173:5173"
networks: [laravel]Prod Overrides
In production the code is baked into the image. A named volume app_code is seeded from the built image and shared read-only with nginx so it can serve the compiled assets. Only the .env, PHP config, and worker config are mounted in. nginx handles HTTP→HTTPS redirects plus Let's Encrypt certificates.
services:
app:
build:
target: prod
volumes:
- app_code:/var/www/html/laravel-app
- ./.env:/var/www/html/laravel-app/.env
- ./docker/php/prod/php.ini:/usr/local/etc/php/conf.d/local.ini
- ./docker/supervisor.d/laravel-worker.ini:/etc/supervisor/conf.d/laravel-worker.conf
environment:
APP_ENV: production
ENABLE_LARAVEL_WORKER: "true"
nginx:
ports:
- "80:80"
- "443:443"
volumes:
- app_code:/var/www/html/laravel-app:ro
- ./docker/nginx/prod/default.conf:/etc/nginx/conf.d/default.conf
- /etc/letsencrypt:/etc/letsencrypt:ro
- /var/www/certbot:/var/www/certbot:ro
volumes:
app_code:
driver: localapp_code volume on each deploy to pick up the new image contents — but don't use -v with the shared dbdata volume or you'll lose your database.Nginx: Dev vs Prod
Dev nginx simply forwards PHP to the app container and serves Laravel from the mounted public directory:
server {
listen 80;
index index.php index.html;
root /var/www/html/laravel-app/public;
client_max_body_size 200M;
location / {
try_files $uri $uri/ /index.php?$query_string;
gzip_static on;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass app:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
}Prod nginx forces HTTPS, redirects non-www to www, proxies phpMyAdmin, caches static files for 30 days, and denies hidden files:
server {
listen 80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
return 301 https://www.example.com$request_uri;
}
server {
listen 443 ssl;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
client_max_body_size 512M;
root /var/www/html/laravel-app/public;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$query_string;
gzip_static on;
}
location ~ \.php$ {
try_files $uri =404;
set $backend "app:9000";
fastcgi_pass $backend;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~* \.(jpg|jpeg|gif|png|css|js|ico|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
location ~ /\.ht { deny all; }
}Xdebug in Dev
The dev Xdebug config connects back to your IDE on host.docker.internal and listens on port 9001:
zend_extension=xdebug
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_host=host.docker.internal
xdebug.client_port=9001
xdebug.idekey=PHPSTORMSupervisor: PHP-FPM + Queue Workers
In production, Supervisor keeps PHP-FPM running and spawns 8 Laravel queue workers. The worker config is only mounted in prod, and it starts only when ENABLE_LARAVEL_WORKER is true:
[supervisord]
nodaemon=true
user=root
[program:php-fpm]
command=php-fpm -F
autostart=true
autorestart=true
[include]
files = /etc/supervisor/conf.d/*.conf[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/laravel-app/artisan queue:work --sleep=3 --tries=3 --timeout=180
autostart=%(ENV_ENABLE_LARAVEL_WORKER)s
autorestart=true
numprocs=8
redirect_stderr=true
stdout_logfile=/var/www/html/laravel-app/storage/logs/worker.log.dockerignore
Keep the build context small so prod images build fast and never leak secrets or local dependencies:
.git
.env
node_modules
vendor
storage/*.key
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
.idea
.vscode
*.log
*.mdDaily Workflow
- Start / stop:
docker compose up -danddocker compose down(data persists). - Rebuild after Dockerfile changes:
docker compose up -d --build. - Run artisan:
docker compose exec app php artisan migrate. - Shell in:
docker compose exec app bash. - Tail logs:
docker compose logs -f app.
# First-time setup
docker compose up -d --build
docker compose exec app composer install
docker compose exec app cp .env.example .env
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate --seedWhy This Setup Works
- Dev speed: bind mounts + Vite HMR give instant feedback; Xdebug is isolated to dev.
- Tiny prod image: no dev dependencies, no dev tools, built assets baked in.
- One Dockerfile: dev and prod share the base layer, so only the differences are rebuilt.
- Production ready: Supervisor manages php-fpm and queue workers, and HTTPS is handled by nginx + Let's Encrypt.