Skip to content

4544: POC for using FrankenPHP behind Traefik - #59

Draft
turegjorup wants to merge 14 commits into
developfrom
feature/frankenphp-poc
Draft

4544: POC for using FrankenPHP behind Traefik#59
turegjorup wants to merge 14 commits into
developfrom
feature/frankenphp-poc

Conversation

@turegjorup

@turegjorup turegjorup commented Aug 1, 2025

Copy link
Copy Markdown
Contributor

Link to ticket

https://leantime.itkdev.dk/tickets/showKanban?tab=ticketdetails#/tickets/showTicket/4544

Description

Serves the site from a single FrankenPHP container behind Traefik, in place of the phpfpm and nginx pair, and moves the stack to PHP 8.5.

The branch was 236 commits behind develop and pinned to Symfony 7, so it has been merged up first. Three files conflicted; composer.json and composer.lock took develop's Symfony 8 versions.

The published image is not enough on its own. dunglas/frankenphp is deliberately minimal: it has no pdo_mysql, no amqp and no intl, so the application cannot boot on it. No tag variant (-alpine, -builder, -trixie) bundles them and there is no itkdev/frankenphp on Docker Hub. A four-line Dockerfile adds them on top of the published dunglas/frankenphp:1.12-php8.5, along with gd, zip, xdebug and msmtp. Publishing an itkdev/frankenphp image next to itkdev/php8.5-fpm would remove the Dockerfile and the per-job build described below — worth deciding before this leaves POC.

Where the service lives. In the per-environment override files: docker-compose.override.yml locally and docker-compose.server.override.yml on the servers. phpfpm and nginx are parked in a profile that is never enabled, because compose cannot delete an inherited service. The base compose files stay as the itkdev template ships them, so the swap is a few lines per environment rather than a rewrite.

Traefik keeps terminating TLS. auto_https is off and SERVER_NAME is a bare :8080, so Caddy neither requests nor serves a certificate.

Two files carry what used to live on the images:

  • .docker/Caddyfile — a port of .docker/nginx.conf and .docker/templates/default.conf.template. Two notes. RE2, which Caddy uses, has no negative lookahead, so the /.well-known exception that nginx expressed inline is a matcher of its own. And the deny-list keeps yml but not yaml, exactly as nginx had it, because public/api-spec-v1.yaml is served.
  • .docker/php.ini — a mirror of the fpm image's env-driven ini templates (fpm/conf.d/90-php.ini, mods-available/opcache.ini, mods-available/xdebug.ini) plus the error logging from its pool config. It uses the image's own PHP_* variable names, all defaulted in the Dockerfile the way the fpm image defaults them, so the same overrides work and no variable is ever unset.

Logging and metrics

There is no nginx prometheus exporter to port. nginxinc/nginx-unprivileged:alpine has --with-http_stub_status_module compiled in, but the itkdev template never adds a stub_status location, so nginx exported nothing. php-fpm sets pm.status_path = /status, which the nginx config never routed.

The stack's only exporter is supercronic, inside the fpm image:

/usr/local/bin/supercronic -json -prometheus-listen-address 0.0.0.0:9746 "${CRONFILE}" | jq -c '. + {log_type: "cron", source: "supercronic"}'

The entrypoint runs that only if [ -f "${CRONFILE}" ], and CRONFILE=/app/crontab. This repository has no crontab, so supercronic has never started here and /cron-metrics has always answered 502 — nginx was a reverse proxy in front of nothing.

Caddy ships a real exporter (http.handlers.metrics), so /metrics replaces /cron-metrics behind the same ITKMetricsAuth@file middleware, and there is finally something behind it: 52 metric families — request counts, durations and sizes by code, method and handler, requests in flight, plus Go runtime and process metrics. FrankenPHP's own thread metrics only exist in worker mode, which is off. A supercronic sidecar, if one is added, needs a route of its own.

Logging matches where it can. php-fpm sent error_log, slowlog and — via catch_workers_output = yes — all worker stderr to ${PHP_LOGS}, i.e. /dev/stderr, and configured no access log, so nginx's was the only per-request record. .docker/php.ini keeps error_log on ${PHP_LOGS}.

Caddy's access log is JSON, not nginx's log_format main text. Every field that format carried is present, verified against a live request:

nginx Caddy JSON
$http_x_real_ip request>headers>X-Real-Ip, and request>client_ip resolved
$remote_user user_id
$time_local ts
$request request>method, request>uri, request>proto
$status status
$body_bytes_sent size
$http_referer request>headers>Referer
$http_user_agent request>headers>User-Agent
$http_x_forwarded_for request>headers>X-Forwarded-For
duration, bytes_read

The text layout cannot be reproduced byte for byte: that needs the Caddy transform encoder, and this build has console, json, append, filter and journald only — format transform fails with module not registered: caddy.logging.encoders.transform. Getting it would mean the -builder image and xcaddy, compiling Caddy and PHP from source, which gives up the published-image property entirely. Worth a decision: JSON also matches supercronic, which the fpm image already runs with -json, so the house style arguably is JSON.

A bug found on the way. set_real_ip_from 172.16.0.0/16 covers neither the frontend network (172.18.0.0/16) nor the client (172.22.0.0/16), so nginx's real_ip module never matched and real-IP resolution silently did nothing — the log line only looked right because it printed the $http_x_real_ip header directly rather than the resolved address. The Caddy port had inherited the same range and logged client_ip as the proxy hop. It now trusts private_ranges10/8, 172.16/12, 192.168/16, localhost — which is what a /12 in the template would have meant, and client_ip resolves to the forwarded client.

Changes

  • Add the frankenphp service in docker-compose.override.yml and docker-compose.server.override.yml; park phpfpm and nginx in a never-enabled profile
  • Build on dunglas/frankenphp:1.12-php8.5, adding pdo_mysql, amqp, intl, gd, zip, xdebug and msmtp
  • Port the nginx configuration to .docker/Caddyfile and the fpm image's PHP settings to .docker/php.ini
  • Move the stack to PHP 8.5: itkdev/php8.5-fpm, itkdev/supervisor-php8.5 and the composer platform requirement
  • Point Taskfile.yml, the workflows, both Woodpecker files and the docs at the frankenphp service
  • Move staging's ITKBasicAuth middleware, the www-redirect labels and production's shared .env.local mount off the disabled services
  • Drop runtime/frankenphp-symfony

Why

runtime/frankenphp-symfony is gone because symfony/runtime has done its job natively since 7.4: SymfonyRuntime::getRunner() returns its own FrankenPhpWorkerRunner when $_SERVER['FRANKENPHP_WORKER'] is set, and FrankenPHP sets that itself for a worker script (worker.go:155). The package is only for Symfony older than 7.4, which the FrankenPHP docs now say explicitly. It happens to also have no Symfony 8 release, but redundancy is the reason it is not here.

PHP 8.5 is not a drive-by. The web container and the messenger worker share vendor/ and var/cache over the same bind mount, so they have to agree on the PHP version; pinning FrankenPHP to 8.5 without moving itkdev/supervisor-php8.4 would put two PHP versions on one compiled container.

The phpfpmfrankenphp rename touches twelve files, which is more churn than it looks like it should be. docker compose exec phpfpm appears eleven times in Taskfile.yml alone, plus six workflows, both Woodpecker files, the README and claude.md. Disabling the service leaves all of them with nothing to exec into. The alternative — keeping the service named phpfpm while swapping its image — would have been a near-zero diff, but a service called phpfpm running Caddy is worse to live with than a one-off rename.

Screenshot of the result

No user interface changes.

Checklist

  • My code is covered by test cases.
  • My code passes our test (all our tests).
  • My code passes our static analysis suite.
  • My code passes our continuous integration process.

The container work needed no tests of its own — its value is that the existing suite passes unchanged on the new container, which it does. The state fixes did need them, and had none: ProcessDetectionResultHandlerTest mocks the handlers wholesale, so the factories were entirely uncovered, and neither the dashboard nor the Security Contract CRUD was in the admin smoke test. Twenty tests added; the suite is now 71 tests, 144 assertions, alongside PHPStan, PHP-CS-Fixer, twig-cs-fixer, composer validate --strict, composer normalize --dry-run, prettier and markdownlint. The API spec export is unchanged and fixtures load.

Coverage says nothing about whether tests discriminate, so each set was run against the code it replaced. The two testAFailedFlushLeavesNothingForTheNextCall tests fail there, reporting a second call that reused a stale buffered entity. Both dashboard tests fail when unsetAll() is moved after setController() — the silent failure mode, where the URL points elsewhere and nothing throws. The rest pass either way and are behaviour-preservation guards, which is worth stating rather than implying.

One quirk left deliberately intact: ModuleVersion::getVersion() reports 'Unknown' for a null version, so the scan this replaced never matched a null-versioned module and wrote a row per occurrence. Keying it properly would change which rows get written, so it stays, pinned by a test that says so. Worth deciding separately.

Behaviour was checked against what nginx did, not just for a 200: /health/live answers 200 both directly and through Traefik over HTTPS (server: FrankenPHP Caddy), /health/detail reports the database and RabbitMQ healthy — so pdo_mysql and amqp are really loaded — and the deny rules match, including /index.php and /index.php/… returning 404 the way nginx's internal made them. Every ported PHP setting was read back out of the running container and matches the fpm image.

One thing to expect in CI: every job now builds the image instead of pulling one, since docker compose run --rm frankenphp has a build:. That is the cost of the Dockerfile, and a published itkdev/frankenphp image is what removes it.

The ITKMetricsAuth and ITKBasicAuth middlewares could not be exercised locally, and not because of these labels. The Traefik publishing :443 on this machine belongs to another project and defines neither middleware, so routers referencing them are dropped and the base router serves the path unauthenticated — /health/detail behaves the same way on develop. The itkdev Traefik's own API confirms the router is built correctly: itksites-metrics@docker, enabled, priority 58, service resolved, ITKMetricsAuth@file attached.

Additional comments or questions

Progress on the original list:

  • xdebug — installed, xdebug.mode off by default, still driven by PHP_XDEBUG_MODE
  • messenger/supervisor — untouched, still itkdev/supervisor-php8.5. Whether the worker should also be FrankenPHP is the open question
  • composer — in the image
  • extensions — see above
  • logging — PHP errors go to ${PHP_LOGS} as under php-fpm; Caddy's access log replaces nginx's, as JSON rather than the main text layout. See above
  • metrics — /metrics serves Caddy's Prometheus endpoint behind ITKMetricsAuth, which is strictly more than nginx exported. A supercronic sidecar still needs its own route if cron ever runs here
  • trusted proxies — carried over and corrected: the template's /16 matched nothing. See above
  • run image as non-root — still root. FrankenPHP wants to write to /data and /config, so this needs its own look
  • SSL Termination - traefik or frankenphp? — Traefik, unchanged. Caddy is HTTP-only and never sees a certificate

Worker mode

Worker mode is available now and off by choice, not by constraint. Uncommenting worker ./public/index.php in .docker/Caddyfile is the whole change: FrankenPHP sets FRANKENPHP_WORKER=1, and symfony/runtime — v8.1.0 here — picks its own FrankenPhpWorkerRunner off that. Present from the 7.4 branch onward; absent in 7.2 and 7.3.

What held it back was application state, not plumbing. An audit against igor-php v0.9.5 found three things; all three are fixed on this branch.

The contract for ResetInterface advises statelessness first — "we advise making your services stateless instead of implementing this interface when possible" — and that split the findings cleanly:

Made stateless. PackageVersionFactory and ModuleVersionFactory kept dedup buffers in properties, cleared after flush() rather than in a finally, so a throw left entities from a closed EntityManager for the next call. The buffers only ever needed to live for one call — they stand in for the repositories between persist() and flush() — so they are locals now, threaded through the private helpers. Nothing to forget to clear. This was a live bug, not a worker-mode one: these factories run under messenger:consume, long-running via supervisor, where --failure-limit=1 merely contained it. Their version buffers were also keyed on $package->getId(), which only holds once Doctrine has assigned the ULID during persist(); they key on object identity now.

ResetInterface, the fallback, used once. LeantimeService memoises the Leantime user directory and documents it as "at most once per service instance" — the exact assumption worker mode breaks, and it is reachable from the web through RepoAdvisoryController with every service in that chain shared: yes. Its cache cannot become a local: resolveUserName() runs inside a loop over tickets, so dropping it costs an API round trip per ticket. reset() restores the per-request lifetime, and debug:container --tag=kernel.reset confirms autoconfigure applied it.

Neither — cleared at the call site. DashboardController and SecurityContractCrudController mutated the injected AdminUrlGenerator without ->unsetAll(), which AppExtension and RepoAdvisoryService both did. The object is EasyAdmin's, so neither lever applies; the chain now opens with unsetAll() at all four sites.

Symfony 8.1's FRANKENPHP_RESET_KERNEL=1 clones the kernel between requests and remains available as a comparison point — if throughput with it barely differs, the state work was the cheaper route to the same place.

Full audit, including the igor-php cross-check: the readiness report linked in the thread.

Coming from the phpfpm stack, the containers it left behind have to be removed once, since a profile stops a service from starting but does not stop one already running:

docker compose rm --stop --force phpfpm nginx

@turegjorup turegjorup self-assigned this Aug 1, 2025
…-poc

# Conflicts:
#	composer.json
#	composer.lock
#	docker-compose.yml
Bring the POC compose config in line with develop: required-variable syntax, mariadb healthcheck dependency, protected /health/detail route, and the markdownlint/prettier dev services.
Replace the phpfpm and nginx pair with a single FrankenPHP container, added in
the per-environment override files, and move the stack to PHP 8.5.

Changes

- Add the `frankenphp` service in `docker-compose.override.yml` and
  `docker-compose.server.override.yml`, and park `phpfpm` and `nginx` in a
  profile that is never enabled
- Port the nginx configuration to `.docker/Caddyfile` and the PHP settings the
  fpm image derives from `PHP_*` variables to `.docker/php.ini`
- Keep TLS termination in Traefik: `auto_https` is off and Caddy serves plain
  HTTP on 8080
- Build on the published `dunglas/frankenphp:1.12-php8.5` image, adding the
  extensions it omits: pdo_mysql, amqp and intl
- Move `itkdev/php8.5-fpm`, `itkdev/supervisor-php8.5` and the composer
  platform requirement to PHP 8.5
- Point Taskfile, workflows, Woodpecker, the staging and redirect overrides and
  the docs at the `frankenphp` service

Why

The POC was a year behind develop and pinned to Symfony 7. Putting the service
in the override files keeps the base compose files as the template ships them,
so the swap is one file per environment rather than a rewrite.
@github-actions

Copy link
Copy Markdown

API Specification - Non-breaking changes

No changelog changes

@codecov-commenter

codecov-commenter commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77419% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 41.33%. Comparing base (bae8493) to head (a9cb37f).
⚠️ Report is 49 commits behind head on develop.

Files with missing lines Patch % Lines
...ontroller/Admin/SecurityContractCrudController.php 0.00% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop      #59      +/-   ##
=============================================
+ Coverage      37.14%   41.33%   +4.19%     
- Complexity       948     1092     +144     
=============================================
  Files            133      146      +13     
  Lines           2972     3493     +521     
=============================================
+ Hits            1104     1444     +340     
- Misses          1868     2049     +181     
Flag Coverage Δ
unittests 41.33% <96.77%> (+4.19%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The populated-database job checks out the base branch before the pull request, so it sees two revisions of docker-compose.yml and cannot assume either service name.
Changes

- Expose Caddy's Prometheus endpoint at `/metrics`, behind the `ITKMetricsAuth`
  middleware `/cron-metrics` used
- Log requests as JSON from Caddy and keep PHP's `error_log` on `${PHP_LOGS}`,
  where php-fpm sent it
- Mirror the itkdev/php8.5-fpm ini templates in `.docker/php.ini` using the
  image's own `PHP_*` variable names, defaulted in the Dockerfile
- Trust `private_ranges` instead of `172.16.0.0/16`

Why

nginx exported no metrics at all: `stub_status` is compiled into the image but
the template never enabled it, php-fpm's `pm.status_path` was never routed, and
the supercronic behind `/cron-metrics` only starts when `/app/crontab` exists,
which this project has no. Caddy has a real exporter, so the endpoint finally
has something behind it.

`172.16.0.0/16` covers neither the `frontend` network (172.18/16) nor the client
(172.22/16), so `set_real_ip_from` never matched and real-IP resolution silently
did nothing. `private_ranges` is what a `/12` in the template would have meant.

The ini file previously hardcoded values the fpm image derives from environment
variables, and invented two variable names. Mirroring the image's templates
keeps the same overrides working.
symfony/runtime has shipped FrankenPhpWorkerRunner since 7.4, selected automatically off the FRANKENPHP_WORKER=1 that FrankenPHP sets for a worker script. runtime/frankenphp-symfony is only for older Symfony, so its lack of a Symfony 8 release never blocked anything — dropping it was right, but redundancy was the reason, not incompatibility.

What actually holds worker mode back is application state, not the runtime.
Changes

- `PackageVersionFactory` and `ModuleVersionFactory` keep their deduplication
  buffers in locals threaded through the private helpers, not in properties
- Key the version buffers on object identity rather than on the entity id, so
  they hold before Doctrine has assigned one
- `LeantimeService` implements `ResetInterface`; autoconfigure tags it
  `kernel.reset`
- Add tests for all three; the factories had none

Why

The contract advises statelessness over `ResetInterface` where it is possible,
and for the factories it is: the buffers exist only to stand in for the
repositories between `persist()` and `flush()` within one call, so their
lifetime is exactly that call. Making them locals also fixes the reason they
were flagged — they were cleared after `flush()` rather than in a `finally`, so
a failing flush left entities from a closed EntityManager for the next call.
That was a live bug in the messenger consumer, which already runs long.

`LeantimeService` is the case the fallback is for. Its cache cannot become a
local: `resolveUserName()` is called inside a loop over tickets, so dropping it
would cost an API round trip per ticket. `reset()` restores the per-request
lifetime `loadUsers()` already documents.

The two `testAFailedFlushLeavesNothingForTheNextCall` tests fail against the
previous implementations; the other thirteen pass either way and guard the
deduplication behaviour, including a null-version quirk left deliberately
intact.
Changes

- Call `unsetAll()` before `setController()` in `DashboardController` and
  `SecurityContractCrudController`
- Add `DashboardControllerTest`, and put `SecurityContractCrudController` into
  the admin smoke test's provider

Why

EasyAdmin registers `AdminUrlGenerator` as `shared: no`, so each injection point
gets its own instance — but both consumers here are shared, so that instance
lives as long as they do, which in a worker is longer than one request. It
accumulates route parameters as it is used. `AppExtension` and
`RepoAdvisoryService` already opened with `unsetAll()`; these two were the
inconsistency, and inconsistency is what rots.

Neither site was covered. The dashboard test asserts where the redirect lands
rather than that it merely redirects, because the failure mode worth catching is
silent: `unsetAll()` placed after `setController()` wipes the controller back
out and produces a URL pointing somewhere else without anything throwing. Both
new tests fail against that arrangement.
Worker mode needs no package and no code change — symfony/runtime has shipped FrankenPhpWorkerRunner since 7.4 and the Caddyfile already reads {$FRANKENPHP_CONFIG} — so it is documented as an environment variable, with what it measured here and the caveats on those numbers.

The statelessness rules go in claude.md as a section rather than a bullet, because messenger:consume is already long-running in production and the rules apply whether or not worker mode is on. Each rule points at the service in this codebase that follows it.
Changes

- Add `igor-php/igor-php` as a dev dependency and register `IgorPhpBundle` in dev
- Configure it in `igor.json`: project scope, dev environment, baseline file
- Record the 33 existing findings in `igor-baseline.json`, each with a reason
- Add `composer worker-state-check` and `worker-state-baseline`, and a
  `Worker state audit` job to the review workflow

Why

The statelessness rules the last few commits established are the kind that decay
without enforcement, and they matter whether or not worker mode is ever switched
on: `messenger:consume` is already long-running in production.

igor-php audits every shared service in the compiled container rather than
grepping for patterns, which is why it caught the `AdminUrlGenerator` mutations
that reading `src/` for stateful properties had missed. Against that, roughly two
thirds of its project findings are noise — mostly Doctrine entities returned from
a repository, which it reads as shared services — so it is only usable behind a
baseline. Vendor code is out of scope: it reported 341 findings there, none of
them ours to fix.

Every baseline entry carries a reason rather than the generated TODO, so the file
documents why each is safe instead of just silencing it. Verified the gate is
live: introducing a stateful property on a service fails the audit, and removing
it passes.
Changes

- Turn the Dockerfile into `base` → `dev` → `prod`; the override files pick a
  target, and a bare `docker build .` gets `prod`
- `prod` drops Xdebug and sets `opcache.validate_timestamps=0`
- Move the Xdebug ini to `.docker/php-dev.ini`, mounted only in development

Why

One image served both environments, so production loaded a debugger it never
used and OPcache stat-ed every file on every request —
`validate_timestamps=1` with `revalidate_freq=0` means check every time, which
is the opposite of what that pair is usually meant to express.

Turning timestamp validation off makes a code change need a new container.
Both deployment paths already give it one: staging runs
`up -d --force-recreate`, the release playbook brings the stack up again, and a
fresh container starts with an empty OPcache, so it compiles what is on disk.

The Xdebug ini moves rather than staying inert in production, so no file
mentions settings whose extension is absent. Verified per stage: dev has the
extension with timestamps validated, prod has neither, and coverage still
collects in dev — `XDEBUG_MODE=coverage` reaches Xdebug even though `ini_get`
reports the ini value, which the CI job depends on.
Missed from the previous commit: the script that wrote them asserted against README wording first and stopped before reaching these two.
Changes

- Create `deploy` and `runner` in the image, drop Caddy's
  `cap_net_bind_service`, hand it `/data/caddy` and `/config/caddy`, and end
  both stages with `USER deploy`
- Make the id a `DEPLOY_UID` build argument, defaulting to 1042
- Mirror phpfpm's `user: ${COMPOSE_USER:-deploy}` on the local override
- Add a health check on `/health/live` to both overrides
- Reorder `task site:update` to install before waiting on health

Why

Root was the one regression against the setup this replaces: phpfpm ran as
`${COMPOSE_USER:-deploy}`, and beyond the security footprint, a root container
writing `var/` through a bind mount leaves root-owned files on the host.

The id has to match whoever owns that checkout, and in devops_docker-images it
depends on the base distro — consistently across 8.3, 8.4 and 8.5, the ubuntu
tags give `deploy` 1000 and the alpine ones 1042. The servers run the alpine
tags, so 1042 is the default; a build argument because the number belongs to the
host account rather than to this image. Nothing needs a capability to bind 8080.

`up --wait` previously only waited for the process to exist, since only mariadb
had a check. Now it waits for the application to answer. That check calls into
the application, so it cannot pass before dependencies are installed — hence
starting, installing, then waiting, which is also the order that task always
meant.

Coming from the root-run container, `var/` needs handing over once:
`docker compose run --rm --user root frankenphp chown -R deploy:deploy /app/var`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants