ADR-037: Dev/Prod Docker Convergence on FrankenPHP
- Status: Accepted
- Date: 2026-06-01
- Depends on: ADR-019 (Deployment Infrastructure)
- Related: ADR-036 (manual OSM refresh); ADR-061 (in prod the FrankenPHP edge serves plain HTTP behind Traefik + Cloudflare Tunnel — auto-HTTPS disabled; dev/recette unchanged)
Context
Development and production ran two divergent Docker topologies:
- Dev:
caddy:2-alpine(proxy) +dunglas/mercure(hub) +phprunning the Symfony CLI server, plusmailcatcher/ollama. Defined in a self-containedcompose.yaml. - Prod: a single FrankenPHP image (
php) embedding the reverse proxy, the Mercure hub and the PHP app. Defined in a self-containedcompose.prod.yaml.
The two files duplicated most service definitions and drifted: a runbook editing compose.prod.yaml did not match what a developer saw locally, and the dev caddy/mercure services behaved differently from the prod FrankenPHP edge. The Dockerfiles already shipped a frankenphp_dev stage and a pwa dev target, so the divergence was incidental, not required.
Decision
Run the same FrankenPHP topology in dev and prod, with a layered Compose setup:
compose.yaml— the iso-prod base and single source of truth: FrankenPHPphpedge (proxy + embedded Mercure + app),worker,pwa,database,redis,valhalla,provisioner;read_only, Docker secrets,deploy/replicas, prod env. Read as-is by CI and Coolify.compose.dev.yaml— dev overrides layered on top:frankenphp_dev/ pwadevbuild targets, source bind-mounts, Xdebug,read_only: false, lexik filesystem JWT keys (no Docker secrets), the MercuredemoUI, plus the dev-onlymailcatcherandollamaservices.
compose.prod.yaml and the standalone caddy / mercure services are removed.
Invocation
- Dev:
docker compose -f compose.yaml -f compose.dev.yaml …, wired through the Makefile viaexport COMPOSE_FILE=compose.yaml:compose.dev.yaml.make start-devused to addCOMPOSE_PROFILES=routingto bring up Valhalla; since #881 routing is opt-in (make routing-up), because the serve-onlyvalhallaneeds a graph built out of band bymake routing-build <slug>. - Prod:
docker compose -f compose.yaml …(CI setsCOMPOSE_FILE: compose.yaml; Coolify points its "Docker Compose Location" atcompose.yaml).
Why compose.dev.yaml and not compose.override.yaml
Docker auto-loads compose.override.yaml next to compose.yaml. Coolify's exact docker compose invocation for a deployed app is not documented, and that auto-load is a known footgun (dev settings silently applied in prod). Naming the overrides compose.dev.yaml (not auto-loaded) guarantees prod reads compose.yaml alone, regardless of how Coolify invokes Compose. compose.override.yaml stays free (and gitignored) for per-developer local tweaks.
Consequences
Positive
- One topology to reason about; dev mirrors prod (FrankenPHP edge, embedded Mercure).
- No duplicated service definitions; the override carries only deltas.
- Fewer dev services (no standalone
caddy/mercure).
Negative
- Dev installs Composer deps and runs migrations at first boot (vendor is not baked into
frankenphp_dev), so the firstmake start-devis slower; the devphphealthcheck uses a longerstart_periodto absorb it. - A few keys need the Compose
!override/!resettags so the dev layer replaces (rather than merges into) the prod volume and secret lists. - Prod bakes a warmed
var/cache/prodinto the image at build (Composerpost-install-cmd→cache:clear, no external deps). Prod therefore mounts no writable volume on/app/var:php/worker/worker-llmrunread_onlyon the baked cache. This is safe because nothing writes undervarat runtime (Monolog →stderr, all cache pools + Symfony lock → Redis). A priorphp_varnamed volume on/app/varmasked and froze that cache across deploys, causing stale-container 500s (/trips/{id}/detail) and 404s (/users/me/ai-settings) — issue #728; it has been removed.
Neutral
- JWT differs by environment by design: prod uses Docker secrets (
/run/secrets), dev uses lexik filesystem keys generated by the entrypoint (config/jwt).
Addendum (2026-09-21) — the PHP image is now rebuilt, not consumed as-is
The php image no longer runs the upstream dunglas/frankenphp binary. It is recompiled by a
frankenphp_builder stage in .docker/php/Dockerfile, for one reason: the Mercure hub is
embedded in that binary (the whole point of this ADR), and the upstream image pins
dunglas/mercure/caddy v0.24.2, which only speaks the Mercure 0.x protocol. The app
speaks Mercure 1.0 — OAuth 2.0 access tokens, match / match_urlpattern — so the module has
to be rebuilt at v1.0.0.
Two facts make this cheap rather than a fork:
mercure/caddyv1.0.0 requires Caddy v2.11.4, exactly the version the upstream image already embeds, so no other module moves;frankenphpitself is built from the sources vendored in the official builder image (/go/src/app), so the binary links against the very libphp it ships with.
The only real obstacle was the toolchain: mercure/caddy v1.0.0 declares go 1.27 while the
builder image ships Go 1.26 under GOTOOLCHAIN=local. Setting GOTOOLCHAIN=auto lets Go fetch
the newer toolchain on demand; no separate golang:1.27 stage is needed.
Runtime and builder images must move together. The compiled binary links against the builder's libphp, so a PHP patch-level mismatch between the two breaks the dynamic link at boot. Both are digest-pinned to the same FrankenPHP release; bump them in the same commit.
The deprecated_topic / deprecated_claim build tags are deliberately not passed: they only
matter for protocol_version_compatibility, which weakens access-token validation to keep 0.x
clients alive. Every client here (PWA, mobile, and the backend's own subscription check) speaks
1.0, so there is nothing to keep alive.
Cost: the image build now compiles Go with CGO, adding roughly three minutes to an uncached
build — including the arm64 build in deploy.yml.