Programming Advanced 16 min

How to Dockerize a Laravel App with PHP-FPM and Nginx

A single-image "PHP + Nginx + MySQL in one container" setup is tempting but wrong — it fights against Docker's process model, makes scaling impossible, and bloats your image. The right approach is four containers: php-fpm, nginx, mysql, and redis, wired together with Compose. This guide builds that stack from scratch with a multi-stage Dockerfile that produces a lean production image.

Step-by-step

  1. 1

    Write a multi-stage Dockerfile

    Stage 1 (builder) installs Composer dependencies. Stage 2 (runtime) copies only the vendor artifacts — not Composer itself. The final image stays small and has no build tools.

    dockerfile
    # ── Stage 1: Composer dependencies ──────────────────────────────
    FROM composer:2 AS builder
    WORKDIR /app
    COPY composer.json composer.lock ./
    RUN composer install \
          --no-dev \
          --no-scripts \
          --optimize-autoloader \
          --no-interaction \
          --prefer-dist
    
    COPY . .
    RUN composer run-script post-autoload-dump
    
    # ── Stage 2: PHP-FPM runtime ──────────────────────────────────
    FROM php:8.3-fpm-alpine AS runtime
    
    # Install system deps + PHP extensions
    RUN apk add --no-cache \
          libpng-dev libjpeg-turbo-dev libwebp-dev \
          libzip-dev oniguruma-dev icu-dev && \
        docker-php-ext-install \
          pdo_mysql mbstring zip bcmath intl gd opcache
    
    WORKDIR /var/www
    
    # Copy app + vendor from builder
    COPY --from=builder /app .
    
    # Non-root user
    RUN addgroup -g 1000 laravel && \
        adduser -u 1000 -G laravel -s /bin/sh -D laravel && \
        chown -R laravel:laravel /var/www
    
    USER laravel
    
    EXPOSE 9000
    CMD ["php-fpm"]
  2. 2

    Create the Nginx config for PHP-FPM

    Nginx proxies PHP requests to php:9000 — that's the service name in Compose, not a local port. Static files are served directly without touching PHP.

    nginx
    # docker/nginx/default.conf
    server {
        listen 80;
        root /var/www/public;
        index index.php;
    
        # Serve static files directly
        location / {
            try_files $uri $uri/ /index.php?$query_string;
        }
    
        # Forward PHP requests to php-fpm
        location ~ \.php$ {
            fastcgi_pass   php:9000;
            fastcgi_index  index.php;
            include        fastcgi_params;
            fastcgi_param  SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
            fastcgi_param  DOCUMENT_ROOT   $realpath_root;
        }
    
        location ~ /\.ht {
            deny all;
        }
    }
  3. 3

    Write the docker-compose.yml

    Four services. All on the same internal network laravel. Volumes persist database data and Laravel storage across container restarts.

    yaml
    services:
      php:
        build:
          context: .
          target: runtime
        volumes:
          - ./storage:/var/www/storage
        env_file: .env
        networks:
          - laravel
    
      nginx:
        image: nginx:1.27-alpine
        ports:
          - "80:80"
        volumes:
          - ./public:/var/www/public:ro
          - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
        depends_on:
          - php
        networks:
          - laravel
    
      mysql:
        image: mysql:8.4
        environment:
          MYSQL_ROOT_PASSWORD: "${DB_PASSWORD}"
          MYSQL_DATABASE: "${DB_DATABASE}"
        volumes:
          - mysql_data:/var/lib/mysql
        networks:
          - laravel
    
      redis:
        image: redis:7.4-alpine
        networks:
          - laravel
    
    volumes:
      mysql_data:
    
    networks:
      laravel:
  4. 4

    Create a .dockerignore file

    Without this, Docker's build context includes vendor/ and node_modules/ — hundreds of MB sent to the daemon on every build, even when nothing changed.

    bash
    vendor/
    node_modules/
    .git/
    .env
    .env.*
    storage/logs/*
    storage/framework/cache/*
    storage/framework/sessions/*
    storage/framework/views/*
    tests/
    docker/
    *.md
  5. 5

    Run migrations via an entrypoint script

    Migrations need to run after mysql is healthy, not just started. Use a small entrypoint that waits for the DB before running artisan.

    bash
    #!/bin/sh
    # docker/php/entrypoint.sh
    
    set -e
    
    echo "Waiting for MySQL..."
    until php -r "new PDO('mysql:host=\${DB_HOST};dbname=\${DB_DATABASE}', '\${DB_USERNAME}', '\${DB_PASSWORD}');" 2>/dev/null; do
      sleep 1
    done
    
    php artisan migrate --force
    php artisan optimize
    php artisan storage:link --force
    
    exec "\$@"
  6. 6

    Wire the entrypoint into the Dockerfile

    Add two lines to the runtime stage, just before the final CMD. The ENTRYPOINT runs the migration script; CMD passes php-fpm as the argument to "$@".

    dockerfile
    # Add to the runtime stage in your Dockerfile (as root, before USER laravel)
    COPY docker/php/entrypoint.sh /usr/local/bin/entrypoint.sh
    RUN chmod +x /usr/local/bin/entrypoint.sh
    
    USER laravel
    ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
    CMD ["php-fpm"]
  7. 7

    Set environment variables for containers

    Your regular .env file works — just change the host names to match Compose service names. MySQL is mysql, Redis is redis, not 127.0.0.1.

    bash
    APP_ENV=production
    APP_KEY=base64:your_key_here
    APP_DEBUG=false
    APP_URL=https://example.com
    
    DB_CONNECTION=mysql
    DB_HOST=mysql
    DB_PORT=3306
    DB_DATABASE=laravel
    DB_USERNAME=root
    DB_PASSWORD=secret
    
    REDIS_HOST=redis
    REDIS_PORT=6379
    
    CACHE_STORE=redis
    SESSION_DRIVER=redis
    QUEUE_CONNECTION=redis
  8. 8

    Build and start the stack

    Build the PHP image and start all four containers. Watch the logs to confirm PHP-FPM and Nginx come up cleanly.

    bash
    # Build and start (detached)
    docker compose up -d --build
    
    # Watch all logs
    docker compose logs -f
    
    # Confirm all containers are running
    docker compose ps
    
    # Run artisan commands inside the container
    docker compose exec php php artisan tinker
    
    # Stop everything (keeps volumes)
    docker compose down
    
    # Stop and wipe database (DANGER: deletes data)
    docker compose down -v
  9. 9

    Production hardening checklist

    Before deploying to production, apply these changes:

    • Non-root user — already done in the Dockerfile above (user laravel)
    • No dev dependencies — already handled by composer install --no-dev
    • Read-only storage volume — mount only storage/, not the whole app
    • Health checks — add healthcheck blocks in Compose so dependent services wait properly
    • Secrets management — use Docker secrets or a secrets manager (Vault, AWS SSM) instead of a plain .env file in production
    • Image scanning — run docker scout cves or Trivy against your final image before pushing
    yaml
    # Add health check to mysql service in docker-compose.yml
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    
    # Scan for vulnerabilities
    docker scout cves local://your-image:latest

Tips & gotchas

  • Use <code>docker compose watch</code> (Compose v2.22+) for local development — it syncs file changes into the container without rebuilding.
  • Pin exact image tags in production (<code>php:8.3.9-fpm-alpine</code>, not <code>php:8.3-fpm-alpine</code>) to prevent surprise upgrades from breaking your build.
  • The <code>opcache.validate_timestamps=0</code> PHP setting is essential for production — it prevents OPcache from stat()-ing every file on each request. Set it in <code>docker/php/opcache.ini</code>.
  • Never bake secrets into the image. Docker build args become part of the image layer history — anyone with image access can read them with <code>docker history</code>.
  • Run <code>docker compose exec php php artisan queue:work</code> or add a dedicated <code>worker</code> service in Compose for background jobs.

Wrapping up

You now have a four-container Laravel stack that mirrors production, runs with a single docker compose up, and produces a lean image via multi-stage builds. The same docker-compose.yml can be handed to any developer on the team and they'll have the identical environment in minutes — no more "works on my machine" conversations.

#Docker #Laravel #DevOps
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.