Docker + Laravel in Production: The Setup That Actually Works
The first time I tried to containerize a Laravel app, I spent two full days on it. Got it working locally. Deployed it. Production was broken because I forgot to set APP_KEY at runtime. Then it broke again because I built dev dependencies into the image. Then queue workers weren't running. Then the logs were disappearing into nowhere.
I'm writing this so it takes you two hours, not two days.
The Structure That Actually Holds Up
I run three containers for a typical Laravel production app:
- app — PHP-FPM process running the Laravel application
- nginx — Reverse proxy serving static assets directly, passing PHP to app
- scheduler — Runs
artisan schedule:runevery minute via cron
Queue workers are managed separately by Supervisor inside the app container. Redis runs as a managed service (AWS ElastiCache or DigitalOcean's managed Redis) — I stopped running it in Docker because the persistence story gets complicated.
The Dockerfile That Doesn't Embarrass You
Two things I see in almost every tutorial that shouldn't be there in production: dev dependencies in the image, and running as root.
FROM php:8.4-fpm-alpine AS base
# Install only what you need
RUN apk add --no-cache nginx supervisor \
&& docker-php-ext-install pdo_mysql opcache pcntl
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY . .
# Production build — no dev deps, everything cached
RUN composer install --no-dev --optimize-autoloader \
&& php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache \
&& chown -R www-data:www-data storage bootstrap/cache
USER www-data
EXPOSE 9000
The chown line before switching to www-data is the one I forget every single time and then spend 20 minutes wondering why files can't be written.
Nginx Config for Laravel
The important bit is the try_files directive — everything that isn't a static file goes through index.php:
server {
listen 80;
root /var/www/html/public;
index index.php;
client_max_body_size 50M;
location / {
try_files $uri $uri/ /index.php?$query_string;
gzip_static on;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 300;
}
# Long-lived cache for versioned assets
location ~* \.(css|js|woff2|png|jpg|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Queue Workers with Supervisor
Don't run queue workers with & in a shell script. They'll die silently and you won't know until your users start complaining that emails aren't sending. Use Supervisor:
[program:laravel-worker]
command=php /var/www/html/artisan queue:work redis
--sleep=3
--tries=3
--max-time=3500
--memory=256
autostart=true
autorestart=true
numprocs=3
user=www-data
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
--max-time=3500 (just under an hour) means workers gracefully restart before they've been running so long they've got memory leaks or stale state. I've seen workers that have been up for three weeks start doing weird things.
GitHub Actions CI/CD
Here's the actual workflow I use. The key is building the image, pushing to a registry, then SSHing to pull and restart — not copying files with rsync (which I see in a lot of tutorials and it just creates inconsistency):
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_IP }}
username: deploy
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}
docker service update \
--image ghcr.io/${{ github.repository }}:${{ github.sha }} \
--update-order start-first \
laravel_app
docker exec $(docker ps -q -f name=laravel_app) php artisan migrate --force
--update-order start-first in Docker Swarm means new containers start before old ones stop — zero downtime deploys without orchestration complexity.
Secrets Management
Never bake secrets into the Docker image. Never. ENV APP_KEY=base64:abc123 in a Dockerfile means your key is in the image layer history forever. Use your cloud provider's secrets manager (AWS SSM, DigitalOcean App Platform environment variables, or Docker Swarm secrets) and inject at runtime.
The Health Check You Need
Route::get('/health', function () {
DB::connection()->getPdo();
Cache::put('health_check', true, 10);
return response()->json(['status' => 'ok', 'time' => now()->toIso8601String()]);
});
Hook this up to your load balancer health check. If the DB is down, this throws an exception, the health check fails, and traffic stops routing to the broken instance. This has saved me multiple times.