Containerizing a Symfony Application: Docker, Compose, and the Production Gotchas
Most Dockerized Symfony setups start life as a copied tutorial. They build, they run docker compose up, the app answers on localhost, and everyone moves on. Then the same image goes to a server and fails in one of a handful of predictable ways: the container cannot write to var/cache, PHP serves stale code because opcache never rechecks files, FPM workers eat all the memory under load, or the orchestrator routes traffic to a container that is not actually ready. A working Docker Symfony production setup is not a longer tutorial. It is a short list of decisions that differ between development and production, made explicitly instead of by accident.
This post walks through those decisions for a Symfony 7 application: the multi-stage Dockerfile, file permissions, opcache behavior, FPM pool sizing, web server configuration, secrets handling, health checks, and a local Compose setup that does not crawl on macOS.
The multi-stage Dockerfile for Docker Symfony production
A production image should contain your code, your vendor directory, a warmed cache, and a PHP runtime. It should not contain Composer, git, node, build caches, or dev dependencies. Multi-stage builds give you that separation in one file:
# Stage 1: build
FROM composer:2 AS composer_build
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative
# Stage 2: runtime
FROM php:8.3-fpm-alpine AS runtime
RUN apk add --no-cache icu-libs \
&& docker-php-ext-install intl opcache pdo_pgsql
WORKDIR /app
COPY --from=composer_build /app /app
COPY docker/php/prod.ini /usr/local/etc/php/conf.d/zz-prod.ini
ENV APP_ENV=prod
RUN php bin/console cache:warmup
USER www-data
CMD ["php-fpm"]
Two details matter more than the rest. First, copy composer.json and the lock files before the source code, so dependency installation stays in the Docker layer cache and only re-runs when the lock file changes. On a typical Symfony project this turns a three minute rebuild into fifteen seconds. Second, warm the cache at build time. If the first request after deploy has to compile the container and routes, your p99 latency spikes on every release and your health check may time out before the app ever answers.
If your frontend assets are built with Webpack Encore or AssetMapper, add a third stage on a node or PHP base image and copy only the compiled public/build output into the runtime stage. The node toolchain has no business in a PHP runtime image.
File permissions: the var/ directory problem
The most common first-deploy failure is a permission error on var/cache or var/log. It happens because the image was built as root, the files in /app belong to root, and PHP-FPM workers run as www-data. Locally you never notice, because a bind mount takes your host permissions along.
The fix is to be explicit about ownership of exactly the paths Symfony writes to:
RUN mkdir -p var/cache var/log \
&& chown -R www-data:www-data var
USER www-data
Do not chown -R the whole application. It doubles image size on some storage drivers because every file gets copied into a new layer, and it hides a useful property: code that the runtime user cannot write to is code an attacker who compromises a worker cannot modify either. Application code stays root-owned and read-only; only var is writable. If you log to stdout (you should, in containers) and cache is pre-warmed and read-mostly, the writable surface is small.
opcache: why production serves stale code, and why that is correct
opcache compiles PHP files once and keeps the bytecode in shared memory. The setting that controls whether it ever looks at the file again is validate_timestamps. In production you want:
opcache.enable=1
opcache.validate_timestamps=0
opcache.memory_consumption=256
opcache.max_accelerated_files=25000
opcache.preload=/app/config/preload.php
opcache.preload_user=www-data
With validate_timestamps=0, PHP never stats files on disk after first compile. That removes filesystem calls from every request and is one of the cheapest performance wins available. It also means editing a file inside a running production container does nothing, which surprises people exactly once. That is not a bug to work around. Containers are immutable; you ship changes by building a new image and replacing the container, and the deploy itself becomes your cache invalidation.
The gotcha runs the other direction in development: if you copy a production ini into your dev setup, your code changes stop appearing and you will spend an afternoon blaming your volume mounts. Keep two ini files, one per environment, and have Compose mount the dev one.
PHP-FPM pool sizing: the memory math nobody does
The default FPM configuration is written for a shared host, not a container with a memory limit. Under load, pm.max_children is the only setting standing between you and the OOM killer, and the math is short: peak memory per worker times max workers must fit in the container limit.
Measure real worker memory with ps under load or from your APM; a typical Symfony app lands between 50 and 120 MB per worker. For a container with a 1 GB limit and workers averaging 80 MB:
pm = static
pm.max_children = 10
pm.max_requests = 500
Static pools are the right default in containers. Dynamic spawning made sense when FPM shared a machine with other tenants; in a container that owns its memory allocation, you want a predictable worker count and no fork churn during traffic spikes. pm.max_requests recycles workers periodically, which papers over slow leaks in extensions and long-lived references. If you see containers killed with exit code 137, this is the section to revisit before you blame the platform.
The front controller: nginx and Caddy that actually route correctly
Symfony routes everything through public/index.php, and a subtly wrong web server config produces symptoms that look like application bugs: assets that 404, URLs that work only with index.php in them, or PATH_INFO exploits where /uploads/evil.jpg/foo.php reaches the interpreter. The nginx block that behaves:
server {
root /app/public;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass app:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
internal;
}
location ~ \.php$ {
return 404;
}
}
The internal directive and the final location ~ \.php$ { return 404; } block are the security-relevant lines: only index.php is ever executed, and only via internal rewrite. With Caddy the equivalent is a one-liner (php_fastcgi app:9000 with root /app/public), which is a real argument for Caddy in small teams. If you would rather remove the FPM-plus-web-server pair entirely, we compared that approach in FrankenPHP in production.
Environment variables and secrets
Symfony gives you two mechanisms and they compose cleanly with containers: environment variables for configuration that varies per environment, and the secrets vault for values that must not appear in plain text. In containers, prefer runtime injection over baking values into images. An image with a database password in an ENV layer leaks that password to anyone who can pull the image.
The practical split: non-sensitive config (APP_ENV, feature flags, hostnames) as plain environment variables from your orchestrator; sensitive values either from the orchestrator's secret store (Kamal secrets, ECS secrets, Kubernetes secrets) injected as env vars at container start, or via Symfony's vault with only the decryption key injected. Never commit .env.local, and never COPY .env* . in a Dockerfile without thinking about what lands in the image.
Health checks that tell the truth
The missing health check is the quietest gotcha on this list. Kamal, ECS, and Kubernetes all decide when a container receives traffic, and without a real check they decide based on "the process started", which is not the same as "Symfony can answer requests". The result is a burst of 502s on every single deploy, usually written off as network flakiness.
Expose a route that exercises the framework without hammering dependencies:
#[Route('/healthz', name: 'healthz')]
public function healthz(Connection $db): JsonResponse
{
$db->executeQuery('SELECT 1');
return new JsonResponse(['status' => 'ok']);
}
Wire it into the image so every orchestrator picks it up:
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s \
CMD php -r 'exit(strpos(@file_get_contents("http://127.0.0.1/healthz"), "ok") !== false ? 0 : 1);'
start-period matters: it gives the container time to boot before failures count. Keep the check cheap and honest. A check that pings four downstream services turns any dependency blip into a full restart cascade; a check that returns 200 unconditionally is decoration. If you deploy with Kamal, the zero-downtime deploy flow depends entirely on this endpoint being truthful.
Local development: Compose without the macOS tax
The production image is immutable; development needs the opposite, and the classic mistake is using one Dockerfile stage for both. Add a dev stage with xdebug and looser ini settings, and mount source code over the image copy:
services:
app:
build:
context: .
target: dev
volumes:
- ./:/app
- /app/vendor
- /app/var
The two anonymous volumes are the macOS performance fix. Docker Desktop's file sharing is dramatically slower than native I/O, and vendor plus var account for most file operations per request. Excluding them from the bind mount keeps those paths on the VM's native filesystem; with VirtioFS this takes a typical request from seconds to normal. The trade-off is that composer install must run inside the container, which is the correct habit anyway.
Development vs production: the ini settings that must differ
| Setting | Development | Production |
|---|---|---|
opcache.validate_timestamps | 1 | 0 |
display_errors | On | Off |
memory_limit | 512M | Sized to FPM pool math |
pm | dynamic, low children | static, calculated children |
APP_ENV | dev | prod, cache pre-warmed |
| xdebug | Installed in dev stage only | Absent from the image |
If any production value above matches your development value, that line deserves a second look.
The pre-deploy checklist
Before the first real deploy, verify: the image builds without dev dependencies; var is the only writable path and belongs to www-data; opcache timestamps are off in prod and on in dev; pm.max_children times worker memory fits the container limit; only index.php is executable through the web server; no secret is baked into an image layer; /healthz fails when the app genuinely cannot serve; and a code change on your Mac appears in the browser without a rebuild.
None of these steps is difficult in isolation. What makes containerization projects drag is discovering them one incident at a time. If you are moving an existing Symfony application into containers, that usually surfaces older problems too, from implicit server state to configuration that only exists on one machine; that is the modernization work we do in legacy code optimization. And if you want a second pair of eyes on a setup before it takes production traffic, a focused review through our code quality consulting catches most of this list in a day.
Questions about a specific setup? Write to hello@wolf-tech.io or find us at wolf-tech.io.

