Dockerize Laravel: Multi-Stage Dockerfile and Docker Compose Guide - NextGenBeing
Back to discoveries

Dockerize Laravel: Multi-Stage Dockerfile and Docker Compose Guide

Dockerize Laravel with a tested multi-stage Dockerfile, nginx and Compose queue worker: 771 MB down to 187 MB, 19.7s rebuilds, plus the MySQL healthcheck trap.

Web Development 9 min read
Bekzod Erkinov

Bekzod Erkinov

Nov 9, 2026 • 2 views
Size:
Height:
9 min read 3,289 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
Table of contents · 9 sections

Dockerize Laravel: Multi-Stage Dockerfile and Docker Compose Guide

A single-stage Laravel image can carry Composer, Node and your dev dependencies into the container that serves production traffic. This guide builds a multi-stage image plus a Compose stack with nginx and a queue worker, runs it, and compares it against a naive single-stage build of the same app. It also covers the healthcheck mistake that made my database report "healthy" while refusing connections.

Affiliate disclosure: this post contains no affiliate links. Every tool is linked to its official site or docs.

Quick verdict

Pick this if Setup
You deploy to a VPS or any container host Multi-stage production target, php-fpm-alpine, separate nginx image
You run on Laravel Cloud or Forge Probably skip it. Check the Laravel deployment docs for what those platforms manage; I did not test either

Pros: no Composer, Node or dev packages in the runtime image; fast rebuilds after code edits; php-fpm runs as www-data; the queue worker reuses the same image. Cons: more files to maintain (Dockerfile, entrypoint, nginx config, Compose); the cold build was no faster; you must handle migrations yourself.

What I tested

All numbers below come from one machine: Windows 11 Pro, Intel Core i7-8550U, 32 GB RAM, Docker running Linux containers. Every build and timing was run once, not averaged, so treat the timings as indicative.

Versions, each checkable with a command:

$ docker version --format '{{.Server.Version}}'
29.7.2
$ docker compose version
Docker Compose version v5.3.1
$ composer show laravel/framework      # inside composer:2, in the app directory
versions : * v13.40.0

The app came from composer create-project laravel/laravel; its composer.json asks for laravel/framework ^13.17 and php ^8.3, and the lock file resolved v13.40.0. Images: php:8.4-fpm-alpine, mysql:8.4, nginx:1.27-alpine.

I used the Laravel 13.x deployment docs. I fetched that page and confirmed it covers the extension list, php artisan optimize, the /up health route, APP_DEBUG, reloading long-running services and FrankenPHP.

The docs list the required PHP extensions (Ctype, cURL, DOM, Fileinfo, Mbstring, OpenSSL, PDO, Tokenizer, XML and others). Most were already in the base image. I only added pdo_mysql, opcache, bcmath and pcntl, using the community install-php-extensions script, which the official PHP image page describes as a tool outside the images.

The baseline: a naive single-stage image

To see what multi-stage removes, I first built a single-stage Dockerfile:

FROM php:8.4-fpm-alpine
COPY --from=ghcr.io/mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions pdo_mysql opcache bcmath pcntl
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN apk add --no-cache nodejs npm
WORKDIR /var/www/html
COPY . .
RUN composer install --prefer-dist --no-interaction && npm install --no-audit --no-fund && npm run build

It works. It is also 771 MB, and inside it:

$ docker run --rm lv-naive sh -c 'which composer node; ls vendor/bin; du -sh vendor node_modules'
/usr/bin/composer
/usr/bin/node
carbon patch-type-declarations php-parse phpunit pint psysh var-dump-server
75.1M	vendor
87.7M	node_modules

Composer, Node, PHPUnit, Pint and 88 MB of node_modules sit in the image that faces the internet, and none is needed at runtime. Also, COPY . . comes before composer install, so any file edit invalidates the install layer. Appending one line to routes/web.php and rebuilding took 1m34s, close to the cold build (1m57s).

The multi-stage Dockerfile

# syntax=docker/dockerfile:1
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction
COPY . .
RUN composer dump-autoload --optimize --no-dev --classmap-authoritative

FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY resources ./resources
COPY vite.config.js ./
COPY public ./public
RUN npm run build

FROM php:8.4-fpm-alpine AS base
COPY --from=ghcr.io/mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions pdo_mysql opcache bcmath pcntl
WORKDIR /var/www/html

FROM base AS production
COPY --from=vendor /app .
COPY --from=assets /app/public/build ./public/build
RUN mkdir -p storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache \
 && chown -R www-data:www-data storage bootstrap/cache
COPY docker-entrypoint.sh /usr/local/bin/entrypoint
RUN chmod +x /usr/local/bin/entrypoint
USER www-data
ENTRYPOINT ["entrypoint"]
CMD ["php-fpm"]

FROM base AS dev
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

FROM nginx:1.27-alpine AS web
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=production /var/www/html/public /var/www/html/public

The mechanics come from Docker's multi-stage docs: named stages, COPY --from, and --target to stop at a stage. Per those docs BuildKit builds only the stages your target depends on. The dev stage is a bare PHP image with Composer; I built it but did not write or test a dev Compose override for it, so this guide only covers production and web.

Three details matter more than the stage count:

  • Manifests first. The vendor stage copies only composer.json and composer.lock before installing, so the install layer is rebuilt only when those two files change.
  • --no-scripts --no-autoloader, then dump-autoload. Composer's post-install scripts call artisan, which isn't copied yet at that point. Generating the autoloader after COPY . . avoids that, and --classmap-authoritative makes Composer rely on its generated classmap alone.
  • The entrypoint. It runs php artisan optimize before exec "$@", so config, events, routes and views are cached from the real container environment:
#!/bin/sh
set -e
php artisan optimize
exec "$@"

The composer:2 PHP version does not match php:8.4

The vendor stage runs on the composer:2 image, whose PHP is not the runtime PHP. In my build it was different:

$ docker run --rm composer:2 sh -c 'php -v | head -1'
PHP 8.5.11 (cli) (built: Sep 24 2026 19:10:01) (NTS)
$ docker run --rm lv-prod sh -c 'php -v | head -1'     # runtime image
PHP 8.4.27 (cli) (built: Nov  5 2026 19:02:14) (NTS)

Composer therefore resolved against PHP 8.5 while the app runs on 8.4. I did not pass --ignore-platform-req, and the install succeeded. To confirm the result still suits the runtime, I ran the generated platform check inside the production image:

$ docker run --rm lv-prod sh -c 'grep -n PHP_VERSION_ID vendor/composer/platform_check.php; php -r "require \"vendor/composer/platform_check.php\"; echo \"platform_check ok on \".PHP_VERSION;"'
7:if (!(PHP_VERSION_ID >= 80401)) {
platform_check ok on 8.4.27

The check requires PHP 8.4.1 or newer, which comes from the lock file, and the runtime image passes. If you want the two to match for certain, pin the stage to a Composer image on the same PHP minor, or copy the binary with COPY --from=composer:2 /usr/bin/composer into a php:8.4 stage. I didn't try either.

.dockerignore

.git
vendor
node_modules
.env
storage/logs/*
storage/framework/*/*
public/build

Compose, nginx and a queue worker

nginx.conf, copied into the web image:

server {
    listen 80;
    root /var/www/html/public;
    index index.php;
    location / { try_files $uri $uri/ /index.php?$query_string; }
    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
    location ~ /\.(?!well-known) { deny all; }
}

fastcgi_pass app:9000 points at the PHP container. compose.yaml:

services:
  app:
    build: { context: ., target: production }
    environment: &env
      APP_ENV: production
      APP_DEBUG: "false"
      APP_KEY: base64:...   # generate your own
      DB_CONNECTION: mysql
      DB_HOST: db
      DB_DATABASE: laravel
      DB_USERNAME: laravel
      DB_PASSWORD: secret
    depends_on:
      db: { condition: service_healthy }
  queue:
    build: { context: ., target: production }
    command: php artisan queue:work --tries=3 --max-time=3600
    restart: unless-stopped
    environment: *env
    depends_on:
      db: { condition: service_healthy }
  web:
    build: { context: ., target: web }
    ports: ["8080:80"]
    depends_on: [app]
  db:
    image: mysql:8.4
    environment: { MYSQL_DATABASE: laravel, MYSQL_USER: laravel, MYSQL_PASSWORD: secret, MYSQL_ROOT_PASSWORD: root }
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
      interval: 5s
      retries: 20

condition: service_healthy only works if the dependency defines a healthcheck, per the Compose startup-order docs. The queue service reuses the production image with a different command, so it needs no extra build.

The healthcheck that lied

My first Compose file used -h localhost in the healthcheck. Compose reported the database healthy, app started, and then:

$ curl localhost:8080/
GET / 500
SQLSTATE[HY000] [2002] Connection refused

I observed the symptom but did not inspect MySQL's first-run initialization directly. My explanation: mysqladmin ping -h localhost talks over the Unix socket, so it can succeed while TCP port 3306 is still closed, and the app connects over TCP. Changing the host to 127.0.0.1 forces TCP, and the healthcheck then waited for the real server. After that change, from a clean down -v:

$ docker compose up -d --wait
$ docker compose exec app php artisan migrate --force
  0001_01_01_000000_create_users_table ......... 487.76ms DONE
  0001_01_01_000001_create_cache_table ......... 185.95ms DONE
  0001_01_01_000002_create_jobs_table .......... 260.84ms DONE
GET / -> 200
GET /up -> 200

/up is the health route from the Laravel docs. It returned 200 even during the failure above, because it doesn't touch the database. A passing /up tells you PHP and nginx are alive, not that the app works.

The queue worker crash-loops until you migrate

On a fresh volume the queue container exited with Table 'laravel.jobs' doesn't exist, because the worker started before I ran migrations. With restart: unless-stopped it retried, and in my single run it stayed up roughly 25 seconds after migrate finished:

$ docker compose exec queue sh -c 'ps | grep queue:work'
    1 www-data  0:00 php artisan queue:work --tries=3 --max-time=3600

In production, run migrations as a deploy step before the workers start.

Results

Check Naive single stage Multi-stage production
composer / node on PATH Both present Neither
vendor/bin/phpunit and pint Present Absent
vendor size 75.1 MB 47.4 MB
node_modules 87.7 MB Not in image
php-fpm master process root www-data
php-fpm pool workers www-data www-data
Image size 771 MB 187 MB
Cold build (single run) 1m57s about 1m54s
Rebuild after appending a line to routes/web.php (single run) 1m34s 19.7s

The user row needs care. Both images ran php-fpm and I listed the processes:

$ docker exec fpmnaive ps -o user,pid,args        # naive
root         1 php-fpm: master process (/usr/local/etc/php-fpm.conf)
www-data     7 php-fpm: pool www
www-data     8 php-fpm: pool www

$ docker exec fpmprod ps -o user,pid,args         # production, USER www-data
www-data     1 php-fpm: master process (/usr/local/etc/php-fpm.conf)
www-data    10 php-fpm: pool www
www-data    11 php-fpm: pool www

So in the naive image the master is root and only the workers drop to www-data; in the production image everything is www-data. php-fpm started cleanly as non-root, listened on port 9000, and logged only two notices:

NOTICE: [pool www] 'user' directive is ignored when FPM is not running as root
NOTICE: [pool www] 'group' directive is ignored when FPM is not running as root
NOTICE: fpm is running, pid 1
NOTICE: ready to handle connections

The web image (nginx plus public assets) is 74 MB. The dev target measured 136 MB, but it has no vendor/ and I did not run it under Compose, so don't compare it to the others.

The size gap is 771 MB against 187 MB. docker history on the production image shows where the weight sits: the PHP base with extensions is most of it, and the application layer, COPY /app ., is 50.6 MB. The built Vite assets are 217 kB. The cold build was no faster in my run, so don't expect multi-stage to help there. The gain I measured is the 19.7s rebuild versus 1m34s, which you feel on every deploy.

Gotchas I hit

  1. Alpine's sh is BusyBox, not bash. Brace expansion is not performed: sh -c 'echo {a,b}' in the PHP image printed {a,b}. My first mkdir -p storage/framework/{cache,sessions,views} would have created one folder literally named {cache,sessions,views}, so I spelled the paths out.
  2. Don't bind-mount ./public into nginx. My first Compose file did that. The Vite output in public/build only exists inside the image, so the host folder hides it and your CSS and JS would 404. Copying public from the production stage into the web image fixed it.
  3. config:cache and env(). The Laravel docs say that after config:cache, .env isn't loaded and env calls return null. Pass real environment variables to the container and call env() only inside config/*.php.
  4. Run optimize at start, not by hand. Running it with docker compose exec only caches inside that one container's filesystem and it vanishes on recreate. With the entrypoint above, bootstrap/cache held config.php, events.php and routes-v7.php after a plain up, and artisan about showed Config, Events, Routes and Views all as CACHED.
  5. The APP_KEY in Compose is a placeholder. Use secrets or your host's env management in real deployments. For hardening beyond the container, see Laravel security hardening against the OWASP Top 10.
  6. Workers need a restart per release. The Laravel docs say long-running services such as queue workers should be reloaded after deploy. With Compose, recreating the queue container does that, and --max-time=3600 makes the worker exit and restart on its own every hour.

Who should use this and who should skip

Use it if you run your own VPS or container host and want reproducible builds. It also suits apps where you'll scale workers separately, such as multi-tenant SaaS on Laravel and React, because the worker is just another service on the same image.

Skip it if the platform you deploy to builds the runtime for you. Also skip the nginx-plus-fpm split if you want a single container; FrankenPHP, which the Laravel docs mention, serves PHP itself. I didn't test it.

Next steps: copy the full setup

This post shows every file the stack needs, so you can copy it directly:

  1. Dockerfile, docker-entrypoint.sh, nginx.conf, .dockerignore and compose.yaml from the sections above, in your project root.
  2. Run docker build --target production . and confirm which composer returns nothing in the result.
  3. Generate your own APP_KEY and move it and the DB credentials to your host's secrets, not the Compose file.
  4. Run docker compose up -d --wait, then docker compose exec app php artisan migrate --force, then check /up.
  5. Commit package-lock.json and switch npm install to npm ci. My fresh scaffold had no lockfile, so I used install.
Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.