ADR-0009: Self-hosted deployment with Docker Compose ​
Status: accepted (2026-10-12)
Decision ​
- One application image (
quavon-app) contains the workspace with production dependencies only, keeping pnpm's workspace symlinks so Node's type stripping works (sources under realnode_modulespaths are not stripped). Build stage:node:22-bookworm-slimwith pnpm; runtime:gcr.io/distroless/nodejs22-debian12:nonroot(Node 22.22), both pinned by digest. No shell, no package manager at runtime. The worker uses a second target (quavon-worker,node:22-trixie-slim+git, npm/corepack removed, non-root, read-only root filesystem,/tmptmpfs for repository workspaces) because review context needsgit. - Commands in the image (working directory
/app):- API:
node apps/api/src/main.ts - Worker:
node apps/worker/src/main.ts - CLI:
node apps/cli/src/bin.ts <command>(doctor,db-init) - Existing admin bins:
node apps/api/src/bin/<setup-token|break-glass|grant-instance-admin|keys>.ts
- API:
- Compose services (project name
quavon, filedeploy/compose/docker-compose.yml):caddy(only service with published ports,HTTP_PORT/HTTPS_PORT),api,worker,init(one-shotdb-init;apiandworkerwait forservice_completed_successfully),postgres(built fromdeploy/images/postgres),valkey. Postgres and Valkey are on an internal network without published ports. All services:read_onlywhere possible,cap_drop: [ALL],no-new-privileges, healthchecks, restartunless-stopped. - Secrets are generated by the installer into
deploy/compose/secrets/(directory mode 0700; files 0644 because non-swarm Compose secrets are bind mounts that keep host ownership and must be readable by non-root containers) and mounted as Compose secrets under/run/secrets/<name>. Applications read them through*_FILEvariables. File names:postgres_superuser_password,quavon_migrator_password,quavon_app_password,quavon_system_password,database_admin_url,database_migrator_url,database_url,database_system_url,valkey_password,valkey_url,encryption_key. Generated passwords are hex (URL-safe); the encryption key is 32 random bytes, base64. - Non-secret settings live in
deploy/compose/.envwritten by the installer:QUAVON_DOMAIN,PUBLIC_URL,TLS_MODE(acme|provided|internal),ACME_EMAIL,TLS_CERT_FILE/TLS_KEY_FILE(provided mode),HTTP_PORT,HTTPS_PORT,LOG_LEVEL, optionalGITHUB_URL. Compose setsNODE_ENV=productionandTRUST_PROXY=1for the API behind Caddy. - TLS is terminated by Caddy: automatic ACME certificates, operator-provided certificates, or Caddy's internal CA for local evaluation.
- First run:
initcreates roles (bootstrapRoles) and applies migrations; the installer then creates a setup token and prints the setup URL. - Operations go through the host wrapper
deploy/compose/quavon(install,doctor,setup-token,break-glass,keys,logs,status), which runs the matching command in the application image viadocker compose run --rm. - Backups, restore and upgrades are added before the first external design partner; Helm and air-gap bundles follow later.