Skip to content

Docker Compose deployment

The production deployment is a single Caddy front door (TLS + the admin SPA + an API reverse proxy) in front of the UniSSH server (plain HTTP on an internal network), with SQLite by default and optional Postgres / Prometheus profiles.

:80 / :443 internal compose network "unissh"
client ───────────────► caddy ──────────────────► server (:8443 HTTP)
│ TLS │
│ SPA (same-origin) └─ :9090 /metrics
└─ proxy /v1 /healthz /readyz (internal only)

The production stack is the compose.yml at the repository root (build context ., so rust-core/, server/, and server-ui/ are all in one context).

Terminal window
cp deploy/.env.example .env # at the repo root, next to compose.yml
$EDITOR .env # set domain, email, and secrets
docker compose up -d --build
  • Only Caddy publishes host ports: 80 and 443 (443/udp for HTTP/3).
  • The server is never host-published; Caddy reaches it as http://server:8443.
  • Migrations auto-apply on boot (SQLite). The SPA is served same-origin, so the admin panel and its API share one origin and CORS stays off.

Then open https://<UNISSH_DOMAIN>/.

compose.yml builds from source — it needs the Rust and Node toolchains and a cold compile. Operators who just want to run the server can use compose.prod.yml at the repository root, which pulls prebuilt multi-arch images from GHCR (ghcr.io/goduni/unissh-server and ghcr.io/goduni/unissh-caddy, published by the publish-images.yml workflow):

Terminal window
docker compose -f compose.prod.yml up -d # pull + run, no build toolchain

Everything else — the .env, TLS, profiles, and volumes — is identical to the from-source stack.

A fresh instance is unclaimed and holds no data. On first boot it prints a one-time setup code to the server log; the first person to present it becomes the owner.

Terminal window
docker compose logs server 2>&1 | grep -i "setup code"

Enter that code on the admin panel’s Claim screen (or in the desktop client) to take ownership — it is valid only while the instance is unclaimed. See Server configuration → [setup] and Admin panel.

Lost the log line to a restart? Only sha256(code) is persisted, so it cannot be shown again — issue a fresh one rather than dropping the volumes:

Terminal window
docker compose exec server /usr/local/bin/unissh-server setup-code --rotate --config /app/config.toml

It invalidates the previous code, applies immediately without a restart, and leaves accounts, vaults and objects intact. Without --rotate the command only reports the code’s status.

If the container is not running — a port conflict or a failed certificate is usually why you are here — exec has nothing to attach to; docker compose run --rm server setup-code --rotate --config /app/config.toml does the same on a stopped stack.

Caddy is the only TLS terminator and the only host-exposed service. The UniSSH server always runs plain HTTP behind it (UNISSH__SERVER__TLS_CERT/TLS_KEY empty → plain, UNISSH__SERVER__TRUST_PROXY=true). The server never does ACME — acme=true is a hard startup error — so all certificate management lives in Caddy, and switching TLS modes is a Caddy/env change with no server rebuild.

TLS is controlled by one env knob, UNISSH_TLS_DIRECTIVE:

  • Public domain (automatic ACME): set UNISSH_DOMAIN to your real domain and UNISSH_TLS_DIRECTIVE="tls you@example.com" (the email enables expiry notices; leave it empty for ACME without an account email). Caddy gets a public cert (Let’s Encrypt / ZeroSSL via HTTP-01 or TLS-ALPN-01). Port 80 must be reachable for the challenge and the HTTP→HTTPS redirect.
  • Certificates you already have: set UNISSH_TLS_DIRECTIVE="tls /certs/fullchain.pem /certs/privkey.pem" and mount them with the deploy/compose.tls-files.yml override. Renewal needs an explicit caddy reload --force — see Deployment scenarios.
  • LAN / air-gapped (self-signed internal CA): set UNISSH_DOMAIN to a local host (e.g. unissh.local) or an IP and UNISSH_TLS_DIRECTIVE="tls internal". Caddy issues a cert from its own internal CA — every client machine must then trust that root (export it from the caddy-data volume at /data/caddy/pki/authorities/local/root.crt). The client verifies against the OS trust store and has no “accept anyway” prompt, and it refuses plain http:// to a non-loopback host.

For certificates you already hold, an existing nginx in front, a LAN with no public DNS, or no proxy at all, see Deployment scenarios — with a tested nginx server block and the client-side trust steps.

The admin panel uses crypto-wasm (wasm-bindgen), which requires script-src 'self' 'wasm-unsafe-eval'. Because the SPA is served same-origin and its API client uses a relative base, all fetches hit /v1 and /readyz on the page origin, so connect-src 'self' suffices and CORS stays disabled. The full CSP is set in deploy/Caddyfile. See Admin panel.

The server image is gcr.io/distroless/cc-debian12:nonrootno shell, no curl/wget, and the binary has no health subcommand (only serve / migrate / seq-bump / setup-code / reclaim). So the server service has no Docker HEALTHCHECK by design. Health is observed at the proxy instead:

  • Caddy reverse-proxies /healthz and /readyz, so external probes hit https://<domain>/readyz.
  • Caddy’s reverse_proxy ... health_uri /readyz actively health-checks the upstream and stops routing to it when unhealthy.
  • The Postgres profile has a real container healthcheck (pg_isready) that gates the migrate init container.

A single named volume unissh-data mounted at /app/data (owned by uid 65532, the distroless nonroot user). The rootfs is read-only with a tmpfs /tmp. Migrations auto-apply on boot. The default SQLite path needs no database secretsPOSTGRES_PASSWORD is not required, and docker compose config resolves with only UNISSH_DOMAIN set.

Adds a postgres:16-alpine service (with a pg_isready healthcheck) and a one-shot unissh-server-migrate init container that runs migrations after Postgres is healthy and before the server connects.

Terminal window
docker compose --profile postgres up -d --build

Adds Prometheus scraping server:9090 (deploy/prometheus.yml) over the internal network. The metrics listener (UNISSH__OBS__METRICS_BIND=0.0.0.0:9090) is never host-published. Prometheus itself is internal by default; uncomment its ports in compose.yml for local UI access.

Terminal window
docker compose --profile monitoring up -d

All secrets come from the gitignored .env (template: deploy/.env.example); nothing secret is baked into images. Config uses figment env keys UNISSH__SECTION__KEY. Generate strong tokens with openssl rand -hex 32. See Server configuration.

  • Rollback / sequence floor: docker compose run --rm server seq-bump ... — see Backups & anti-rollback restore.
  • Backup (SQLite): stop the stack or snapshot the unissh-data volume (/app/data/unissh.db).
  • Backup (Postgres): pg_dump the postgres service or snapshot the unissh-pg volume.

Backups contain only ciphertext — zero-knowledge is preserved.

server/docker-compose.yml is a minimal single-service dev variant: it builds only the server and publishes 8443 as plain HTTP bound to 127.0.0.1 only — no TLS, no Caddy, no SPA — running with trust_proxy=false. Use it only for local development, never in production:

8443/readyz
docker compose -f server/docker-compose.yml up --build

The production path is always the root compose.yml.