Docker and FrankenPHP

The whole product ships as one FrankenPHP 1.12 / PHP 8.4 image on a Debian base. FrankenPHP runs Laravel in worker mode over HTTP/2 and HTTP/3 in a single process — no nginx + PHP-FPM pair to babysit. One image, multiple roles: the HTTP app, the queue:work daemon, the reverb:start daemon, and schedule:work are different command:s on a self-hosted orchestrator (compose / k8s / VPS). CaaS recipes (Magic Containers, Cloud Run, Cloudflare) cover the HTTP app only; queue worker, Reverb, and scheduler are self-hosted always-on processes, not CaaS sidecars (ADR 0009).

Why Debian, not Alpine

The libSQL PHP client (turso/libsql) loads a glibc native library through FFI — ffi.enable=1 is set in the image. No musl build of that library exists, so Alpine is out. We size the base around that constraint instead of fighting it: dunglas/frankenphp:1.12.7-php8.4-bookworm.

Four build stages

The Dockerfile chains four stages. Dev dependencies and Node never reach production; the frontend is compiled once and copied in as public/build.

flowchart LR
    app["app<br/>PHP extensions + composer deps<br/>optimized, no dev"]
    dev["dev<br/>+ dev dependencies, composer kept<br/>used by docker-compose.dev.yml"]
    assets["assets<br/>node:22-alpine, npm ci + npm run build<br/>emits public/build"]
    runtime["runtime<br/>+ public/build copied in<br/>non-root uid 1000, no caps<br/>opcache validate_timestamps=0"]
    app --> dev
    app --> runtime
    assets -->|public/build| runtime
  • app — installs ffi intl bcmath pcntl posix zip opcache exif gd, runs composer install --no-dev, caches views, chowns storage to uid 1000.
  • dev — re-adds composer and installs dev dependencies; source is bind-mounted over /app at runtime.
  • assetsnode:22-alpine, npm ci, npm run build. Disjoint from the PHP lineage.
  • runtime — from app, copies public/build from assets, drops to appuser (uid 1000), strips capabilities, locks opcache.

Hardening

Verified in the runtime stage:

  • Non-root: useradd --uid 1000 --system appuser, then USER appuser.
  • No capabilities: setcap -r /usr/local/bin/frankenphp (port :8080 is unprivileged, so none are needed).
  • Read-only root filesystem and no --privileged are enforced at the runtime layer (Magic Containers / Compose read_only: true), not in the Dockerfile.
  • opcache immutable-code: opcache.validate_timestamps=0 — code is baked into the image and never changes at runtime, so PHP skips re-statting files on every request. interned_strings_buffer=16, max_accelerated_files=20000, memory_consumption=128.
  • Healthcheck: curl -fsS http://127.0.0.1:8080/up every 10s.

Build

docker compose -f docker-compose.build.yml build

Default platform is linux/amd64 (Magic Containers are amd64-only). Nothing is pushed unless you explicitly opt in with your own registry user:

IMAGE_NAME=<registry-user>/qcentic-edge-template:latest \
  docker compose -f docker-compose.build.yml build --push

Three Compose files

File Purpose
docker-compose.dev.yml Bind-mounted source, Vite HMR, local sqld, MinIO, Reverb; optional Redis via --profile redis.
docker-compose.prod.yml Self-hosted prod (VPS / k8s): published image, one-shot migrate, worker, scheduler, Reverb against remote libSQL; optional bundled sqld and Redis. Not a Magic Containers spec — Magic Containers runs the HTTP app + installer only (ADR 0009).
docker-compose.build.yml Builds the production image locally (linux/amd64); no push unless build --push.

See also

Built by Qcentic