Running Laravel with Docker in Production
If you've already decided to containerise your Laravel application, you're past the "why Docker" debate. This guide is for engineers who want a production-ready setup — not a local toy — covering the Dockerfile, Compose file, Nginx configuration, queue workers, and the deployment mistakes that will silently break your app after docker compose up -d.
Prerequisites: PHP 8.3+, Laravel 12 or 13, Docker Engine 26+, Docker Compose v2, familiarity with Artisan commands. All examples target Laravel 13 on PHP 8.3 (the current production minimum as of March 2026).
For a broader look at deployment options including Laravel Cloud, Forge, Vapor, and Octane, see Laravel Deployment and Hosting: Cloud, Forge, VPS, Docker, and Octane.
Why a Multi-Stage Dockerfile
A single-stage build bakes Composer, dev packages, and build tooling into the final image. The result is a 600 MB+ image with attack surface you don't need. Multi-stage builds let you install dependencies in a builder stage and copy only the compiled output to a lean runtime image.
# ── Stage 1: Composer dependencies ───────────────────────────────────────────
FROM php:8.3-fpm-alpine AS builder
WORKDIR /app
# System dependencies required by common Laravel packages
RUN apk add --no-cache \
git \
unzip \
libpng-dev \
libjpeg-turbo-dev \
libwebp-dev \
zip \
&& docker-php-ext-install pdo pdo_mysql gd bcmath opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--optimize-autoloader \
--no-interaction \
--no-progress
COPY . .
# Cache Laravel config, routes, views, and events at build time
RUN php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache \
&& php artisan event:cache
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
FROM php:8.3-fpm-alpine
WORKDIR /app
RUN apk add --no-cache libpng libjpeg-turbo libwebp \
&& docker-php-ext-install pdo pdo_mysql gd bcmath opcache
# Copy only the production artifact from the builder
COPY --from=builder /app .
# Non-root user for PHP-FPM
RUN adduser -D -u 1000 appuser \
&& chown -R appuser:appuser /app/storage /app/bootstrap/cache
USER appuser
EXPOSE 9000
CMD ["php-fpm"]
Key decisions here:
- Alpine base keeps the image under 120 MB.
- Composer runs as root in the builder only; the runtime container runs as
appuser(UID 1000). - Config/route/view caches are baked at build time, not injected at startup. This means your image is immutable and identical across all replicas.
-
.envis not copied into the image. Environment variables are injected at runtime via Docker secrets or your orchestrator.
Nginx Sidecar Container
PHP-FPM speaks FastCGI, not HTTP. You need a web server in front of it. In a container setup, Nginx runs as a separate container and talks to PHP-FPM over a shared network.
# nginx/default.conf
server {
listen 80;
server_name _;
root /app/public;
index index.php;
# Serve static assets directly; never pass them to PHP
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
expires max;
log_not_found off;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000; # "app" is the PHP-FPM service name in Compose
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 60;
}
# Deny access to hidden files (.env, .git, etc.)
location ~ /\. {
deny all;
}
}
Mount the same /app volume in both the app and nginx containers so Nginx can serve static files directly without proxying through PHP-FPM.
Docker Compose for Production
Development Compose files often include bind mounts, debug ports, and Mailpit. Strip those out for production.
# docker-compose.prod.yml
services:
app:
image: registry.example.com/myapp:${IMAGE_TAG:-latest}
restart: unless-stopped
environment:
APP_ENV: production
APP_DEBUG: "false"
APP_KEY: ${APP_KEY}
DB_HOST: db
DB_DATABASE: ${DB_DATABASE}
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
REDIS_HOST: redis
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- internal
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
# Share the compiled app so Nginx can serve public/ assets
- app_public:/app/public:ro
depends_on:
- app
networks:
- internal
worker:
image: registry.example.com/myapp:${IMAGE_TAG:-latest}
restart: unless-stopped
command: php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
environment:
APP_ENV: production
APP_KEY: ${APP_KEY}
DB_HOST: db
REDIS_HOST: redis
depends_on:
- db
- redis
networks:
- internal
scheduler:
image: registry.example.com/myapp:${IMAGE_TAG:-latest}
restart: unless-stopped
# Runs `php artisan schedule:run` every minute via a shell loop
command: sh -c "while true; do php artisan schedule:run --no-interaction; sleep 60; done"
environment:
APP_ENV: production
APP_KEY: ${APP_KEY}
DB_HOST: db
REDIS_HOST: redis
depends_on:
- db
- redis
networks:
- internal
db:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
redis:
image: redis:7.4-alpine
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
volumes:
db_data:
redis_data:
app_public:
networks:
internal:
driver: bridge
Three services share the same image (app, worker, scheduler) but run different commands. This is intentional — one build artifact, multiple roles.
Startup Entrypoint and Migrations
Do not run php artisan migrate --force as part of the CMD. If a migration fails, your container crashes and restarts in a loop. Instead, handle it in a dedicated entrypoint script that runs once before the main process:
#!/bin/sh
# docker/entrypoint.sh
set -e
if [ "$RUN_MIGRATIONS" = "true" ]; then
echo "Running migrations..."
php artisan migrate --force
fi
exec "$@"
Set RUN_MIGRATIONS=true only on the app service, not on worker or scheduler. This prevents concurrent migration runs when all three containers start simultaneously.
The env() Trap After config:cache
This is the single most common bug in Dockerised Laravel deployments. When you run php artisan config:cache (which you should, at build time), the .env file is no longer read at runtime. Every call to env() directly in application code — outside a config/*.php file — returns null.
// WRONG — returns null when config cache is active
$apiKey = env('STRIPE_SECRET_KEY');
// CORRECT — wrap in a config file first
// config/services.php
'stripe' => [
'secret' => env('STRIPE_SECRET_KEY'),
],
// Then read it in your code:
$apiKey = config('services.stripe.secret');
Scan your codebase before containerising:
# Find direct env() calls outside config/ directory
grep -rn "env(" --include="*.php" . | grep -v "^./config/"
Any hit in app/, routes/, or bootstrap/ is a potential null after config caching.
Security: What Not to Do in a Dockerfile
-
Never
COPY .env .— your secrets end up baked into the image and pushed to your registry. -
Never run as root — add a non-root user and switch to it before
CMD. - Never expose MySQL/Redis ports publicly — keep database containers on an internal network only, as shown in the Compose file above.
-
Always set
APP_DEBUG=false— Laravel's debug page reflects request parameters. CVE-2024-13918 and CVE-2024-13919 demonstrate how debug mode enables reflected XSS when attackers craft specific request parameters. - For Livewire users: update to v3.6.4+ immediately. CVE-2025-54068 is a critical remote code execution vulnerability affecting Livewire v3 up to and including v3.6.3.
Verifying the Running Deployment
After docker compose -f docker-compose.prod.yml up -d, run these checks:
# 1. Confirm all containers are healthy
docker compose -f docker-compose.prod.yml ps
# 2. Check Laravel health endpoint (requires Laravel 12+)
curl -s http://localhost/up
# Expected: HTTP 200 with JSON status of db, cache, and queue
# 3. Test queue worker is processing
docker compose -f docker-compose.prod.yml exec app \
php artisan queue:monitor redis:default
# 4. Verify config cache is active (returns cached config, not env)
docker compose -f docker-compose.prod.yml exec app \
php artisan config:show app
# 5. Check logs for any startup errors
docker compose -f docker-compose.prod.yml logs --tail=50 app
Laravel 12 introduced native health check routes at /up — no extra package required. The endpoint reports database, cache, and queue connectivity and returns HTTP 503 if any check fails, making it a clean liveness probe for load balancers.
Common Mistakes to Avoid
| Mistake | Consequence |
|---|---|
Caching config at runtime in CMD
|
Race condition when multiple replicas start simultaneously |
No healthcheck on db service |
app container starts before MySQL is ready, fails on first DB call |
| Single container for app + worker | Worker failure brings down the web process |
Bind-mounting storage/ in production |
Container restarts wipe any writes not on the host path |
composer install without --no-dev
|
Dev packages (Faker, PHPUnit, Sail) shipped to production |
Sharing APP_KEY across environments |
Session and encrypted payload forgery across staging/production |
Tradeoffs vs Managed Platforms
Docker on a VPS gives you full control and portability — the same docker-compose.prod.yml runs on DigitalOcean, Hetzner, or your own bare metal. The cost is operational overhead: you own Dockerfile maintenance, image registry, security patching, and log aggregation.
Laravel Forge handles the server provisioning and Nginx configuration for you but runs PHP-FPM directly on the host — no Docker layer. Laravel Cloud abstracts everything further with auto-scaling EC2 containers, but you lose the ability to SSH in and inspect state directly.
If your team already has Docker expertise and needs environment parity across CI, staging, and production, the containerised VPS path is the most cost-effective option at steady-state traffic. If you want zero ops overhead and are comfortable with usage-based pricing, Laravel Cloud is the faster path.
If you need Laravel development in Mumbai, Mumbai Web Designer builds production-grade Laravel applications.
Top comments (0)