| name | symfony-deployment |
| description | Ship a Symfony application to production as a Docker image running FrankenPHP: the build and release sequence and what each step is for, migrations run automatically at deploy, production php.ini and OPcache preloading, environment variables and secrets on the server, Messenger workers under systemd or supervisor, post-deployment checks and rollback. Use this skill whenever someone says deploy this, put it in production, ship it, set up the server, prepare a Dockerfile or a production image, write a deploy script, run migrations on the server, restart the workers, configure OPcache or preloading, set environment variables in production, roll back a release — and equally when they describe a symptom: it works locally but not on the server, the site broke after deploying, my CSS is missing in production, production is slow, the workers are running old code, I get a 500 with no error message, the new code is not being picked up. Also use it before a first deployment, when nobody knows how the app is deployed. |
Deployment
One artefact, one sequence, one way to undo it.
The target is a Docker image running FrankenPHP, deployed by replacing containers.
Everything below assumes that model, because it is what makes the rest simple: the
image is immutable, so "what is running" is a question with an exact answer, and
rollback is redeploying a tag.
For running the app locally — including the production-like FrankenPHP check you should
do before shipping — see symfony-local-dev. For the CI pipeline that must pass
first, see symfony-quality. This skill starts once the image is about to be built, and
stops at where logs and metrics go: what the application emits — channels, log
levels, redaction, alerting, health checks — is symfony-observability's. This skill
owns the prod handler configuration; that one owns everything written into it.
Do not write the Dockerfile
Use dunglas/symfony-docker. It is
maintained by the FrankenPHP author and tracks Symfony's recipes. A hand-rolled
Dockerfile reaches production faster and then quietly lacks:
| What it gives you | Why a hand-rolled file usually misses it |
|---|
| Multi-stage build | The final image is debian:13-slim plus the binaries and /app — no Composer, no git, no build toolchain to attack |
| Non-root user | Runs as www-data, setuid/setgid bits stripped, var/ owned group 0 with g=u so it also works on arbitrary-UID platforms like OpenShift |
| Healthcheck | Probes Caddy's metrics endpoint on :2019, so the orchestrator knows the difference between "container started" and "app answers" |
| Worker mode | The Caddyfile declares worker { file ./public/index.php } — the kernel is booted once and reused, which is most of FrankenPHP's speed |
| TLS | Automatic Let's Encrypt certificates from SERVER_NAME, HTTP/2 and HTTP/3. AssetMapper needs HTTP/2 to not be slower than a bundle |
###> recipes ### markers | Flex writes into the Dockerfile itself, so installing a bundle that needs a PHP extension updates the image on its own |
Copying that repository into a skill would guarantee it rots. Point at it, and read its
Dockerfile when you need to know what actually runs — it is the source of truth over
this page.
The sequence
Every step has a job. The list is worth little; what breaks when you skip it is
what stops you skipping it.
| Step | Its job | Skipped → |
|---|
composer install --no-dev --optimize-autoloader | Production dependencies, class map instead of filesystem probing | Dev bundles in prod (profiler, maker), and every class load hits the disk |
composer dump-env prod | Compiles the .env files into .env.local.php | .env is parsed on every request, and Dotenv needs the files to still be there |
cache:clear or cache:warmup | Builds the container, routes, Twig, validator metadata, and the .preload.php file | First request of every worker pays the full build; opcache.preload silently does nothing |
asset-map:compile | Writes hashed asset files under public/assets/ | Every asset is generated by PHP at runtime, on every request. This is why people think AssetMapper is slow |
doctrine:migrations:migrate --no-interaction | Brings the schema to the code's expectations | The new code queries columns that do not exist |
messenger:stop-workers | Asks running workers to stop after their current message | Workers keep executing the old code against the new schema — the worst failure in this list, because nothing looks broken |
| restart workers | Brings consumers back on the new code | The queue fills up silently |
Two of these are commonly written wrong:
cache:clear already warms up unless you pass --no-warmup, and it runs the
optional warmers that produce the preload file. Running cache:clear and
cache:warmup warms the cache twice. In an image built from a clean checkout there
is nothing to clear — cache:warmup alone is the honest call. (composer run-script post-install-cmd runs cache:clear through Flex's auto-scripts, so most builds
have already done it.)
messenger:stop-workers does not signal a process. It writes a timestamp into
the cache.app pool; workers read it between messages and exit. Two consequences:
the deploy job must write to the same pool the workers read, and on Symfony < 7.4
(where that pool lives under var/cache/) cache:clear after it erases the
signal. Details and the fix: references/deploy-sequence.md.
Where each step runs
With an image, the sequence splits in two and the split is the whole point.
At build time, baked into the image: composer install, dump-env, cache warmup,
asset-map:compile. They are deterministic, they need no database, and doing them here
means the container starts in milliseconds and every replica is byte-identical.
At release time, once per deploy, not once per container: migrations, stopping
workers, restarting them. symfony-docker's entrypoint runs the migration for you when
migrations/ is non-empty — convenient for one container, a race when you scale.
references/deploy-sequence.md covers running it as a separate job.
Migrations run automatically, and that is a choice
Deliberate, with a stated price: migrations are not written to be
backward-compatible with the code currently running. A short interruption is acceptable
if a migration is incompatible with the version being replaced.
That buys a migration you can read as one change — rename the column, done — instead of
a three-deploy dance for every schema edit, and no half-applied intermediate state
living in production for a week.
What it costs, stated plainly so you can deviate on purpose: there is a window,
usually seconds, where old containers run against the new schema. If your
requirement is zero downtime, this rule does not apply to you and the pattern you need
is expand/contract: add the new column nullable → deploy code that writes both →
backfill → make it non-null → deploy code that reads only the new one → drop the old.
Five deploys instead of one. Write it in references/deploy-sequence.md's terms, and
know you are opting into a slower cadence for a real guarantee.
Writing the migration itself — reading and correcting what make:migration generated —
belongs to symfony-doctrine. This skill only says when it runs.
Production settings
APP_ENV=prod and APP_DEBUG=0, then the php.ini that makes the difference between
"it runs" and "it is fast":
opcache.preload = /app/config/preload.php
opcache.preload_user = www-data
opcache.validate_timestamps = 0
realpath_cache_size = 4096K
realpath_cache_ttl = 600
opcache.memory_consumption = 256
opcache.max_accelerated_files = 32531
opcache.interned_strings_buffer = 16
opcache.validate_timestamps = 0 means PHP never re-reads a file it has already
compiled. New code is invisible until the OPcache is dropped. Replacing the container
does that for free — which is precisely why the container model is worth its
constraints. Deploy any other way (rsync, a releases/ symlink) and you owe a
restart or an opcache_reset() after every deploy, or you will ship code that never
runs.
opcache.preload needs config/preload.php, which the Symfony recipe already ships and
which does nothing unless the prod cache has been warmed — it requires
var/cache/prod/App_KernelProdContainer.preload.php. That is the second reason warmup
belongs in the build.
Also worth setting, both in config/: .container.dumper.inline_factories (fewer files
to load per request) and framework.enabled_locales (stop compiling translation
catalogues for languages you do not serve). Both, with the exact syntax and the version
caveats, in references/production-config.md.
Environment variables and secrets
composer dump-env prod compiles the .env files into .env.local.php. Real
environment variables still win over it — that is how you inject a DSN per environment
without rebuilding.
Secrets live in .env.local or in the server's environment. The secrets vault is not
used here. Say the price out loud rather than implying safety that does not exist:
nothing is encrypted at rest, anyone with a shell on the host or a look at the
orchestrator's config can read them, and sharing a secret with a colleague happens
outside the repository — a password manager, not a commit. What you get in exchange is
one mechanism instead of two, no decryption key to deploy and lose, and .env.local
being unreadable by design because it is never committed.
.env.local is not committed, so it is not in the image either. On a container platform,
inject through the platform. references/production-config.md has the precedence rules
and the trap where dump-env is silently bypassed.
Verify the deploy
A deploy nobody checks is a deploy whose failure is reported by users, hours later,
without a stack trace. Four commands, ten seconds:
php bin/console about
php bin/console doctrine:migrations:up-to-date
php bin/console lint:container
php bin/console messenger:stats
Then one real request: check the status code, that an asset comes from
/assets/…-<hash>.css and not through PHP, and that the error log is quiet. The full
checklist, including what a healthy messenger:stats looks like:
references/deploy-sequence.md.
Rollback
Rollback works when two things are true, and they are decided before the incident:
- Image tags are immutable. Deploy
app:a1b2c3d, never app:latest. You cannot
roll back to a tag that has been overwritten.
- Migrations do not destroy data. A migration that dropped a column can be rolled
back in code and not in reality. Splitting a destructive change over two releases —
stop writing to the column now, drop it next week — is what keeps rollback available.
What makes it impossible: a mutable tag, a down() method nobody has ever run, and
building the image on the production host so there is no previous artefact to return to.
When production misbehaves
| Symptom | Cause |
|---|
| Blank page, 500, nothing in the log | APP_DEBUG=0 hides the error but the log handler is misconfigured — check logs go to stderr. If the handler is right and the log is still silent, that is fingers_crossed doing its job, and the question belongs to symfony-observability |
| CSS and JS 404 or are slow | asset-map:compile did not run in the build |
| New code deployed, old behaviour | opcache.validate_timestamps=0 and the process was not replaced |
| Workers process messages with old code | messenger:stop-workers skipped, or the signal was written to a pool the workers do not read |
| Restart signal ignored | cache:clear ran after messenger:stop-workers and deleted it (Symfony < 7.4) |
Unable to write in the cache directory | var/ not writable, or the image runs as a user that does not own it |
| Works on one replica, fails on another | State written to the local filesystem — sessions, uploads, the cache.app pool |
| Migration hangs the deploy | Several replicas ran doctrine:migrations:migrate at once and are blocked on the lock |
| An env var is set on the server but ignored | It is set for the shell, not for the process the supervisor starts |
Reference files
| File | When to read it |
|---|
references/deploy-sequence.md | Writing or fixing a deploy script; migrations at release; workers, stop signal, supervisor and systemd; the verification checklist; rollback |
references/production-config.md | php.ini and OPcache, preloading, container and locale settings, environment variables and secrets, filesystem and logs, FrankenPHP worker mode |
assets/README.md | Before copying the worker unit files — both need project-specific values |