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-onlydocker-compose.ymlat the repo root is for local development and is unrelated.
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.
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).
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.
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.
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.)
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.
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:
Token paste (anytime / fallback). Open http://<host>:8080, mint a token, and
paste it into the sign-in screen’s manual-token field:
docker compose exec axon-server axon token issue --label web
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.
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.
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 tailnetNo 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 EncryptFor 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
| 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 resettingstore_key) orphans all encrypted data — account tokens and historical Megolm keys become undecryptable, forcing re-login and loss of message history. Back upaxon-dataand never regenerate the config in place. The entrypoint never passesaxon init --forcefor this reason.
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/amd64only. arm64 has to be emulated through QEMU on a GitHub runner, which turned a releasecargo buildinto a multi-hour job, so the workflow defaults to amd64 and arm64 is opt-in (dispatch withplatforms = 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.shis 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.
docker compose logs -f axon-serverdocker compose stop axon-server (SIGTERM → clean drain)docker compose up -d --build axon-server
(or, for prebuilt images, docker compose pull && docker compose up -d)DATABASE_URL and a persistent /var/lib/axon volume.