Skip to content

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 real node_modules paths are not stripped). Build stage: node:22-bookworm-slim with 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, /tmp tmpfs for repository workspaces) because review context needs git.
  • 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
  • Compose services (project name quavon, file deploy/compose/docker-compose.yml): caddy (only service with published ports, HTTP_PORT/HTTPS_PORT), api, worker, init (one-shot db-init; api and worker wait for service_completed_successfully), postgres (built from deploy/images/postgres), valkey. Postgres and Valkey are on an internal network without published ports. All services: read_only where possible, cap_drop: [ALL], no-new-privileges, healthchecks, restart unless-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 *_FILE variables. 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/.env written 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, optional GITHUB_URL. Compose sets NODE_ENV=production and TRUST_PROXY=1 for 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: init creates 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 via docker compose run --rm.
  • Backups, restore and upgrades are added before the first external design partner; Helm and air-gap bundles follow later.

The open core is released under the Apache License 2.0.