DEV Community

Cover image for Running Laravel with Docker in Production
Sumeet Shroff
Sumeet Shroff

Posted on

Running Laravel with Docker in Production

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"]
Enter fullscreen mode Exit fullscreen mode

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.
  • .env is 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 "$@"
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

Scan your codebase before containerising:

# Find direct env() calls outside config/ directory
grep -rn "env(" --include="*.php" . | grep -v "^./config/"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)