Step-by-step
-
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
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
Write the docker-compose.yml
Four services. All on the same internal network
laravel. Volumes persist database data and Laravel storage across container restarts.yamlservices: 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
Create a .dockerignore file
Without this, Docker's build context includes
vendor/andnode_modules/— hundreds of MB sent to the daemon on every build, even when nothing changed.bashvendor/ node_modules/ .git/ .env .env.* storage/logs/* storage/framework/cache/* storage/framework/sessions/* storage/framework/views/* tests/ docker/ *.md -
5
Run migrations via an entrypoint script
Migrations need to run after
mysqlis 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
Wire the entrypoint into the Dockerfile
Add two lines to the
runtimestage, just before the finalCMD. TheENTRYPOINTruns the migration script;CMDpassesphp-fpmas 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
Set environment variables for containers
Your regular
.envfile works — just change the host names to match Compose service names. MySQL ismysql, Redis isredis, not127.0.0.1.bashAPP_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
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
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
healthcheckblocks in Compose so dependent services wait properly - Secrets management — use Docker secrets or a secrets manager (Vault, AWS SSM) instead of a plain
.envfile in production - Image scanning — run
docker scout cvesor 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 - Non-root user — already done in the Dockerfile above (user
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.