◀ Back to blog
Docker

Multi-stage Docker builds for PHP

Published on 15 Jan 2024· 7 min read
#Docker#PHP#Optimisation

Why use multi-stage builds?

Multi-stage builds let you separate the build environment from the runtime environment. The result: lighter and more secure images.

The principle is simple: a single Dockerfile contains several FROM instructions. Each one starts a new stage, with its own base image. Intermediate stages install build tools, download dependencies or compile assets; the final stage only picks up the result, with COPY --from. Everything that is not explicitly copied (compilers, package caches, unneeded sources) is gone from the shipped image.

The problem with monolithic images

A classic PHP image with all its development dependencies can easily exceed 1 GB. In production, you need neither Composer, nor test tools, nor uncompiled source files.

That weight has concrete consequences: slower deployments because every server has to download the image, a registry that fills up faster, and above all a larger attack surface. Every binary in the image (compiler, Git client, package manager) is one more tool available to an attacker, and one more source of alerts in vulnerability scanners.

Multi-stage Dockerfile example

# Stage 1: Build
FROM composer:2 AS builder
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --no-dev --optimize

# Stage 2: Production
FROM php:8.3-fpm-alpine
RUN docker-php-ext-install pdo_mysql opcache
COPY --from=builder /app /var/www/html
EXPOSE 9000
CMD ["php-fpm"]

The first stage, named builder, starts from the official Composer image. It first copies only composer.json and composer.lock, then installs the dependencies: as long as those two files do not change, Docker reuses the cached layer and the installation is not re-run, even if the application code has changed. Scripts and autoloader generation are deferred (--no-scripts, --no-autoloader) because the code is not there yet: once it has been copied, composer dump-autoload --optimize generates an autoloader that includes the application's classes. The second stage starts from a PHP-FPM Alpine image, installs the required extensions before copying the code, so that this expensive layer stays cached, then picks up the whole /app directory.

A more complete version

In a real project, this Dockerfile is usually taken further: the process should not run as root, Composer's cache can be kept between two builds, the extension build dependencies should not remain in the image, and a Symfony application often has front-end assets to compile. Here is a more complete version:

# syntax=docker/dockerfile:1

# Stage 1: PHP dependencies
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs
COPY . .
RUN composer dump-autoload --no-dev --classmap-authoritative

# Stage 2: front-end assets
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY assets/ assets/
COPY webpack.config.js ./
RUN npm run build

# Stage 3: production
FROM php:8.3-fpm-alpine AS production
RUN apk add --no-cache icu-libs \
    && apk add --no-cache --virtual .build-deps icu-dev $PHPIZE_DEPS \
    && docker-php-ext-install intl pdo_mysql opcache \
    && apk del .build-deps
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
WORKDIR /var/www/html
COPY --from=vendor --chown=www-data:www-data /app ./
COPY --from=assets /app/public/build ./public/build
USER www-data

A few explanations:

  • --mount=type=cache keeps Composer's cache between two builds on the same machine, without ever writing it into the image.
  • --ignore-platform-reqs is needed because the Composer image has neither the same PHP version nor the same extensions as the final image. Composer generates a platform_check.php file that checks the PHP version at startup, so an incompatibility will be detected immediately.
  • --classmap-authoritative produces a complete classmap, generated after the code is copied, and avoids any filesystem lookup when a class is loaded.
  • The build dependencies (icu-dev, $PHPIZE_DEPS) are installed and removed in the same RUN instruction: only runtime libraries such as icu-libs remain in the image.
  • The assets stage brings in Node.js, which has no business being in production: only the public/build directory is copied.

Also remember the .dockerignore file. Without it, COPY . . sends the local vendor/ directory to the build, where it would overwrite the one the stage just installed, along with .git and your local environment files:

.git
vendor/
node_modules/
var/
.env.local
docker-compose*.yml

Building a specific stage

The --target option stops the build at the given stage. It is useful for debugging an intermediate stage, or for reusing the same Dockerfile with a development stage:

docker build --target production -t myapp:1.4.2 .
docker build --target vendor -t myapp:vendor .

# Compare sizes and inspect layers
docker image ls myapp
docker history myapp:1.4.2

With BuildKit, enabled by default in recent Docker versions, only the stages needed for the target are built, and independent stages (here vendor and assets) are built in parallel.

The same mechanism lets you describe a development stage that starts from the production image and only adds Xdebug and the test tools. Development and production then share the same base, and Docker Compose picks the stage to build through the build.target key.

Concrete benefits

  • Smaller size: from 800 MB down to less than 150 MB
  • Security: no development tools in production
  • Docker cache: each stage is cached independently
  • Reproducibility: the same result in every environment

Best practices

Use Alpine as the base image to minimize the attack surface. Install only the PHP extensions you need. Configure OPcache for production with optimal settings.

RUN echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.max_accelerated_files=20000" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini

Rather than a series of echo commands, a versioned docker/php/opcache.ini file copied into the image (as in the complete version above) is easier to read. It can also enable the preloading offered by Symfony and tune the realpath cache:

opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.preload=/var/www/html/config/preload.php
opcache.preload_user=www-data
realpath_cache_size=4096K
realpath_cache_ttl=600

With validate_timestamps=0, PHP no longer checks whether files have changed: that is exactly what you want in an immutable image, but it means you must never modify the code of a running container. A deployment is done by replacing the container.

Finally, order the Dockerfile instructions from least to most frequently changed (system packages, extensions, dependencies, then code) and pin base image versions so that two builds of the same commit produce the same result.

This approach is used successfully on platforms such as Keytchens to deploy Symfony applications with build times cut by 60%.