Most PHP Docker images are bigger than they need to be for one reason: the image that runs your app is the same image that built it. Composer, Git, Node, npm, compilers and every cache they produced sit in the final layers even though PHP-FPM never touches them. A multi-stage build fixes that by building in throwaway stages and copying only the finished result into a small runtime image. This guide walks through a complete multi-stage Dockerfile for a Laravel app, explains each decision, and shows how to measure the result on your own project instead of trusting a number in a blog post.
Why the image gets big in the first place
A Docker image is a stack of read-only layers. Each RUN, COPY and ADD instruction adds one, and a layer is never edited afterwards. That has a consequence people trip over constantly: deleting a file in a later instruction hides it, but the bytes stay in the earlier layer and still count toward the image size.
RUN npm ci # adds node_modules to the image
RUN rm -rf node_modules # hides it, does not shrink the image
Here is the kind of single-stage Dockerfile that produces an oversized image. It works, which is why it survives for years:
FROM php:8.5-cli
RUN apt-get update && apt-get install -y git unzip nodejs npm
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY . .
RUN docker-php-ext-install pdo_mysql bcmath pcntl
RUN composer install
RUN npm ci && npm run build
CMD ["php", "artisan", "serve", "--host=0.0.0.0"]
Count what ends up in production: the Debian-based PHP image, Git, Node and npm, the apt package lists, Composer, dev dependencies from composer install (no --no-dev), the whole node_modules directory, and your .git history if nothing excludes it. It also runs the development server instead of a real PHP process manager. None of that is needed at runtime, and several parts are a security liability.
Start with the base image: it is the biggest single choice
Before any multi-stage work, look at what the base image costs. These are the compressed sizes Docker Hub reports for the linux/amd64 images, read from the Docker Hub API on 2026-10-13:
| Image | Compressed size (Docker Hub) |
|---|---|
php:8.5-cli |
189.4 MB |
php:8.5-fpm |
177.6 MB |
php:8.5-apache |
181.7 MB |
php:8.5-cli-alpine |
47.1 MB |
php:8.5-fpm-alpine |
38.7 MB |
composer:2 |
77.7 MB |
node:lts-alpine |
61.6 MB |
Two things to keep in mind when reading this table. First, Docker Hub shows compressed sizes, while docker images shows the uncompressed size on disk, which is larger. Compare like with like. Second, these tags move: a new patch release changes the numbers a little, so treat them as a snapshot, not a promise.
The gap between fpm and fpm-alpine is the largest saving available, and you get it just by changing the tag. The multi-stage build then makes sure the extra tools you need for building never reach that small base.
Alpine is not free. It uses musl instead of glibc, so a few things behave differently: some precompiled binaries and PECL extensions assume glibc, and locale and DNS edge cases show up occasionally. If you hit one, php:8.5-fpm (Debian) with the same multi-stage structure still removes the build tooling. Test your app on Alpine before you commit to it.
The multi-stage Dockerfile
The idea is one stage per job. Only the last stage becomes your image. Earlier stages exist to produce files, and COPY --from= pulls those files forward.
# ---------- Stage 1: PHP dependencies ----------
FROM composer:2 AS vendor
WORKDIR /app
# Copy only the manifests first so this layer is cached
# until composer.json or composer.lock changes.
COPY composer.json composer.lock ./
RUN composer install \
--no-dev --no-scripts --no-autoloader \
--no-progress --no-interaction \
--ignore-platform-reqs
# Now the source, so the optimized autoloader can map your classes.
COPY . .
RUN composer dump-autoload \
--no-dev --no-scripts --optimize --classmap-authoritative
# ---------- Stage 2: front-end assets ----------
FROM node:lts-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY vite.config.js ./
COPY resources ./resources
RUN npm run build
# ---------- Stage 3: runtime ----------
FROM php:8.5-fpm-alpine AS runtime
RUN set -eux; \
docker-php-ext-install -j"$(nproc)" pdo_mysql bcmath pcntl; \
cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"; \
echo 'opcache.validate_timestamps=0' > "$PHP_INI_DIR/conf.d/zz-opcache.ini"
WORKDIR /var/www/html
COPY . .
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
# Only the directories PHP must write to are owned by www-data.
RUN chown -R www-data:www-data storage bootstrap/cache
COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/app-entrypoint
ENTRYPOINT ["app-entrypoint"]
CMD ["php-fpm"]
EXPOSE 9000
Save it as Dockerfile in the project root. The entrypoint script it references goes in docker/entrypoint.sh:
#!/bin/sh
set -e
# Cache config at container start, when the real environment variables exist.
php artisan config:cache
exec "$@"
What each decision does
Manifests before source. Docker reuses a cached layer only if everything before it is unchanged. Copying composer.json and composer.lock alone, installing, and only then copying the rest means a code change does not reinstall every dependency. Putting COPY . . first is the most common reason builds feel slow.
Composer flags. --no-dev leaves out your require-dev packages (PHPUnit, Pest, Faker and so on). --optimize builds a classmap, and --classmap-authoritative tells Composer to load classes only from that map instead of probing the filesystem for classes that are not in it. The trade-off is that a class added to the container after the autoloader was dumped will not be found, which is exactly what you want for an immutable image and exactly what breaks if you mount code over it at runtime.
--no-scripts. Laravel's composer.json runs php artisan package:discover after autoload dumps. In the Composer stage there is no application environment to run that in, so it is skipped. Laravel builds its package manifest on first boot if it is missing, which is why bootstrap/cache has to be writable by the PHP user. If you prefer to generate it at build time, add RUN php artisan package:discover --ansi to the runtime stage.
--ignore-platform-reqs. The composer:2 image ships a PHP with only some extensions, so Composer would refuse to install packages that require ext-pcntl or similar. This flag turns that check off. The cost is that Composer no longer protects you from a real mismatch, so verify the runtime image has everything you need (a check for that is below). A tighter option is to ignore only specific extensions with --ignore-platform-req=ext-pcntl.
Node never reaches the runtime image. The assets stage runs npm ci and the Vite build, then only public/build is copied forward. Node, npm and node_modules stay behind. If your CSS has @source lines that point into vendor/, as Tailwind-based starters can, copy the vendor directory into the assets stage as well with COPY --from=vendor /app/vendor ./vendor before the build.
Extensions. The official image has few extensions by default, and a Laravel app usually needs pdo_mysql, bcmath and often pcntl for queue workers. docker-php-ext-install compiles them from the PHP source bundled in the image. For extensions that link against system libraries, install the development headers, build, then remove them in the same RUN while keeping the runtime libraries. Removing them in a later RUN would not shrink the image:
RUN set -eux; \
apk add --no-cache icu-libs libzip; \
apk add --no-cache --virtual .build-deps icu-dev libzip-dev; \
docker-php-ext-install -j"$(nproc)" intl zip; \
apk del .build-deps
Installing icu-libs and libzip explicitly first matters: they are marked as wanted, so deleting the virtual .build-deps group does not take them with it.
OPcache on PHP 8.5. OPcache is built into PHP 8.5 and always loaded. Do not run docker-php-ext-install opcache or add zend_extension=opcache.so on 8.5; those are for older versions. Setting opcache.validate_timestamps=0 is valid and recommended for an immutable image, because the files can never change underneath PHP. It also means a code change requires a new container, which is how it should work anyway.
Ownership without duplicating layers. A RUN chown -R www-data:www-data /var/www/html after copying the code creates a new layer containing a changed copy of every file, nearly doubling the application's footprint. Limiting chown to storage and bootstrap/cache keeps that layer tiny and keeps your application code read-only for the PHP user, which is also better security.
config:cache at startup, not build time. php artisan config:cache reads environment variables and writes their values into a cached file. Run it during docker build and your build-time values, or none at all, get baked into the image for every environment. Once configuration is cached, the .env file is not loaded and env() calls outside config files return null. Running it in the entrypoint uses the variables the container actually receives. For the same reason, never COPY a .env file into the image; pass secrets at runtime.
.dockerignore is part of the optimization
Without it, COPY . . sends your whole working directory to the build, including things that should never be in an image and things that bust the cache whenever they change:
.git
node_modules
vendor
.env
.env.*
public/hot
public/build
storage/logs/*.log
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*.php
Excluding vendor and public/build is deliberate: the stages produce them, and a stale local copy must not overwrite the fresh one. The storage patterns keep local logs and compiled views out while leaving the directory skeleton (the .gitignore placeholder files) so Laravel still finds the folders it needs.
Serving it: FPM needs a web server
php-fpm speaks FastCGI on port 9000, not HTTP, so this image is paired with a web server container. Nginx needs the static files in public/, and it reaches PHP over the network. A minimal Laravel server block looks like this, with app being the service name of the PHP container:
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;
}
}
Both containers need the public directory: Nginx serves the files itself, and PHP-FPM needs index.php at the same path. Either build a small Nginx image that copies public/ out of the runtime stage, or share it through a volume. If you would rather have one container, php:8.5-apache serves HTTP directly, at the price of the larger Debian base shown in the table.
Build it and measure the difference yourself
Do not take a before-and-after number from anyone's blog, including this one. Your dependencies, your extensions and your assets decide the result. Build the old and new Dockerfiles side by side and compare:
docker build -f Dockerfile.single -t myapp:fat .
docker build -t myapp:slim .
docker images myapp --format 'table {{.Tag}}\t{{.Size}}'
To see where the remaining weight is, list the layers with their sizes:
docker history myapp:slim
docker image inspect -f '{{.Size}}' myapp:slim # exact size in bytes
You can also build a single stage for debugging, which is handy when the Composer or Vite step fails:
docker build --target vendor -t myapp:vendor .
docker run --rm -it myapp:vendor sh
Then confirm the runtime image has what the app needs, since --ignore-platform-reqs skipped that check at build time:
docker run --rm --entrypoint php myapp:slim -m | grep -E 'pdo_mysql|bcmath|pcntl'
docker run --rm --entrypoint php myapp:slim --ri "Zend OPcache" | head
The --entrypoint php bypasses the startup script so the command does not run config:cache first. If an extension is missing, the app fails with an error such as could not find driver the first time it touches the database, so it is far better to catch it here.
Common problems and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Build reinstalls all Composer packages on every change |
COPY . . comes before composer install
|
Copy composer.json and composer.lock first |
vendor/autoload.php not found |
vendor was never copied from its stage, or .dockerignore removed it and nothing replaced it |
Keep COPY --from=vendor /app/vendor ./vendor
|
could not find driver |
pdo_mysql missing in the runtime stage |
Install it with docker-php-ext-install in the final stage |
Permission denied writing to storage or bootstrap/cache
|
PHP runs as www-data but the folders belong to root |
Keep the targeted chown
|
Config values are null or wrong per environment |
config:cache ran at build time, or env() is used outside config/
|
Run it in the entrypoint, read values through config()
|
| Image is still large after a cleanup step | Files were deleted in a later layer | Install and remove in the same RUN, or move the work to a build stage |
| Styles missing after the build | Vite output was not copied, or public/build is not served by the web container |
Check COPY --from=assets and that Nginx sees the same public/
|
Production considerations
-
Pin your tags.
php:8.5-fpm-alpinemoves with every patch release. That is fine for staying patched, but make sure your CI rebuilds on a schedule and tests before deploying. For fully reproducible builds, pin to an image digest. - Smaller is also safer. A runtime image without Git, Composer, Node or a compiler gives an attacker who gets code execution fewer tools to work with, and there is less software to patch.
- Reuse the build cache in CI. A fresh CI runner has no layer cache, so the dependency stages rebuild every time. Use your CI's Docker layer caching or BuildKit's registry cache export to avoid it.
-
Queue workers and the scheduler. Use the same image with a different command (
php artisan queue:work,php artisan schedule:work) instead of building separate images. The entrypoint still runsconfig:cachefirst. -
Do not mount your code over the image in production. A bind mount hides the baked-in files, and with
validate_timestamps=0and an authoritative classmap, changed files will not be picked up. Bind mounts are for local development with a separate compose file.
Frequently asked questions
Does a multi-stage build make the build itself faster?
Not by itself. The speed comes from caching: stages that did not change are reused, and BuildKit skips stages the final image does not depend on. The main win from multi-stage builds is image size and what is inside the image.
Should I use Alpine or Debian for PHP?
Alpine gives a much smaller base, as the table above shows. Debian is the safer choice if you rely on glibc-specific binaries or extensions. The multi-stage structure is the same either way.
Can I run composer install inside the runtime stage instead?
You can, but then Composer, its cache and any build tools it needs stay in the final image unless you install them and remove them in one layer. A separate vendor stage keeps that out cleanly.
Why not run php artisan config:cache in the Dockerfile?
Because it freezes the environment variables present at build time. The same image should run in staging and production with different settings, so the cache is created when the container starts.
References: Docker: Multi-stage builds, PHP official image on Docker Hub, docker-library/php, Composer official image, Composer: dump-autoload and Laravel: Deployment.
Originally published on DEV Talk.
Top comments (0)