Skip to content

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) + php running the Symfony CLI server, plus mailcatcher/ollama. Defined in a self-contained compose.yaml.
  • Prod: a single FrankenPHP image (php) embedding the reverse proxy, the Mercure hub and the PHP app. Defined in a self-contained compose.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: FrankenPHP php edge (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 / pwa dev build targets, source bind-mounts, Xdebug, read_only: false, lexik filesystem JWT keys (no Docker secrets), the Mercure demo UI, plus the dev-only mailcatcher and ollama services.

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 via export COMPOSE_FILE=compose.yaml:compose.dev.yaml. make start-dev used to add COMPOSE_PROFILES=routing to bring up Valhalla; since #881 routing is opt-in (make routing-up), because the serve-only valhalla needs a graph built out of band by make routing-build <slug>.
  • Prod: docker compose -f compose.yaml … (CI sets COMPOSE_FILE: compose.yaml; Coolify points its "Docker Compose Location" at compose.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 first make start-dev is slower; the dev php healthcheck uses a longer start_period to absorb it.
  • A few keys need the Compose !override / !reset tags so the dev layer replaces (rather than merges into) the prod volume and secret lists.
  • Prod bakes a warmed var/cache/prod into the image at build (Composer post-install-cmd → cache:clear, no external deps). Prod therefore mounts no writable volume on /app/var: php/worker/worker-llm run read_only on the baked cache. This is safe because nothing writes under var at runtime (Monolog → stderr, all cache pools + Symfony lock → Redis). A prior php_var named volume on /app/var masked 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/caddy v1.0.0 requires Caddy v2.11.4, exactly the version the upstream image already embeds, so no other module moves;
  • frankenphp itself 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.