Building Multi-Stage Dockerfiles for PHP and Node Projects the Right Way
AI generated
Docker · Multi-Stage Build · PHP · Node.js
Building Multi-Stage Dockerfiles for PHP and Node
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.

14 min read Multi-Stage · Builder-Stage · Layer-Cache · Runtime-Stage Docker 24+ · PHP 8.4 · Node 22

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.

11. FAQ: Multi-Stage Dockerfiles for PHP and Node

1What is a multi-stage Dockerfile?
Multiple FROM blocks. Each stage has its own tools. COPY --from selectively transfers artifacts. Final image contains only runtime content, no build tools.
2Why are multi-stage images smaller?
Composer, npm and compilers never land in the runtime image. Only PHP-FPM, app code and runtime extensions. Typically 200 to 500 MB instead of 1 to 3 GB.
3How does layer caching work in multi-stage?
Copy dependencies before code. The package manager layer is only invalidated when composer.lock or package-lock.json changes, not on every code commit.
4Do PHP and Node run in parallel?
Yes, with DOCKER_BUILDKIT=1. BuildKit runs independent stages in parallel, saving 1 to 3 minutes on PHP+Node projects.
5What does --mount=type=cache give you?
Persistent cache mount across build boundaries. npm and Composer load from local cache instead of the registry: faster and no layer size impact.
6Debugging an error in a builder stage?
docker build --target stagename . then docker run --rm -it IMAGE sh. Only the desired stage gets built, shell available for inspection.
7Alpine or Debian as the base?
Alpine for Node. Debian slim for PHP: musl libc issues with some PHP extensions. php:8.4-fpm-bookworm as a stable PHP runtime base.
8Multi-stage Dockerfiles with Docker Compose?
Yes, use build.target to target a different stage locally than in prod. Developers use the builder stage with all tools, CI uses the runtime stage.
9When are custom base images worth it?
Starting at two projects with similar PHP extensions. Centralizes configuration, shortens build times and ensures consistent environments.
10Suitable for local development?
Yes, but often via Dockerfile.dev with all tools. Alternatively --target builder for a stage with more tools locally, --target runtime for CI/prod.