All articles
LaravelDockerDevOpsDeploymentCI/CD

Docker + Laravel in Production: The Setup That Actually Works

PKParth Kanasagra
11 min read

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:run every 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.

Related Articles

PK

Parth Kanasagra

Certified Senior Laravel & PHP Developer · 9+ years experience · Available for hire