matrix-axon

Deploying Axon with Docker

A first-party image for axon-server plus a full-stack Compose deployment: one docker compose up brings up Postgres, Axon, and the browser web client together, fully configured, migrated, and self-bootstrapping. See ADR 0052 for the design.

The stack has a single HTTP front door — the web service — that serves the web client and reverse-proxies the API (/v1, /healthz, and the /v1/ws WebSocket) to axon-server on the internal network. Because the client and the API share one origin, the browser needs no CORS, and a TUI can point at the very same URL (axon-server itself is not published to the host). Open the browser at http://<host>:8080.

This is the deployment stack (deploy/docker-compose.yml). The Postgres-only docker-compose.yml at the repo root is for local development and is unrelated.

Quick start (prebuilt images, no clone)

Run the full Axon stack — server and the web client — from prebuilt images, with no clone and no build. Images are public, so no docker login or credentials are required.

Prereqs: Docker

# 1. Fetch the one-file Compose, then start it (images pull automatically)
curl -fsSL "https://raw.githubusercontent.com/matrix-axon/matrix-axon/refs/heads/main/deploy/docker-compose.beta.yml" \
  -o docker-compose.yml
docker compose up -d

# 2. Open the printed one-time setup URL in your browser (if installation hasn't yet finished, try again after a minute):
docker compose logs axon-server | grep 'bootstrap is armed'

Opening that http://<host>:8080/bootstrap/<code> URL mints your first credential and signs the web client in with nothing to paste. Update later with docker compose pull && docker compose up -d; stop with docker compose down (add -v to wipe data too). For encrypted remote access, run tailscale serve http://localhost:8080 on the host, or see TLS / remote access profiles below.

To connect the terminal or desktop client to this stack, see Installing Axon.

Quick start from a checkout (builds from source)

cd deploy
./run-docker.sh

run-docker.sh brings up the whole stack, waits for it to go healthy, and prints the one-time web bootstrap URL. Open it in a browser: the setup page mints your first credential and signs the web client in — nothing to copy or paste (see The first sign-in). Add --tui to also mint a token and write a ready-to-use axon-tui config (it never silently overwrites an existing one — it warns, confirms, and backs the old file up with a timestamped .bak). Flags: --tui (also set up the TUI), --yes (assume yes to the overwrite prompt).

Or drive Compose directly

cd deploy
# No config needed — the stack runs with built-in defaults. GIT_HASH is optional
# (stamps the build with the current commit; falls back to "unknown").
GIT_HASH=$(git -C .. rev-parse --short HEAD) docker compose up --build -d
docker compose ps            # postgres, axon-server, web should become healthy
curl -fsS localhost:8080/healthz   # -> {"status":"ok"} (proxied through `web`)

# grab the one-time web bootstrap URL and open it in a browser to finish setup:
docker compose logs axon-server | grep "web bootstrap is armed"
# -> open http://localhost:8080/bootstrap/<code>  (rewrite the host to your front door)

Create a .env (cp .env.example .env) only to override defaults — credentials, publish address, a boot-time Matrix account, or a TLS profile. Postgres is reachable only on the internal compose network (it publishes no host port), so its default password is a formality between the two containers; change it only if you expose the DB. The critical secret — store_key — is generated by axon-server init onto the axon-data volume, never hardcoded here.

On first boot the container runs axon-server init once to generate its config — a CSPRNG store_key plus the Postgres URL — onto the axon-data volume at /var/lib/axon/axon.toml. Later boots find the file and skip init, keeping the key stable. Database migrations run automatically at startup.

The first sign-in (web bootstrap)

By default the stack arms the one-time first-credential web bootstrap. On first boot — while no account, token, or SSO identity exists yet — axon-server prints a URL to its logs:

docker compose logs axon-server | grep "web bootstrap is armed"
# -> First-credential web bootstrap is armed: open http://<bind>/bootstrap/<code>

Open that path on your front door (http://<host>:8080/bootstrap/<code>) in a browser. The setup page mints your first credential and — because the front door serves the SPA and the bootstrap page on one origin — shows the token once, and its Open web client button drops the token into the web client and takes you in, signed in with nothing to paste. Copy the token from that page first if you also want it for another client. The <code> is unguessable and the bootstrap is consumed by first use (and then permanently closed).

Because every request reaches axon through the web proxy, axon can’t tell your browser from any other peer, so the stack enables bootstrap_web_allow_remote. The protection is the unguessable one-time code plus a wrong-URL lockout; on a TLS profile, also set AXON_PUBLISH_ADDR=127.0.0.1 so only the proxy is reachable. Disable the whole flow with AXON_BOOTSTRAP_WEB=false in .env.

Bearer tokens (TUI, scripts, token-paste fallback)

Clients also reach /v1/ with a plain bearer token. Mint one any time:

docker compose exec axon-server axon-server token issue --label default
curl -H "Authorization: Bearer <token>" localhost:8080/v1/status   # -> 200

Paste it into the web client’s manual-token field, drop it in an axon-tui config, or use it from scripts. Note: minting a token creates a credential, which consumes an unused web bootstrap — so if you want the zero-paste web sign-in, open the bootstrap URL first, then mint additional tokens. Revoke an exposed token with docker compose exec axon-server axon-server token revoke <id> (axon-server token list for ids). (With AXON_BOOTSTRAP_WEB=false, first boot instead mints and logs a token once unless you also set AXON_INIT_PRINT_TOKEN=false.)

Provisioning a Matrix account

Accounts are added at runtime via the API — there is no declarative/env provisioning:

curl -X POST localhost:8080/v1/accounts/login \
  -H "Authorization: Bearer <token>" -H 'Content-Type: application/json' \
  -d '{"username":"@you:example.org","homeserver_url":"https://matrix.example.org","password":"..."}'

To adopt an existing access token instead of a fresh password login, use POST /v1/accounts/import with homeserver_url, username, access_token, and device_id.

Web client

The browser client is served by the web front door at the same :8080. No CORS or server URL setup is needed — the client is same-origin with the API, and its version id (shown on the Settings page) is the GIT_HASH you passed at build time. Two ways in:

The front door compresses JS/CSS/HTML and proxied /v1/ JSON (encode zstd gzip in deploy/web/Caddyfile). Media (image/*, video/*, …) is left alone — those bytes are already compressed, and the media routes support Range. Clients that do not send Accept-Encoding (axon-tui) get the uncompressed body.

Optional: OAuth / SSO sign-in

For a nicer recurring sign-in you can enable Axon’s OAuth flow instead: set oauth.enabled = true on the server, register an axon-web client whose redirect_uris include your front-door origin’s /oauth/callback (e.g. http://localhost:8080/oauth/callback, or your TLS domain), and bake provider buttons into the web build with VITE_AXON_OAUTH_PROVIDERS (see clients/web/README.md). Because the client is same-origin, the redirect URI is just <your front-door URL>/oauth/callback.

TLS / remote access profiles

The core stack (the web front door) serves plain HTTP on :8080 — fine on a trusted LAN, but you must put TLS in front before exposing it to the internet. Two opt-in profiles do this; both reach the web service over the compose bridge (and web proxies the API onward to Axon), so set AXON_PUBLISH_ADDR=127.0.0.1 in .env to stop publishing :8080 on external interfaces (Compose can’t remove a published port from a service via a profile, so this is manual).

tailscale — remote access over your tailnet

No router port-forwarding, nothing exposed to the public internet. Generate an auth key, set TS_AUTHKEY in .env, then:

docker compose --profile tailscale up -d
docker compose exec tailscale tailscale serve status
# reach https://<device>.<tailnet>.ts.net/healthz from another tailnet device
# then open https://<device>.<tailnet>.ts.net for the web client

Public exposure is opt-in via Tailscale Funnel (configure separately).

Already running Tailscale on the host? The sidecar is a separate tailnet node (its own tailscaled in userspace networking, its own state on the axon-tsstate volume) and coexists with a host tailscaled — no daemon, port, or routing conflict. But you’d normally pick one. The simplest option when the host is already on your tailnet is to skip this profile and serve from the host instead — set AXON_PUBLISH_ADDR=127.0.0.1 and run:

tailscale serve --bg http://localhost:8080

Because the front door is same-origin (SPA and API on one port), the host’s tailscale serve proxies the whole thing with no extra config. One caveat if you do run both: if your host node is also named axon, Tailscale suffixes the second node axon-1, which changes the serve URL / cert domain — set a distinct TS_HOSTNAME in .env for the sidecar.

caddy — public domain + automatic Let’s Encrypt

For operators with a public domain who will forward :443 at their router. Set DOMAIN (and ideally ACME_EMAIL) in .env, point the domain’s DNS at your public IP, then:

docker compose --profile caddy up -d
curl -fsS https://$DOMAIN/healthz

Volumes & backup

Volume Contents Criticality
axon-data axon.toml (store_key), sync/crypto store, search index, media cache Critical — back this up
axon-pgdata Postgres data Critical
axon-tsstate Tailscale node state Rebuildable (re-auth)
axon-caddy-data / -config Caddy certs/state Rebuildable (re-issue certs)

⚠️ Losing axon-data (or resetting store_key) orphans all encrypted data — account tokens and historical Megolm keys become undecryptable, forcing re-login and loss of message history. Back up axon-data and never regenerate the config in place. The entrypoint never passes axon init --force for this reason.

Prebuilt images (beta access)

By default the stack builds axon-server and the web client from your checkout. You can instead run prebuilt images — useful for anyone who shouldn’t have to build. The images are published to a public GHCR package, so pulling them needs no login or credentials.

Run a published image:

# in deploy/.env:
AXON_SERVER_IMAGE=ghcr.io/matrix-axon/axon-server:beta
AXON_WEB_IMAGE=ghcr.io/matrix-axon/axon-web:beta

docker compose pull axon-server web
docker compose up -d
# then grab the first-sign-in URL:  docker compose logs axon-server | grep 'bootstrap is armed'

(run-docker.sh builds from source, so this path uses the two commands above directly rather than that script.)

Publish (maintainers):

echo "$GHCR_PAT" | docker login ghcr.io -u <you> --password-stdin   # write:packages
./publish.sh --tag beta            # multi-arch → ghcr.io/matrix-axon/{axon-server,axon-web}:beta + :<hash>
./publish.sh --tag beta --amd64-only   # skip arm64 if you have no QEMU

Or push a beta-* git tag (or run the Publish images workflow) to build and push from CI (.github/workflows/publish-images.yml, on a GitHub-hosted runner). On the package’s first push, set its visibility to Public in the org’s Packages settings — GHCR defaults new packages to private, and neither publish.sh nor the CI workflow’s token can change that setting, so it’s a one-time manual step. The package also needs this repository added under Manage Actions access with the Write role, or CI pushes fail with a 403 even though docker login succeeds. The images carry an org.opencontainers.image.source label, so GHCR links the package to this repo automatically.

CI publishes linux/amd64 only. arm64 has to be emulated through QEMU on a GitHub runner, which turned a release cargo build into a multi-hour job, so the workflow defaults to amd64 and arm64 is opt-in (dispatch with platforms = linux/amd64,linux/arm64). Apple Silicon and other arm64 hosts can still run these images — Docker emulates them — but startup and runtime are slower. publish.sh is unchanged and still builds multi-arch by default, so publishing from a local machine remains the way to get a true arm64 image until CI moves to a native arm64 runner.

Operations