Bekzod Erkinov
Listen to Article
Loading...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
vendorstage copies onlycomposer.jsonandcomposer.lockbefore installing, so the install layer is rebuilt only when those two files change. --no-scripts --no-autoloader, thendump-autoload. Composer's post-install scripts callartisan, which isn't copied yet at that point. Generating the autoloader afterCOPY . .avoids that, and--classmap-authoritativemakes Composer rely on its generated classmap alone.- The entrypoint. It runs
php artisan optimizebeforeexec "$@", 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
- Alpine's
shis BusyBox, not bash. Brace expansion is not performed:sh -c 'echo {a,b}'in the PHP image printed{a,b}. My firstmkdir -p storage/framework/{cache,sessions,views}would have created one folder literally named{cache,sessions,views}, so I spelled the paths out. - Don't bind-mount
./publicinto nginx. My first Compose file did that. The Vite output inpublic/buildonly exists inside the image, so the host folder hides it and your CSS and JS would 404. Copyingpublicfrom theproductionstage into thewebimage fixed it. config:cacheandenv(). The Laravel docs say that afterconfig:cache,.envisn't loaded andenvcalls returnnull. Pass real environment variables to the container and callenv()only insideconfig/*.php.- Run
optimizeat start, not by hand. Running it withdocker compose execonly caches inside that one container's filesystem and it vanishes on recreate. With the entrypoint above,bootstrap/cacheheldconfig.php,events.phpandroutes-v7.phpafter a plainup, andartisan aboutshowed Config, Events, Routes and Views all as CACHED. - The
APP_KEYin 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. - 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
queuecontainer does that, and--max-time=3600makes 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:
Dockerfile,docker-entrypoint.sh,nginx.conf,.dockerignoreandcompose.yamlfrom the sections above, in your project root.- Run
docker build --target production .and confirmwhich composerreturns nothing in the result. - Generate your own
APP_KEYand move it and the DB credentials to your host's secrets, not the Compose file. - Run
docker compose up -d --wait, thendocker compose exec app php artisan migrate --force, then check/up. - Commit
package-lock.jsonand switchnpm installtonpm ci. My fresh scaffold had no lockfile, so I usedinstall.
Keep reading
Complete Solution: Scaling a Node.js Application with Kubernetes and Docker
29 min · 1,029 views
Data ScienceMastering CI/CD Pipelines with Jenkins and Docker: A Deep Dive into Automated Deployment and Testing
14 min · 1,010 views
Web DevelopmentMastering Multi-Tenant SaaS Architecture Patterns for Enterprise Applications
27 min · 970 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
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