the Right Way, Optimized
A multi-stage Dockerfile consistently separates the build environment from the runtime. Builder stages with Composer, npm and build tools hand their output forward, without ever landing in the final image themselves. The result: smaller images, less attack surface and reproducible builds for PHP and Node projects.
Table of Contents
- 1. The Core Principle of Multi-Stage Dockerfiles
- 2. PHP Builder Stage: Composer and Extensions
- 3. Node Builder Stage: npm and Asset Builds
- 4. Combining PHP and Node in One Dockerfile
- 5. Layer Caching: Why Order Is Decisive
- 6. Custom Base Images for Consistent Environments
- 7. BuildKit: Parallelization and Mount Caches
- 8. Reducing Image Size Systematically
- 9. Single-Stage vs. Multi-Stage Dockerfiles Compared
- 10. Summary
- 11. FAQ
1. The Core Principle of Multi-Stage Dockerfiles
A multi-stage Dockerfile consists of several FROM blocks, each defining an independent build stage. Every stage has access to its own tools, files and packages. The COPY --from=stagename command lets you transfer files from one stage into another, without carrying along the source stage's build tools. The final image contains only what is explicitly copied into the last stage. Everything else (compilers, build dependencies, temporary files) stays in the intermediate stages and never becomes part of the shipped image.
The difference from single-stage Dockerfiles is fundamental: without multi-stage Dockerfiles, you either have to leave build tools in the production image or write awkward multi-step scripts that clean up intermediate files. Both paths lead to heavier, less secure images. The multi-stage Dockerfile solves the problem elegantly through separation of concerns: each stage does exactly one thing, and the final image inherits only the results, not the means.
2. PHP Builder Stage: Composer and Extensions
The PHP builder stage in a multi-stage Dockerfile has one clear job: install PHP dependencies via Composer and compile the PHP extensions needed for the build process. php:8.4-cli is a good base image; the CLI variant is leaner than FPM but still ships all the necessary extension build tools. The official Composer container provides the Composer binary, which gets pulled in via COPY --from=composer:2 /usr/bin/composer /usr/bin/composer.
What matters in the builder stage is separating system dependencies from PHP code: first, all extension dependencies (libicu-dev, libzip-dev, etc.) are installed and extensions compiled. Then a separate layer runs composer install. This order ensures the Composer layer is only invalidated when composer.lock changes, not when system packages change. In a multi-stage Dockerfile with long build times, this caching strategy is decisive.
# Multi-stage Dockerfile: PHP Builder Stage
# Installs PHP extensions and Composer dependencies
# -- Stage 1: php-deps ------------------------------------------------------
FROM php:8.4-cli AS php-deps
# Install system libraries required for PHP extensions
RUN apt-get update && apt-get install -y --no-install-recommends \
libicu-dev \
libzip-dev \
libxml2-dev \
libonig-dev \
&& rm -rf /var/lib/apt/lists/*
# Compile PHP extensions needed for application build
RUN docker-php-ext-install \
intl \
zip \
soap \
bcmath \
pdo_mysql \
opcache
# Bring in Composer binary from official image, no full Composer image needed
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
# Layer: composer dependencies, only invalidated when composer.lock changes
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--optimize-autoloader \
--no-scripts \
--no-interaction \
--prefer-dist
# Layer: application code, copied after dependencies for optimal caching
COPY src/ ./src/
COPY app/ ./app/
COPY bin/ ./bin/
3. Node Builder Stage: npm and Asset Builds
The Node builder stage in a multi-stage Dockerfile handles JavaScript and CSS builds. node:22-alpine works well as a base, since Alpine-based images are significantly smaller than Debian-based ones. The stage installs npm dependencies and runs the build command. The result, compiled CSS files, bundled JavaScript, optimized assets, is then copied into the PHP stage or directly into the runtime stage.
In a project using Tailwind CSS v4, the Node stage looks like this in practice: package.json and package-lock.json are copied first, then npm ci runs. Only after that do the Tailwind configuration and source files get copied, followed by npm run build. That keeps the npm ci layer cached as long as the lockfile does not change. In a multi-stage Dockerfile, this means node modules are not re-downloaded on every CSS-change commit, a considerable time saver on active projects.
4. Combining PHP and Node in One Dockerfile
Combining PHP and Node builder stages into a single multi-stage Dockerfile requires a clear structure: the Node stage produces the assets, the PHP stage compiles the code, and the runtime stage takes the best of both. Using COPY --from=node-builder /app/web/css/styles.min.css and COPY --from=php-deps /app/vendor ./vendor in the runtime stage, both results can be merged. Docker BuildKit runs both builder stages in parallel by default when there is no dependency between them, which saves time.
A common problem with combined multi-stage Dockerfiles is targeting the wrong stage. docker build --target runtime . builds only up to the specified stage and skips everything after it. That is useful for debugging and local development builds. For CI pipelines, you always build the complete final stage. Stage names should be chosen semantically: php-deps, node-assets, runtime, instead of generic names like stage1, stage2. That makes debugging easier and makes the multi-stage Dockerfile instantly understandable to new team members.
# Full multi-stage Dockerfile combining PHP and Node builders
# Both builder stages run in parallel with BuildKit
# -- Stage 1: node-assets ----------------------------------------------------
FROM node:22-alpine AS node-assets
WORKDIR /app
# Cache npm install layer separately from source code
COPY web/tailwind/package.json web/tailwind/package-lock.json ./web/tailwind/
RUN cd web/tailwind && npm ci --prefer-offline
# Copy Tailwind sources and compile
COPY web/tailwind/ ./web/tailwind/
RUN cd web/tailwind && npm run build
# Output: web/css/styles.min.css
# -- Stage 2: php-deps -------------------------------------------------------
FROM php:8.4-cli AS php-deps
RUN apt-get update && apt-get install -y --no-install-recommends \
libicu-dev libzip-dev libxml2-dev && rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-install intl zip bcmath pdo_mysql opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction
COPY . .
# -- Stage 3: runtime ---------------------------------------------------------
FROM php:8.4-fpm AS runtime
# Only runtime extensions, no build tools
RUN docker-php-ext-install pdo_mysql intl opcache zip bcmath
WORKDIR /var/www/html
# Merge results from both builder stages
COPY --from=php-deps /app /var/www/html
COPY --from=node-assets /app/web/css/styles.min.css /var/www/html/web/css/styles.min.css
# Verify: final image contains no npm, no composer binary
RUN php -v && ! command -v npm && ! command -v composer
EXPOSE 9000
CMD ["php-fpm"]
5. Layer Caching: Why Order Is Decisive
In a multi-stage Dockerfile, layer order within each stage is the single most important performance lever. Every RUN, COPY and ADD instruction creates a layer. Docker compares the content of the new layer against the cached layer from the previous build. As soon as a layer changes, every subsequent layer in that stage is invalidated and rebuilt. The basic rule: what rarely changes comes first, what changes often comes last.
Concretely, in multi-stage Dockerfiles for PHP projects this means: system packages and PHP extensions go at the very top, since they rarely change. The Composer lockfile and dependencies follow, since they change with new packages. Application code comes last, since it changes with every commit. The same applies in the Node stage: package-lock.json before the source code. Anyone who ignores this order invalidates the Composer or npm layer on every commit and loses the main caching benefit of the multi-stage Dockerfile.
6. Custom Base Images for Consistent Environments
In teams running multiple projects that all share a similar PHP base configuration, building your own base images pays off. A multi-stage Dockerfile can then build on top of that base image instead of repeating extension compilation every time. The base image contains all shared PHP extensions, PHP configuration, and operating system tools. Project-specific extensions get added in the builder stage.
The workflow: the base image is maintained in its own registry and only rebuilt when the PHP version updates or extensions change. Project Dockerfiles then start with FROM registry.mironsoft.de/php-base:8.4 instead of the official PHP image. That has two advantages: build times are shorter, because extension compilation is outsourced, and all projects run on a consistent base, which considerably simplifies debugging. For multi-stage Dockerfiles in larger teams, the base image pattern is one of the most effective optimizations available.
# Base image Dockerfile, built once, used by all project Dockerfiles
# Stored in internal registry: registry.mironsoft.de/php-base:8.4
FROM php:8.4-fpm AS base
LABEL maintainer="Mironsoft DevOps <devops@mironsoft.de>"
LABEL org.opencontainers.image.description="PHP 8.4 FPM base with common extensions"
# Install OS packages required by PHP extensions
RUN apt-get update && apt-get install -y --no-install-recommends \
libicu-dev \
libzip-dev \
libxml2-dev \
libonig-dev \
libpng-dev \
libjpeg-dev \
&& rm -rf /var/lib/apt/lists/*
# Compile extensions once, all projects inherit this layer from registry
RUN docker-php-ext-configure gd --with-jpeg \
&& docker-php-ext-install \
intl zip soap bcmath pdo_mysql opcache gd mbstring
# Shared PHP configuration
COPY docker/php.ini /usr/local/etc/php/conf.d/custom.ini
# Create app user for non-root runtime
RUN useradd -u 1000 -m appuser
# Healthcheck for orchestration
HEALTHCHECK --interval=30s --timeout=5s CMD php-fpm -t || exit 1
# Project Dockerfiles then extend: FROM registry.mironsoft.de/php-base:8.4 AS runtime
7. BuildKit: Parallelization and Mount Caches
Docker BuildKit is the modern build backend daemon that makes multi-stage Dockerfiles considerably faster. BuildKit analyzes the Dockerfile's dependency graph and automatically runs independent stages in parallel. A PHP builder stage and a Node builder stage with no dependency on each other run simultaneously on separate threads. In a typical PHP plus Node project, that saves one to three minutes of build time.
BuildKit's mount cache feature is especially valuable for multi-stage Dockerfiles that rely on package managers. Instead of caching node_modules within a single layer via npm ci, you can set up the npm cache (/root/.npm) as a persistent mount cache: RUN --mount=type=cache,target=/root/.npm npm ci. This cache survives layer invalidations and persists across builds without growing the layer content. The same applies to the Composer cache (/root/.composer) and the APT cache. With these mount caches, dependency download time on a cache miss drops considerably.
8. Reducing Image Size Systematically
Smaller images mean faster pull times, lower network costs, and a smaller attack surface. In multi-stage Dockerfiles, the most important levers for reducing image size are: Alpine-based base images for Node stages, lean Debian slim images for the PHP runtime, removing build dependencies after extension compilation, and not carrying dev Composer dependencies along. With composer install --no-dev followed by composer dump-autoload --optimize, the vendor/ folder can shrink by 20 to 40 percent.
Another approach for multi-stage Dockerfiles: distroless images as the runtime base. Google's distroless PHP images contain only PHP-FPM and its direct dependencies: no shell, no package manager, no unnecessary operating system. That reduces image sizes dramatically and eliminates an entire class of shell injection attack vectors. For Magento projects this is not always practical, since maintenance scripts need a shell, but for pure API services distroless is the recommended base.
9. Single-Stage vs. Multi-Stage Dockerfiles Compared
The direct comparison makes it clear why multi-stage Dockerfiles should be standard in production projects, while single-stage Dockerfiles are at most suitable for simple prototypes.
| Criterion | Single-Stage Dockerfile | Multi-Stage Dockerfile | Recommendation |
|---|---|---|---|
| Image size | 1 to 3 GB (incl. build tools) | 200 to 500 MB (runtime only) | Multi-stage |
| Security | Compiler, npm in the prod image | No build tooling in the runtime image | Multi-stage |
| Build parallelization | Not possible | Parallel stages with BuildKit | Multi-stage |
| Dockerfile complexity | Simpler, shorter | More lines, more structure needed | Depends on the situation |
| Local debugging | All tools available | Need to target individual stages | Single-stage for dev |
For local development, a single-stage Dockerfile with all tools can be practical. For staging and production, multi-stage Dockerfiles are strongly recommended. A good practice is therefore maintaining two Dockerfiles: Dockerfile.dev for local development with all tools, and Dockerfile as a multi-stage Dockerfile for CI/CD and production.
Mironsoft
Dockerfile architecture, multi-stage builds and container optimization
Lean, secure Docker images for your PHP or Node project?
We analyze existing Dockerfiles, design multi-stage Dockerfile architectures for PHP and Node, and optimize layer caching and BuildKit integration for short build times in CI/CD pipelines.
Dockerfile Review
Analyze existing Dockerfiles, layer cache strategy and size optimization
Multi-Stage Architecture
Builder stages for PHP, Node and other tools with optimal parallelization
CI/CD Integration
BuildKit registry cache, automated image size testing and security scans
10. Summary
Multi-stage Dockerfiles are the central pattern for clean, lean and secure container images in PHP and Node projects. They separate the build environment from the runtime through multiple FROM blocks, with each stage selectively passing artifacts forward via COPY --from. Layer order (dependencies before code) maximizes cache usage and minimizes build times. Docker BuildKit automatically parallelizes independent stages and, through mount caches, enables persistent package manager caches that survive layer invalidations.
Teams running multiple projects benefit from their own base images that centralize shared PHP extensions and configuration. The result of a well-built multi-stage Dockerfile is a runtime image with no build tools, considerably smaller than single-stage equivalents, fully reproducible and deployable within seconds. The initial complexity is justified by the long-term operational benefits in every production project.
Multi-Stage Dockerfiles: The Essentials at a Glance
Stage Separation
php-deps, node-assets and runtime as separate stages. COPY --from transfers only artifacts, no build tools into the final image.
Layer Cache Strategy
Place composer.json/lock and package-lock.json before the code COPY. Rarely changing layers first for maximum cache usage.
BuildKit Features
Parallel stage execution and --mount=type=cache for npm/Composer considerably reduce build times without increasing layer size.
Base Images
Custom base images with precompiled PHP extensions for consistent environments and shorter build times across all projects.