Deployment
Tier: on demand, then not optional — nothing here applies before there is a production target. Once there is one, the sequence and its order are not negotiable.
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-yoandev-local-dev. For the CI pipeline that must pass
first, see symfony-yoandev-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-yoandev-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:clearalready warms up unless you pass--no-warmup, and it runs the optional warmers that produce the preload file. Runningcache:clearandcache:warmupwarms the cache twice. In an image built from a clean checkout there is nothing to clear —cache:warmupalone is the honest call. (composer run-script post-install-cmdrunscache:clearthrough Flex'sauto-scripts, so most builds have already done it.)messenger:stop-workersdoes not signal a process. It writes a timestamp into thecache.apppool; 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 undervar/cache/)cache:clearafter 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-yoandev-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 come from the platform's secret store, injected as environment variables.
Every orchestrator has one, it is where the DSN already lives, and it is the one place
an operator looks. The Symfony secrets vault is not layered on top of it here, for one
reason only: its decryption key would itself be a platform secret, so the vault would be
a second store fed by the first. It is the right tool, not a rejected one, the moment
the platform store is missing — a bare server, an rsync deploy — or the moment
secrets must be versioned and reviewed in the repository; then adopt it wholesale
(secrets:set, config/secrets/prod/, the key injected as SYMFONY_DECRYPTION_SECRET)
and drop the platform variables for those values. Two mechanisms on one project is how a
secret ends up in neither. What is never acceptable: a .env.local on a production
host, which is a plaintext file nobody rotates.
.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 # env prod, debug off, right version
php bin/console doctrine:migrations:up-to-date # schema matches the code
php bin/console lint:container # every service can be built
php bin/console messenger:stats # queues draining, not growing
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, neverapp:latest. You cannot roll back to a tag that has been overwritten. - The release's migrations did not destroy data. A migration that dropped a column can be rolled back in code and not in reality.
The second point is not a backward-compatibility rule, and it does not reopen the one this standard rejects. Migrations still run at deploy, still rename a column in one statement, and old containers still meet the new schema for a few seconds. What it says is narrower: a release that carries a destructive migration has no rollback, and that is stated before deploying, not discovered during the incident. Most releases carry none — an added column, a new table, a new index — and roll back for free.
When a particular release must stay reversible and drops something, split that one change: stop writing to the column in this release, drop it in a later one. That is a choice made per release, for a checkout or a payment table, and the price of it is one extra deploy for that change — not expand/contract as a standing policy for every schema edit.
What makes rollback 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-yoandev-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 |