Docker Compose (standalone box)
The standalone recipe runs Northplane and a TLS-terminating Caddy on one host with Docker Compose. It is the stack the Deploy workflow used to ship to the standalone box np-02 (the deploy-hetzner job was removed with the box, see CI/CD), and it is fully usable by hand: copy .env.example to .env, edit, docker compose up -d.
Two compose files exist in the repository — pick the one that matches your situation:
| File | Purpose | TLS | Secret key | Config |
|---|---|---|---|---|
docker-compose.yml (repository root) + caddy/Caddyfile |
local trial or a simple production box | Caddy: internal cert for localhost, Let’s Encrypt when DOMAIN is set |
auto-generated inside the data volume | DOMAIN from the shell |
deploy/docker-compose.yml + deploy/Caddyfile + .env |
the pipeline-managed standalone box | Caddy: Let’s Encrypt for DOMAIN and an internal cert on the bare SERVER_IP |
persistent ./secret.key bind-mounted read-only |
.env (rendered by CI or copied from .env.example) |
This page documents the deploy/ variant in full and the root file briefly at the end. The edge-proxied variant without Caddy is on the Proxmox VM page.
deploy/ docker-compose.yml # northplane + caddy, env_file .env, secret.key mount Caddyfile # {$DOMAIN} → Let's Encrypt, https://{$SERVER_IP} → tls internal .env.example # every knob, documented — copy to .env for manual use docker-compose.vm.yml # edge-proxied variant (no caddy) — see the Proxmox VM page provision-server.sh # one-time host provisioning — see ProvisioningOn a provisioned host the compose project lives in /opt/northplane (owned by the deploy user, mode 0750) as docker-compose.yml, Caddyfile, .env, .env.previous and secret.key.
The compose file
Section titled “The compose file”# Northplane — production stack (Docker Compose + Caddy TLS).## This file and .env are MANAGED BY THE DEPLOY PIPELINE# (.github/workflows/deploy.yml). Do not hand-edit on the server: every# deploy overwrites this file and re-renders .env from GitHub repo# Variables/Secrets. Change configuration there (see deploy/README.md).## Caddy terminates TLS on :80/:443 — Let's Encrypt for $DOMAIN once its DNS# A-record points here, plus an internal self-signed cert on the bare IP so# the box is reachable before DNS is live — and reverse-proxies to# northplaned on :8443 inside the compose network.
name: northplane
services: northplane: image: ${NORTHPLANE_IMAGE:-ghcr.io/myfoxit/northplane:latest} restart: unless-stopped # .env carries the runtime config (base URL, the demo/real switch, the # per-mode data dir, the break-glass admin). Rendered by the pipeline. env_file: [.env] environment: NORTHPLANE_LISTEN: ":8443" NORTHPLANE_TRUST_PROXY: "true" # Caddy terminates TLS + sets X-Forwarded-* NORTHPLANE_SECRET_KEY_FILE: "/etc/northplane/secret.key" volumes: - northplane-data:/var/lib/northplane # Persistent AES-256 master key (secrets-at-rest). Provisioned once by # deploy/provision-server.sh; survives image swaps and mode flips. - ./secret.key:/etc/northplane/secret.key:ro expose: - "8443" # compose network only; Caddy publishes 80/443 # distroless image has no shell — Caddy health-probes it instead.
caddy: image: caddy:2-alpine restart: unless-stopped depends_on: - northplane ports: - "80:80" - "443:443" environment: DOMAIN: "${DOMAIN:-localhost}" SERVER_IP: "${SERVER_IP:-127.0.0.1}" ACME_EMAIL: "${ACME_EMAIL:-}" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data # ACME account + issued certificates (persist!) - caddy-config:/config healthcheck: test: ["CMD", "wget", "-qO-", "http://northplane:8443/healthz"] interval: 30s timeout: 5s retries: 3
volumes: northplane-data: # app data: SQLite + TSDB, isolated per mode by NORTHPLANE_DATA_DIR caddy-data: caddy-config:The project name is fixed to northplane, so the container is northplane-northplane-1 and the data volume is northplane_northplane-data regardless of the directory name — the Deploy workflow relies on both names.
Services, volumes and mounts
Section titled “Services, volumes and mounts”| Element | What it is | Notes |
|---|---|---|
service northplane |
the app container, ${NORTHPLANE_IMAGE} (default ghcr.io/myfoxit/northplane:latest), restart: unless-stopped |
listens on :8443 inside the compose network only (expose, no ports); NORTHPLANE_TRUST_PROXY=true because Caddy terminates TLS and sets X-Forwarded-Proto |
service caddy |
caddy:2-alpine, publishes 80 and 443, depends_on: northplane |
its healthcheck probes the app (wget -qO- http://northplane:8443/healthz) every 30 s, 5 s timeout, 3 retries — the app image has no shell, so this is where the app’s health becomes visible in docker compose ps |
volume northplane-data → /var/lib/northplane |
SQLite core.db, event segments, tsdb/, artifacts |
both mode directories (demo/, real/) live inside it |
bind mount ./secret.key → /etc/northplane/secret.key:ro |
the AES-256 master key | must be readable by uid 65532 (the provisioner sets owner 65532:65532, mode 0600). A missing source file makes Docker create a directory at that path — the server then logs configured secretKeyFile unusable — falling back to the data directory, and secrets encrypted with the fallback key are lost if you later fix the mount |
bind mount ./Caddyfile → /etc/caddy/Caddyfile:ro |
the Caddy site config | |
volume caddy-data → /data |
ACME account and issued certificates | keep it — losing it means re-issuing certificates (Let’s Encrypt rate limits apply) |
volume caddy-config → /config |
Caddy’s autosaved config |
.env reference
Section titled “.env reference”.env is read by the northplane service (env_file) and by compose variable substitution (${NORTHPLANE_IMAGE}, ${DOMAIN}, ${SERVER_IP}, ${ACME_EMAIL}). Every knob from deploy/.env.example:
| Key | Example value | Meaning |
|---|---|---|
NORTHPLANE_IMAGE |
ghcr.io/myfoxit/northplane:latest |
image to run; the pipeline pins the immutable main-<sha12> tag, latest is fine for manual use |
DOMAIN |
doktrace.com |
hostname Caddy obtains a Let’s Encrypt certificate for (needs a DNS A record pointing here); localhost for a pure local trial |
SERVER_IP |
the box’s public IPv4 | bare-IP fallback endpoint with an internal certificate, reachable before DNS is live |
ACME_EMAIL |
[email protected] |
ACME account e-mail (Let’s Encrypt notifications) |
NORTHPLANE_BASE_URL |
https://doktrace.com |
external base URL used for links, ack URLs and OIDC redirects |
NORTHPLANE_DEMO |
true / false |
the demo/real switch — see Demo mode |
NORTHPLANE_DATA_DIR |
/var/lib/northplane/demo or /var/lib/northplane/real |
per-mode data directory inside the volume; keep it in step with NORTHPLANE_DEMO |
NP_DEFAULT_ADMIN_EMAIL |
[email protected] |
break-glass admin e-mail (seeded when absent) |
NP_DEFAULT_ADMIN_PASSWORD |
<secret> |
its password; leave empty to disable seeding and use the interactive /setup page instead |
Set by the compose file itself (not in .env): NORTHPLANE_LISTEN=:8443, NORTHPLANE_TRUST_PROXY=true, NORTHPLANE_SECRET_KEY_FILE=/etc/northplane/secret.key. Any other NORTHPLANE_* variable from the configuration reference (for example NORTHPLANE_STORAGE_DSN, NORTHPLANE_LOG_LEVEL, NORTHPLANE_ALLOW_SIGNUP) can be added to .env — it is passed through unchanged.
For comparison, the removed deploy-hetzner job rendered this .env for the standalone box: NORTHPLANE_IMAGE=ghcr.io/myfoxit/northplane:main-<sha12>, DOMAIN=localhost, SERVER_IP=<HETZNER_HOST>, [email protected], NORTHPLANE_BASE_URL=https://<HETZNER_HOST>, NORTHPLANE_DEMO=false, NORTHPLANE_DATA_DIR=/var/lib/northplane/real, NP_DEFAULT_ADMIN_EMAIL=root@localhost, NP_DEFAULT_ADMIN_PASSWORD=<secret HETZNER_ADMIN_PASSWORD> — that is, a real-data instance on a bare IP; a domain is added later by changing DOMAIN in the workflow.
Caddy and certificates
Section titled “Caddy and certificates”# Northplane reverse proxy / TLS termination (managed by the deploy pipeline).## Two independent site blocks share :443 by SNI:# • {$DOMAIN} → automatic Let's Encrypt, the moment a DNS A-record for# the domain resolves to this host. Until then Caddy just# retries issuance in the background — harmless.# • https://{$SERVER_IP} → internal (self-signed) cert, so the stack is# reachable at https://<ip> immediately, before DNS, and# as a stable internal endpoint. Browsers show a one-time# warning; that's expected for an IP cert.## default_sni is essential: clients hitting the bare IP send NO TLS SNI (an# IP literal is not allowed as a server name), so without this Caddy can't# pick a certificate and aborts the handshake. Pinning the default SNI to the# IP makes SNI-less connections serve the internal IP cert. Real visitors to# {$DOMAIN} always send SNI, so they're unaffected.
{ # ACME account email for Let's Encrypt expiry/issue notifications. email {$ACME_EMAIL} default_sni {$SERVER_IP}}
{$DOMAIN} { reverse_proxy northplane:8443}
https://{$SERVER_IP} { tls internal reverse_proxy northplane:8443}Two certificate modes run side by side:
| Mode | Site block | Certificate | When it works |
|---|---|---|---|
| Domain | {$DOMAIN} |
automatic Let’s Encrypt (Caddy’s automatic HTTPS; ports 80 and 443 must be reachable from the internet, ACME_EMAIL is the account contact) |
as soon as the A record resolves to this host; until then Caddy retries issuance in the background. With DOMAIN=localhost Caddy uses its internal CA instead |
| Bare IP | https://{$SERVER_IP} with tls internal |
self-signed by Caddy’s internal CA; served to SNI-less clients because of default_sni {$SERVER_IP} |
immediately — the box is reachable at https://<ip> before DNS exists; browsers show a one-time warning |
Caddy forwards X-Forwarded-Proto: https to the app, which is why NORTHPLANE_TRUST_PROXY=true is set: the app then marks cookies Secure and sends HSTS. Northplane does not use X-Forwarded-For for anything — see TLS and reverse proxies.
Health probing through Caddy
Section titled “Health probing through Caddy”The app image is distroless and has no shell, so neither docker compose exec northplane … nor a compose healthcheck on the app container can run a probe. Two patterns work:
# from the host, through Caddy's container (what the Deploy workflow does)cd /opt/northplanedocker compose exec -T caddy wget -qO- http://northplane:8443/healthz # → ok
# from anywhere, through TLScurl -fsS https://<DOMAIN-or-IP>/healthz # -k for the internal IP certcurl -fsS https://<DOMAIN-or-IP>/readyz # {"ready":true,"subsystems":[…]}docker compose ps shows the caddy service as healthy/unhealthy — that status reflects the app’s /healthz, because the health check targets http://northplane:8443/healthz. The endpoints are described under Observability.
Running the stack by hand
Section titled “Running the stack by hand”- Provision the host — Docker CE, the
deployuser,/opt/northplaneandsecret.key— withdeploy/provision-server.sh(see Provisioning), or create the equivalent by hand: a directory for the project and asecret.keycontaining 64 hex characters (openssl rand -hex 32 > secret.key), owned by65532:65532, mode 0600. - Copy the files and create
.env:Terminal window cd /opt/northplanecp /path/to/repo/deploy/docker-compose.yml docker-compose.ymlcp /path/to/repo/deploy/Caddyfile Caddyfilecp /path/to/repo/deploy/.env.example .env && chmod 600 .env# edit .env: DOMAIN, SERVER_IP, ACME_EMAIL, NORTHPLANE_BASE_URL,# NORTHPLANE_DEMO + NORTHPLANE_DATA_DIR, admin e-mail/password - Start and watch:
Terminal window docker compose up -ddocker compose logs -f northplane # "northplane: listening" — and the seeded admin linedocker compose logs -f caddy # certificate issuance - Open
https://<SERVER_IP>(accept the internal certificate) orhttps://<DOMAIN>. Log in as the break-glass admin; if you leftNP_DEFAULT_ADMIN_PASSWORDempty,/setupis open and creates the first admin instead. - Continue with First steps — and read the security checklist before exposing the box.
Day-2 operations
Section titled “Day-2 operations”cd /opt/northplanedocker compose ps # caddy healthy == app /healthz okdocker compose logs -f northplane # JSON logs on stderrdocker compose logs -f caddy
# upgrade to a newer imagedocker compose pull && docker compose up -d --remove-orphans
# roll back to a known-good build: pin the immutable tag in .env …sed -i 's#^NORTHPLANE_IMAGE=.*#NORTHPLANE_IMAGE=ghcr.io/myfoxit/northplane:main-<oldsha12>#' .envdocker compose up -d# … or restore the previous rendering kept by the pipelinemv .env.previous .env && docker compose up -dBack up /opt/northplane/secret.key and the northplane_northplane-data volume (and northplane_caddy-data to keep certificates). The server has no periodic backup loop; see Operations → Backups and Storage.
The root docker-compose.yml (trial stack)
Section titled “The root docker-compose.yml (trial stack)”The repository-root compose file is the same shape without .env and without the secret-key mount:
# DOMAIN=localhost (default) → Caddy issues an internal self-signed cert.# DOMAIN=monitoring.example.net (public DNS → this host) → automatic Let's Encrypt.{$DOMAIN:localhost} { reverse_proxy northplane:8443}docker compose up -d # https://localhost (self-signed)DOMAIN=monitoring.example.net docker compose up -d # Let's EncryptDifferences to the deploy/ stack: the image is fixed to ghcr.io/myfoxit/northplane:latest (uncomment build: . to build from source), NORTHPLANE_BASE_URL is derived as https://${DOMAIN:-localhost}, the secret key is auto-generated inside the data volume (/var/lib/northplane/secret.key), there is no bare-IP site, and an optional commented db: postgres:16 service plus NORTHPLANE_STORAGE_DSN line show how to switch to PostgreSQL. It sets NP_DEFAULT_ADMIN_DISABLED: "1", so the first visit lands on /setup to create the admin account; swap that for NP_DEFAULT_ADMIN_EMAIL/NP_DEFAULT_ADMIN_PASSWORD for unattended installs. Details in Installation.