Multi Stage Builds, Caching and Security Hardening
A Symfony Docker image that runs locally is far from production ready. Multi stage builds separate the build environment from the runtime, Composer caching shortens every build, and a non root user plus a healthcheck make the container operable. This article walks through building a lean, hardened Symfony Docker image step by step.
Table of Contents
- 1. Why a production Symfony Docker image looks different
- 2. Multi stage builds: separating the build and runtime stage
- 3. Caching and optimizing Composer dependencies
- 4. PHP FPM tuning and choosing the base image
- 5. Security hardening: non root users and minimal base images
- 6. Building frontend assets and the Symfony cache into the image
- 7. Docker healthchecks and clean signal handling
- 8. Reducing image size: Alpine versus distroless
- 9. Docker strategies for Symfony compared
- 10. Summary
- 11. FAQ
1. Why a production Symfony Docker image looks different
A local development image installs Xdebug, mounts the source code as a volume and starts with debug mode enabled. A production ready Symfony Docker image must not share any of these traits. It contains no debugger, no build tools and no mounted source code. Instead, the application code is baked directly into the image, the container is immutable, and every instance of a deployment runs the exact same byte for byte identical artifact.
The most common mistake in teams new to containers is a single Dockerfile for both development and production. The result: either the production image drags along unnecessary tools, or the development image loses convenience features such as Xdebug and volume mounts. A clean Symfony Docker image for production comes from a multi stage Dockerfile that serves exactly one target environment well, while the development path is handled through Compose overrides or a separate build target.
In this model the container becomes a pure artifact: built once in the CI pipeline, tagged uniquely, distributed through a registry and started unchanged in every environment. This mindset is the foundation for everything else in this article: caching, security, size and operability of a Symfony Docker image can only be improved consistently once build time and runtime are clearly separated.
2. Multi stage builds: separating the build and runtime stage
A multi stage build consists of several FROM instructions in the same Dockerfile, where each stage brings its own environment and only explicitly named artifacts are passed to the next stage. For a Symfony Docker image this means concretely: a build stage installs Composer, Node and all compilation tools, runs composer install and the frontend build. The final runtime stage instead starts from a lean PHP FPM base image and only copies in the finished vendor directory, the compiled assets and the application code.
The benefit is twofold. First, Composer, Node, Git and all build dependencies disappear completely from the final Symfony Docker image, because they only ever existed in an intermediate stage that never reaches the registry on push. Second, each stage can be cached independently: if only the application code changes, Docker does not need to rerun the build stage with the Composer dependencies as long as composer.lock stays unchanged.
# Dockerfile — multi-stage build for a production Symfony image
# Stage 1: build dependencies and compile frontend assets
FROM composer:2 AS composer_stage
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
FROM node:20-alpine AS assets_stage
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime image — only what the application needs at runtime
FROM php:8.4-fpm-alpine AS runtime
RUN apk add --no-cache icu-libs libzip \
&& docker-php-ext-install -j$(nproc) intl pdo_mysql opcache
WORKDIR /var/www/html
COPY --from=composer_stage /app/vendor ./vendor
COPY --from=assets_stage /app/public/build ./public/build
COPY . .
RUN composer dump-autoload --no-dev --optimize --classmap-authoritative
USER www-data
EXPOSE 9000
CMD ["php-fpm"]
The key point: each stage is free to choose its own specialized base. The Composer stage uses the official Composer image, the assets stage a Node image, and only the last stage determines what actually ends up in the finished Symfony Docker image. Intermediate layers containing hundreds of megabytes of build tools disappear entirely, without any manual cleanup being required in the same layer.
3. Caching and optimizing Composer dependencies
The order of instructions in a Dockerfile directly determines how effectively Docker uses its layer cache. If the entire source code is copied first and composer install runs afterward, every code change immediately invalidates the Composer layer, even if not a single dependency has changed. The correct pattern for a performant Symfony Docker image copies only composer.json, composer.lock and symfony.lock first, installs the dependencies, and only then copies the rest of the application code.
In addition to layer ordering, the native BuildKit cache mount pays off. Instead of letting Composer download everything from scratch on every build, --mount=type=cache binds the Composer directory to the same host cache across multiple build runs. This reduces build times in CI pipelines from minutes to seconds, especially for large Symfony projects with many bundles. The flags --no-dev, --optimize-autoloader and --classmap-authoritative are mandatory for a production Symfony Docker image: they remove development dependencies and build a static class map that no longer requires a filesystem scan at runtime.
# syntax=docker/dockerfile:1.7
FROM composer:2 AS composer_stage
WORKDIR /app
# Copy only the lock files first — cache stays warm across code changes
COPY composer.json composer.lock symfony.lock ./
# BuildKit cache mount — Composer downloads persist across builds
RUN --mount=type=cache,target=/tmp/composer-cache \
composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction \
--cache-dir=/tmp/composer-cache
# Only now copy the actual application source
COPY . .
RUN composer dump-autoload --no-dev --optimize --classmap-authoritative \
&& composer check-platform-reqs
4. PHP FPM tuning and choosing the base image
The choice of base image significantly affects size, compatibility and startup time of a Symfony Docker image. The official php:8.4-fpm-alpine images at around 80 MB are considerably smaller than the Debian based php:8.4-fpm variants, but they use musl instead of glibc as their C library. For most Symfony applications this is unproblematic, though extensions with complex native dependencies, such as certain ICU or GD combinations, can behave subtly differently than on a Debian base.
Beyond the base image, the configuration of the FPM process manager is decisive for stability under load. The dynamic mode adjusts the number of worker processes automatically, but causes brief delays under load spikes while spinning up new workers. The static mode starts the maximum number of workers immediately and eliminates that delay, at the cost of permanently higher memory usage. In a Symfony Docker image that scales horizontally in Kubernetes, static with a moderate worker count per pod is usually the more robust choice, since horizontal scaling already provides additional capacity.
; docker/php/fpm-pool.conf — tuned for a containerized Symfony application
[www]
user = www-data
group = www-data
listen = 9000
; Static pool: predictable memory footprint, no cold-start latency
pm = static
pm.max_children = 8
pm.max_requests = 500
; Log slow requests instead of silently absorbing them
request_terminate_timeout = 30s
slowlog = /proc/self/fd/2
request_slowlog_timeout = 5s
; Production-safe OPcache defaults for this image
php_admin_value[opcache.validate_timestamps] = 0
php_admin_value[opcache.memory_consumption] = 256
php_admin_value[expose_php] = off
5. Security hardening: non root users and minimal base images
A production Symfony Docker image never runs as root. The official PHP images already ship a www-data user, and the USER instruction at the end of the Dockerfile ensures that the PHP FPM process starts with reduced privileges. Should an attacker manage to execute code in the container through an application vulnerability, the non root context significantly limits the damage: no access to system files outside the application directory, no installing new packages, no tampering with other processes in the same namespace.
In addition to the non root user, the filesystem outside of var/cache, var/log and temporary directories should be mounted read only. Docker supports this through the --read-only runtime flag combined with explicit tmpfs mounts for the directories Symfony actually needs to write to. This combination of a non root user and a read only filesystem significantly reduces the attack surface of a Symfony Docker image without touching the application itself.
#!/usr/bin/env bash
# run-hardened.sh — start the Symfony Docker image with reduced privileges
set -euo pipefail
docker run -d \
--name symfony-app \
--read-only \
--tmpfs /var/www/html/var/cache:rw,size=256m \
--tmpfs /tmp:rw,size=64m \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
--user www-data \
--memory 512m \
--pids-limit 200 \
registry.mironsoft.de/symfony-app:1.4.2
echo "[OK] Hardened Symfony container started as non-root, filesystem read-only"
6. Building frontend assets and the Symfony cache into the image
A Symfony Docker image that only warms the production cache once the container starts delays every rollout by several seconds and risks the first requests hitting a freshly started container with an incomplete cache. The more robust approach runs bin/console cache:warmup --env=prod already during the Docker build, so the compiled container, routing and Twig cache becomes part of the image itself. The container then starts ready to serve traffic immediately, without catching up on the first request.
The same applies to frontend assets. When Webpack Encore or AssetMapper is used, the entire asset build belongs in the separate build stage from section two. Only the compiled, version stable files from public/build end up in the final Symfony Docker image, never the asset source, node_modules or the Encore build cache. This keeps the runtime image small and ensures asset versioning and the Symfony cache exactly match the code state they were built from.
7. Docker healthchecks and clean signal handling
Without a HEALTHCHECK instruction, an orchestration layer such as Docker Compose or Kubernetes only knows whether the main process in the container is running, not whether the application is actually answering requests meaningfully. A Symfony Docker image should therefore expose a lightweight HTTP endpoint that checks database access, the cache connection and the application status, without triggering expensive computation itself. This endpoint is used both by the Docker healthcheck and later by Kubernetes probes.
Equally important is correct signal handling during container shutdown. PHP FPM reacts to SIGTERM with an immediate, hard abort of running requests rather than an orderly graceful shutdown. An init process such as tini as the ENTRYPOINT ensures signals are forwarded correctly to PHP FPM and zombie processes do not linger when the container is stopped. For a truly graceful shutdown, a short preStop hook also pays off, giving the load balancer time to remove the container from rotation before SIGTERM is actually sent.
# Add to the runtime stage of the Symfony Docker image
FROM php:8.4-fpm-alpine AS runtime
RUN apk add --no-cache tini curl
# tini as PID 1 forwards signals correctly to php-fpm
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["php-fpm"]
# Docker-native healthcheck against a lightweight endpoint
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
CMD curl -f http://localhost:9000/health || exit 1
8. Reducing image size: Alpine versus distroless
A smaller Symfony Docker image pulls faster from the registry, starts faster in new pods and reduces the attack surface because less installed software brings fewer potential vulnerabilities along. Alpine based images for a typical Symfony application with the necessary PHP extensions often land at 120 to 180 MB, while Debian slim variants easily reach 250 to 350 MB. Distroless images go a step further and, after the build, contain no shell, no package manager and no debug tools at all, which minimizes the attack surface even more.
The price of distroless images is limited debuggability: without a shell in the container, there is no more opening an exec -it session into a running container to quickly check something. In practice, an Alpine based Symfony Docker image is a good compromise between size, compatibility and operational practicality for most teams, while distroless is a candidate for particularly security critical environments with established observability tooling that makes shell debugging inside the container unnecessary.
9. Docker strategies for Symfony compared
The overview below summarizes how different build approaches for a Symfony Docker image differ in terms of size, security and operational effort.
| Strategy | Typical size | Build time | Use case |
|---|---|---|---|
| Single stage, Debian | 450 to 600 MB | slow, no cache benefit | only suitable for quick prototyping |
| Multi stage, Debian slim | 250 to 350 MB | medium | solid default with broad extension compatibility |
| Multi stage, PHP FPM Alpine | 120 to 180 MB | fast with BuildKit cache | recommended default for production images |
| Distroless runtime stage | under 100 MB | fast, more complex setup | security critical environments with external debugging |
For most Symfony teams, the third row of the table is the pragmatic target state: a multi stage build on a PHP FPM Alpine base, consistently cached and operated with a non root user. A Symfony Docker image in this category can be built in under a minute, weighs well under 200 MB and restarts in a fraction of a second, which becomes noticeable especially when scaling horizontally in Kubernetes.
Mironsoft
Symfony DevOps, container infrastructure and production ready deployments
Symfony Docker images that actually hold up in operation?
We build multi stage, hardened Symfony Docker images: lean runtime stage, cached Composer layer, non root operation and healthchecks that match your Kubernetes probes.
Dockerfile audit
Reviewing existing Symfony Docker images for size, caching and security gaps
Multi stage migration
Migrating existing single stage builds to lean, cached multi stage pipelines
CI integration
Adding BuildKit caching and a registry strategy to your existing pipeline
10. Summary
A production ready Symfony Docker image does not result from a single Dockerfile command, but from the consistent interplay of several principles. Multi stage builds cleanly separate build tools from the runtime environment. The right layer order ensures Composer dependencies are only reinstalled when they actually change. A non root user and a read only filesystem significantly reduce the attack surface without touching the application code.
Cache warmup and asset builds belong in the build process, not the container start, so every new container is ready to serve traffic immediately. A healthcheck against a lightweight endpoint and clean signal handling through tini make the container predictable for orchestration tooling. Anyone who consistently applies these points ends up with a Symfony Docker image that is small, secure and ready to start within seconds, whether it runs under Docker Compose, Kubernetes or any other orchestration layer.
Symfony Docker Images for Production — The Essentials at a Glance
Multi stage build
Keep build tools in a separate stage. Only the vendor directory, compiled assets and application code end up in the runtime image.
Composer caching
Copy lock files first, then install. BuildKit cache mounts save minutes on every build.
Security hardening
Non root user, read only filesystem, minimal Alpine base. Less installed software, smaller attack surface.
Operational readiness
Cache warmup during the build, HEALTHCHECK against a real endpoint, tini for clean signal handling.