Docker Compose
The simplest production deployment of Cairn is one host running Docker
Compose: core, the provider workers you use, Postgres, NATS and, optionally,
MinIO and a TLS-terminating reverse proxy. Everything core needs is in the
CAIRN_* environment, so the whole stack is a docker-compose.yml and a
.env.
Pick your options and copy the two files below. They change as you click, and the First start steps underneath match what you picked.
name: cairn
services:
core:
image: ghcr.io/opencairn-org/cairn-core:${CORE_TAG}
restart: unless-stopped
environment:
CAIRN_DATABASE_URL: postgres://cairn:${POSTGRES_PASSWORD}@postgres:5432/cairn?sslmode=disable
# single core instance: migrate on boot (see Upgrading below)
CAIRN_DATABASE_AUTO_MIGRATE: "true"
CAIRN_HTTP_PUBLIC_BASE_URL: https://${CAIRN_DOMAIN}
CAIRN_INSTANCE_PUBLIC_URL: https://${CAIRN_DOMAIN}
CAIRN_INSTANCE_TRUSTED_PROXIES: 172.30.0.0/24
CAIRN_AUTH_SESSION_SECRET: ${CAIRN_AUTH_SESSION_SECRET}
CAIRN_AUTH_MASTER_ENCRYPTION_KEY: ${CAIRN_AUTH_MASTER_ENCRYPTION_KEY}
CAIRN_INSTANCE_BOOTSTRAP_ADMIN_EMAIL: ${CAIRN_INSTANCE_BOOTSTRAP_ADMIN_EMAIL}
CAIRN_INSTANCE_BOOTSTRAP_ADMIN_PASSWORD: ${CAIRN_INSTANCE_BOOTSTRAP_ADMIN_PASSWORD}
CAIRN_NATS_URL: nats://nats:4222
CAIRN_STORAGE_ENDPOINT: http://minio:9000
CAIRN_STORAGE_BUCKET: cairn
CAIRN_STORAGE_ACCESS_KEY_ID: cairn
CAIRN_STORAGE_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
# the public OSM tile and Nominatim servers want a contact
CAIRN_MAP_USER_AGENT: cairn (${CAIRN_INSTANCE_BOOTSTRAP_ADMIN_EMAIL})
CAIRN_GEOCODER_EMAIL: ${CAIRN_INSTANCE_BOOTSTRAP_ADMIN_EMAIL}
depends_on:
postgres: {condition: service_healthy}
nats: {condition: service_healthy}
minio: {condition: service_healthy}
worker-strava:
image: ghcr.io/opencairn-org/cairn-provider-strava:${STRAVA_TAG}
restart: unless-stopped
environment:
CAIRN_NATS_URL: nats://nats:4222
CAIRN_WORKER_NAME: strava-fetcher
depends_on: [core]
worker-garmin:
image: ghcr.io/opencairn-org/cairn-provider-garmin:${GARMIN_TAG}
restart: unless-stopped
environment:
CAIRN_NATS_URL: nats://nats:4222
CAIRN_WORKER_NAME: garmin-fetcher
volumes:
# Garth token cache; the image runs as uid 10001 and owns /data
- garmin_tokens:/data
depends_on: [core]
postgres:
image: timescale/timescaledb-ha:pg17-ts2.27
restart: unless-stopped
environment:
POSTGRES_USER: cairn
POSTGRES_DB: cairn
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_INITDB_ARGS: --encoding=UTF-8 --lc-collate=C --lc-ctype=C
volumes:
- postgres_data:/home/postgres/pgdata/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cairn -d cairn"]
interval: 5s
timeout: 5s
retries: 10
start_period: 20s
nats:
image: nats:2.15.1-alpine
restart: unless-stopped
command: ["-c", "/etc/nats/nats.conf", "--cluster_name", "cairn"]
configs:
- {source: nats-open, target: /etc/nats/nats.conf}
volumes:
- nats_data:/data
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8222/healthz || exit 1"]
interval: 5s
timeout: 5s
retries: 10
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: cairn
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
# ports: ["127.0.0.1:9001:9001"] # admin console, if you want it
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 10
caddy:
image: caddy:2.11.7-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
configs:
- {source: caddyfile, target: /etc/caddy/Caddyfile}
volumes:
- caddy_data:/data
- caddy_config:/config
depends_on: [core]
configs:
nats-open:
content: |
http_port: 8222
jetstream { store_dir: "/data" }
# long activity streams exceed the 1 MiB default
max_payload: 8MB
caddyfile:
content: |
${CAIRN_DOMAIN} {
reverse_proxy core:8080
}
volumes:
postgres_data:
nats_data:
minio_data:
garmin_tokens:
caddy_data:
caddy_config:
networks:
default:
# named subnet so core can trust the proxy (CAIRN_INSTANCE_TRUSTED_PROXIES)
ipam:
config: [{subnet: 172.30.0.0/24}]
# Image tags. Upgrade core first, then the workers.
CORE_TAG=v0.7.2
STRAVA_TAG=v0.7.1
GARMIN_TAG=v0.7.1
# Public hostname; OAuth redirects, webhooks and share links use it
CAIRN_DOMAIN=cairn.example.com
# Crypto secrets. The master key encrypts every stored credential:
# back it up separately from the database, it cannot be rotated.
CAIRN_AUTH_SESSION_SECRET=CHANGE_ME_SESSION_SECRET
CAIRN_AUTH_MASTER_ENCRYPTION_KEY=CHANGE_ME_MASTER_KEY
# First admin, created on first boot
CAIRN_INSTANCE_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
CAIRN_INSTANCE_BOOTSTRAP_ADMIN_PASSWORD=CHANGE_ME_ADMIN_PASSWORD
# Postgres
POSTGRES_PASSWORD=CHANGE_ME_DB_PASSWORD
# MinIO root credentials (the bucket is created by core)
MINIO_ROOT_PASSWORD=CHANGE_ME_MINIO_SECRET
First start
- Put both files in one directory, for example
~/cairn, and fill in.env(at leastCAIRN_DOMAINand the admin email). Everything markedCHANGE_MEmust be replaced. - Point the DNS record of your domain at this host and open ports 80 and 443. Caddy obtains and renews the certificate itself.
- Start everything and watch core apply its migrations:docker compose up -ddocker compose logs -f core
- Sign in at
https://cairn.example.comas the bootstrap admin. Strava is connected per user from Connections, each with their own Strava API app. Garmin credentials are entered per user as well; the worker receives them over NATS. - Garmin accounts with MFA need their token store seeded once with an interactive login into the
garmin_tokensvolume, see Garmin.
What runs
| Service | Image | Role |
|---|---|---|
core | ghcr.io/opencairn-org/cairn-core | API, embedded web UI, migrations, NATS control plane. Distroless, runs as uid 65532 |
worker-strava | ghcr.io/opencairn-org/cairn-provider-strava | Strava imports. Stateless, talks to NATS only |
worker-garmin | ghcr.io/opencairn-org/cairn-provider-garmin | Garmin imports. Keeps its Garth token cache in garmin_tokens, runs as uid 10001 |
postgres | timescale/timescaledb-ha | Postgres 17 with TimescaleDB and PostGIS |
nats | nats | JetStream job and result bus; config is inlined as a compose configs entry |
minio | minio/minio | S3-compatible store for raw files, photos and map snapshots; core creates the bucket |
caddy | caddy | Reverse proxy with automatic TLS for CAIRN_DOMAIN |
The workers never talk to Postgres or S3; they reach core through NATS, which is not published on the host. Core is the only service with a published port, and only when no proxy is bundled.
Inline configs need Docker Compose v2.23.1 or newer. With an older Compose,
write each content: block to a file and bind-mount it instead.
Data and backups
All state lives in named volumes:
| Volume | Holds | Backup |
|---|---|---|
postgres_data | every activity, user, token and setting | docker compose exec postgres pg_dump -U cairn cairn > cairn.sql |
minio_data | raw uploads, photos, cached map snapshots | copy the volume, or mirror the bucket with mc |
nats_data | queued jobs and the worker registry | disposable; queued jobs are re-created by the scheduler |
garmin_tokens | Garth sessions per Garmin account | disposable unless accounts use MFA |
caddy_data | certificates | disposable, Caddy re-issues them |
CAIRN_AUTH_MASTER_ENCRYPTION_KEY in .env encrypts every stored credential.
A database backup without that key is useless, so back the key up separately
and never rotate it in place.
Upgrading
Core is upgraded before the workers (see the release notes on the overview for what each release needs):
# bump CORE_TAG in .env, then
docker compose pull core
docker compose up -d core
docker compose logs -f core # migrations run on boot
# bump STRAVA_TAG / GARMIN_TAG, then
docker compose pull
docker compose up -d
Core migrates the database itself because CAIRN_DATABASE_AUTO_MIGRATE is
true in the generated file. That is right for one core instance. If you
scale core to several replicas, set it to false and run the migration as a
one-shot step before the rollout, as the
Kubernetes bundle does:
docker compose run --rm core migrate up
Going further
- Every other variable, from OIDC sign-in to email adapters, quotas and map
tiles, is on the Configuration page. Add it to
the
environmentblock ofcore. - Providers explains worker enrollment, the NATS permissions an enrolled worker gets and the Garmin MFA seeding.
- A worker can run on another machine: publish
4222on the NATS service (TLS and auth-callout strongly recommended) and point the worker'sCAIRN_NATS_URLat it.