Skip to main content
Version: 0.7.0

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.

Providers
Reverse proxy and TLS
Object storage
Worker authentication
Email
ImagesRelease tags on ghcr.io/opencairn-org. Never latest.
Secrets are generated in your browser and never leave this page.
docker-compose.yml
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}]
.env
# Image tags. Upgrade core first, then the workers.
CORE_TAG=v0.7.0
STRAVA_TAG=v0.7.0
GARMIN_TAG=v0.7.0

# 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

  1. Put both files in one directory, for example ~/cairn, and fill in .env (at least CAIRN_DOMAIN and the admin email). Everything marked CHANGE_ME must be replaced.
  2. Point the DNS record of your domain at this host and open ports 80 and 443. Caddy obtains and renews the certificate itself.
  3. Start everything and watch core apply its migrations:
    docker compose up -d
    docker compose logs -f core
  4. Sign in at https://cairn.example.com as 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.
  5. Garmin accounts with MFA need their token store seeded once with an interactive login into the garmin_tokens volume, see Garmin.

What runs​

ServiceImageRole
coreghcr.io/opencairn-org/cairn-coreAPI, embedded web UI, migrations, NATS control plane. Distroless, runs as uid 65532
worker-stravaghcr.io/opencairn-org/cairn-provider-stravaStrava imports. Stateless, talks to NATS only
worker-garminghcr.io/opencairn-org/cairn-provider-garminGarmin imports. Keeps its Garth token cache in garmin_tokens, runs as uid 10001
postgrestimescale/timescaledb-haPostgres 17 with TimescaleDB and PostGIS
natsnatsJetStream job and result bus; config is inlined as a compose configs entry
miniominio/minioS3-compatible store for raw files, photos and map snapshots; core creates the bucket
caddycaddyReverse 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:

VolumeHoldsBackup
postgres_dataevery activity, user, token and settingdocker compose exec postgres pg_dump -U cairn cairn > cairn.sql
minio_dataraw uploads, photos, cached map snapshotscopy the volume, or mirror the bucket with mc
nats_dataqueued jobs and the worker registrydisposable; queued jobs are re-created by the scheduler
garmin_tokensGarth sessions per Garmin accountdisposable unless accounts use MFA
caddy_datacertificatesdisposable, 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 environment block of core.
  • Providers explains worker enrollment, the NATS permissions an enrolled worker gets and the Garmin MFA seeding.
  • A worker can run on another machine: publish 4222 on the NATS service (TLS and auth-callout strongly recommended) and point the worker's CAIRN_NATS_URL at it.