matrix-axon

ADR 0052 — Docker deployment framework

Targets Milestone 13 (deployment / onboarding; see docs/mvp/implementation.md §13). Builds on the axon init first-run bootstrap from ADR 0051 (PR #208), which is stacked on the platform config-dir discovery in ADR 0050 (PR #206). This ADR can land independently as documentation; the follow-up implementation PR depends on axon init being present on the base branch.

Context

Axon today has no application container. The only container asset is a dev-oriented docker-compose.yml that runs Postgres (and, behind the integration profile, a Synapse test homeserver); the server itself runs on the host via cargo run / run.sh. ADR 0051 makes the bare-binary-on-a-host first run smooth (axon init generates a config with a store_key and points the operator at Postgres), but the everything-in-containers path — the one many self-hosters actually want — does not exist.

The M13 goal is “download one thing, run one command, get a working Axon.” For a container audience that means: a single docker compose up brings up Postgres and axon-server together, fully configured, with an obvious path to put TLS / remote access in front.

Several properties of the server make this tractable (established by reading the tree):

Decision

Ship a first-party Dockerfile for axon-server plus a deployment Compose stack under a new top-level deploy/ directory (leaving the existing dev docker-compose.yml untouched). Four decisions shape it:

1. Scope: Dockerfile + full-stack Compose

docker compose up brings up Postgres and axon-server together and yields a fully running Axon. The image is also usable standalone (operator supplies their own DATABASE_URL). Multi-arch publishing to a registry is done as beta distribution: both images push to a private GHCR package (ghcr.io/matrix-axon/axon-{server,web}, linked to the repo via an org.opencontainers.image.source label) via deploy/publish.sh or the publish-images.yml workflow (self-hosted runner, workflow_dispatch + beta-* tag). The compose image refs are env-overridable (AXON_SERVER_IMAGE / AXON_WEB_IMAGE) so testers docker compose pull prebuilt images while contributors keep building from source. A public GHCR/Docker Hub release remains a later step.

2. The TUI is not in the server image

axon-tui is an interactive per-user client, awkward to run in a server container and already shipped as native binaries by cross-build.yml. The server image stays server-only.

3. First-run config via axon init, not a bespoke .env path

Rather than re-implement CSPRNG key generation and config writing in shell, the container reuses axon init (ADR 0051). A thin, idempotent entrypoint runs, on first boot only:

[ -f "$AXON_CONFIG" ] || axon init --non-interactive \
    --config "$AXON_CONFIG" --database-url "$DATABASE_URL" --no-token
exec axon --config "$AXON_CONFIG"

$AXON_CONFIG points at the mounted data volume (/var/lib/axon/axon.toml), so the generated store_key + Postgres URL persist; later boots find the file, skip init, and keep the key stable. The entrypoint never passes --force (which would regenerate store_key and orphan data). init runs migrations via its DB probe; the subsequent server boot re-runs them idempotently.

The container’s only additions on top of init are container glue: pointing the config at the persisted volume, the idempotency guard, passing the Compose DB URL, and defaulting the bind for container networking — AXON_SERVER__HOST=0.0.0.0 and AXON_SERVER__ALLOW_INSECURE_BIND=true (these live in env, not the generated config, which holds only db url + store_key). Both are overridable.

init’s --start-postgres helper is loopback-guarded and only fires when the DB is unreachable; in Compose the postgres service is up before axon-server starts (depends_on: service_healthy) and is not a loopback host, so it never engages — Compose owns Postgres, init owns config. The first bearer token is minted and printed to the logs on first boot by default (AXON_INIT_PRINT_TOKEN defaults on): the operator is bootstrapping their own single-admin box and can do nothing without a token, so the “secrets don’t belong in logs” principle yields to bootstrap usability here. It prints once (first boot only); operators who ship logs off-box set AXON_INIT_PRINT_TOKEN=false and mint out-of-band with axon token issue, and any exposed token is revocable with axon token revoke. Matrix accounts are provisioned at runtime only (POST /v1/accounts/login or POST /v1/accounts/import), per ADR 0051 and ADR 0024.

4. TLS / remote access as optional Compose profiles

The core stack serves plain HTTP for LAN use. Two opt-in profiles front it, covering the two common self-host situations:

In both profiles the server’s port 8080 is not published to the host; only the proxy is reachable. axon keeps 0.0.0.0 + ALLOW_INSECURE_BIND=true, bridge-internal only.

5. The web client rides the stack behind a same-origin front door

The browser web client (clients/web, a Preact/Vite SPA) joins the stack as a web service that becomes the single HTTP front door. It is a caddy:2 image built in two stages — a Node/pnpm stage runs pnpm build to produce the static dist/, copied into the Caddy runtime — that serves the SPA and reverse-proxies the API (/v1/* and /healthz, including the /v1/ws WebSocket) to axon-server:8080 on the bridge. Three properties of the client force this shape (established by reading clients/web):

Default web sign-in is the one-time first-credential web bootstrap (#265), made zero-paste here. The entrypoint arms it non-interactively (bootstrap_web_auto, a new ServerConfig field that skips the TTY prompt for headless runs) and uses axon init --no-token so the bootstrap owns the first credential. Because requests reach axon through the web proxy — so axon sees the proxy’s bridge IP, never loopback — the stack also sets bootstrap_web_allow_remote; the unguessable one-time code and wrong-URL lockout remain the protection. The bootstrap’s bearer success page, served same-origin with the SPA via the front door, writes the freshly minted token into the client’s localStorage slot and redirects — so the operator lands signed in with nothing to copy, and the token never travels in a URL. Token paste (any axon token issue token) stays available as a fallback, and OAuth/SSO is an optional upgrade; both are documented in the deploy README. Minting a token consumes an unused bootstrap (it is the first credential), so run-docker.sh defaults to surfacing the bootstrap URL and makes TUI token minting an explicit --tui opt-in.

Consequences

Implementation plan (for the follow-up M13 PR — deploy/ silo)

Assets (all new; the dev docker-compose.yml is untouched):

Out of scope (later): a GHCR multi-arch publish workflow; Kubernetes/Helm manifests.

Verification (for the follow-up PR)

  1. cd deploy && cp .env.example .env && GIT_HASH=$(git rev-parse --short HEAD) docker compose up --build -d; docker compose ps shows both services healthy; curl -fsS localhost:8080/healthz → {"status":"ok"}.
  2. Migrations ran — logs show “running database migrations”; psql -c '\dt' lists Axon tables
    • _sqlx_migrations.
  3. axon init ran once and store_key is stable — logs show “Wrote configuration to /var/lib/axon/axon.toml” on first boot only; docker compose down && up -d skips init (no such line) and the store_key in $AXON_CONFIG is unchanged.
  4. First token — docker compose exec axon-server axon token issue --label default prints a token; curl -H "Authorization: Bearer <tok>" localhost:8080/v1/status returns 200.
  5. Account provisioning — POST /v1/accounts/login (or /import), confirm sync starts. Use only a test homeserver account.
  6. caddy profile — docker compose --profile caddy up -d with DOMAIN set; HTTPS reaches /healthz through Caddy.
  7. tailscale profile — with a valid TS_AUTHKEY, docker compose --profile tailscale up -d; tailscale serve status shows the mapping; reach https://<device>.<tailnet>.ts.net/healthz from another tailnet device.
  8. Graceful shutdown — docker compose stop axon-server; logs show a clean drain, no error exit.