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— installsffi intl bcmath pcntl posix zip opcache exif gd, runscomposer install --no-dev, caches views, chownsstoragetouid 1000.dev— re-adds composer and installs dev dependencies; source is bind-mounted over/appat runtime.assets—node:22-alpine,npm ci,npm run build. Disjoint from the PHP lineage.runtime— fromapp, copiespublic/buildfromassets, drops toappuser(uid 1000), strips capabilities, locks opcache.
Hardening
Verified in the runtime stage:
- Non-root:
useradd --uid 1000 --system appuser, thenUSER appuser. - No capabilities:
setcap -r /usr/local/bin/frankenphp(port:8080is unprivileged, so none are needed). - Read-only root filesystem and no
--privilegedare enforced at the runtime layer (Magic Containers / Composeread_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/upevery 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. |