# Symfony Yoandev Deployment

> 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.

- Skill: `yoanbernabeu/symfony-yoandev-deployment` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add yoanbernabeu/symfony-yoandev-deployment`
- Raw SKILL.md: https://api.skillmd.com/api/skills/yoanbernabeu/symfony-yoandev-deployment/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: yoanbernabeu (https://skillmd.com/u/yoanbernabeu)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/yoanbernabeu/symfony-yoandev-deployment

---


# 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`](https://github.com/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-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":

```ini
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 `require`s
`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:

```bash
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`, never `app: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 |

