Self-host overview
Cairn is built to be self-hosted. A production deployment is:
- cairn-core — one distroless container. It serves the Connect-RPC + REST API and the embedded SvelteKit web UI, runs migrations, and is the server side of the NATS control plane.
- Postgres 17 with TimescaleDB (streams) and PostGIS (segments).
- NATS + JetStream — the worker control plane and async job/result bus.
- An S3-compatible object store (MinIO, Garage, AWS S3, …) for raw blobs,
photos and claim-checked worker results. Required: without it provider sync
dead-letters.
CAIRN_STORAGE_DRIVER=noneis the explicit opt-out for API-only instances, see Choosing a store. - Provider workers — one container per provider you use
(
cairn-provider-strava,cairn-provider-garmin), each a separate process.
+-------------------+
web UI ───▶ | cairn-core | ──▶ Postgres (Timescale + PostGIS)
API ───▶ | (API + web UI) | ──▶ S3 (blobs, photos)
+---------+---------+
│ NATS + JetStream (jobs / results / tokens)
┌───────────┴───────────┐
▼ ▼
cairn-provider-strava cairn-provider-garmin
Choose a path
- Docker Compose — simplest single-host deployment, with a configurator that writes the compose file for your providers.
- Kubernetes — manifests for a cluster.
- Configuration — environment variables.
- Providers — enrollment, worker management, and the Strava and Garmin workers.
- Administration — users, audit log, maintenance operations.
Docker Compose
The Docker Compose page has a configurator: pick the
providers, object storage, reverse proxy and worker authentication you want
and copy the resulting docker-compose.yml and .env. Pin the image tags to
a release rather than latest, and upgrade core and the workers together.
Upgrading to 0.6.1
- Object storage is required. Core now refuses to start when
CAIRN_STORAGE_ACCESS_KEY_ID/CAIRN_STORAGE_SECRET_ACCESS_KEYare unset, instead of coming up without a blob store, a state in which every worker result dead-lettered since 0.5.0. Deployments from the Compose configurator or the Kubernetes bundle already set them. An instance that really has no store setsCAIRN_STORAGE_DRIVER=none; the opt-out is logged at Error and shown as Degraded in the health probe. See Storage.
Upgrading to 0.6.0
-
No worker upgrade is required. The worker contract is unchanged since 0.5.0, so 0.5.0 workers keep working against core 0.6.0. The 0.6.0 worker images were released alongside core and differ only in build tooling; upgrade them when convenient, core first as always.
-
Migrations 00067–00073 run on start: OAuth client software metadata, photo variants, reimport previews, the passkey second factor, login challenge purposes, device codes and persisted maintenance jobs.
-
Forced password change. A password that was set by an administrator (the bootstrap admin, accounts created with an initial password, admin resets) must be replaced at the next password sign-in. Expect the prompt on your own admin account after the upgrade. Passkey and OIDC sign-ins are not affected.
-
Sign-in lockout. Ten failed password attempts per account or twenty per address within ten minutes lock that key for ten minutes. Every failure is in the audit log and a lock raises an admin event, see Administration.
-
OAuth scopes are enforced per family on Connect. A token now needs
activities:readforActivityService,gear:*forGearServiceand so on, andQueryMetricSeriesneedstraining:readinstead oftraining:write. Tokens issued before 0.6.0 keep the families they hold; clients re-authorize to pick up the new read scopes. REST keeps the coarse read/write rule. See OAuth. -
Photos get display variants (320 px thumbnail, 1280 px medium, a blurhash). New uploads and mirrored photos get them on ingest; convert the existing ones once:
docker compose run --rm core attachments backfill-variants # --batch 100 -
New optional settings:
CAIRN_STORAGE_PUBLIC_ENDPOINThands clients presigned object-store URLs instead of serving binary content through core, andCAIRN_AUTH_OAUTH_DEVICE_CODE_TTL/..._DEVICE_POLL_INTERVALtune the new device authorization grant. See Configuration. -
Core now also speaks cleartext HTTP/2 (h2c) for native Connect and gRPC clients, and streaming RPCs are exempt from
CAIRN_HTTP_WRITE_TIMEOUT. -
Administrators get instance-wide maintenance operations (segment rematch, best-effort, merge and training-load recomputes, purge of soft-deleted activities, blob garbage collection), see Administration.
Upgrading to 0.5.0
- Core first, then both workers to 0.5.0. Jobs, results and the account
lookup moved to per-pool subjects (
cairn.jobs.<job>.<provider>.<worker_name>, see Provider contract); a 0.4 worker keeps running against core 0.5 but receives no new jobs. Jobs queued before the upgrade are drained by the 0.5 workers. - Migrations 00064–00066 add the parser/release stamps on sources, one
connection per worker pool per provider account, and the
worker_poolsregistry (filled from existing sources). Existing connections are assigned to their pool automatically when the provider has one. - Every source imported before 0.5.0 is flagged "newer parser available"; use Re-parse N out of date on Connections to bring them up to date without provider quota. Garmin activities need a re-fetch to pick up gear: their archives have none.
- Rate budgets are now per provider app and shown per connection.
- Core publishes its release to the
cairn_coreKV bucket and workers refuse an incompatible one. Before 1.0 the minor must match; from 1.0 on, minor and patch releases of core stay compatible with older workers.
Upgrading to 0.4.0
- Core first, then the workers. Core 0.4.0 ignores unknown fields in job results; a 0.4.0 worker talking to a 0.3.x core would fail every import. Core 0.4.0 with 0.3.x workers is fine (no gear, nothing else changes).
- Migration 00063 adds gear tables and
activities.gear_set_by; gear that was attached before (through the API) is kept and marked as set by the user. - Gear from connected accounts arrives on the next reconcile or "Sync gear" as pending records; users decide per record whether it is new gear or an alias (see Gear). Strava activities imported before 0.4.0 get their gear after a reparse, Garmin activities after a reimport.
- The Garmin worker makes one extra call per fetched activity for its gear;
set
CAIRN_GARMIN_FETCH_ACTIVITY_GEAR=falseto keep the old call count.
Upgrading to 0.3.3
- The project moved to the
opencairn-orgGitHub organization. Pull the images fromghcr.io/opencairn-org/(cairn-core,cairn-provider-strava,cairn-provider-garmin); theghcr.io/johnnycube/namespace keeps the tags published so far as a frozen mirror and receives no new versions. Update image pins and Renovate rules; nothing else to migrate.
Upgrading to 0.3.2
- Worker enrollments accept any well-formed provider type (1–48 of
[a-z0-9_-]), not onlystravaandgarmin; the admin UI suggests the built-in ones. Nothing to migrate. Account linking for a new provider still needs core support (see Other providers).
Upgrading to 0.3.1
- Creating a worker enrollment requires a
provider. Enrollments made before 0.3.1 have none; core refuses them at connect and the admin list marks them re-enroll required. Create new ones (see Providers). - Core answers NATS auth-callout requests only with
CAIRN_NATS_AUTH_CALLOUT=trueandCAIRN_NATS_WORKER_ACCOUNTset; follow the auth-callout setup. Without NATS auth nothing changes. - With the auth-callout, run Strava v0.3.1 and Garmin v0.3.1: both enroll
with
CAIRN_WORKER_ENROLLMENT_TOKENalone. The placeholder nkey user in nats.conf is only needed for Strava v0.3.0 and earlier, and the Garmin worker no longer needs a static NATS user.
Upgrading to 0.3.0
- Configuration is read only from
CAIRN_-prefixed variables. Unprefixed host variables (URL,USERNAME,PASSWORD,SESSION_SECRET, …) no longer fill Cairn settings; rename any you relied on. - An empty
CAIRN_DATABASE_URL,CAIRN_AUTH_SESSION_SECRETorCAIRN_AUTH_MASTER_ENCRYPTION_KEYnow fails startup. Runcairn config helpto see every variable with its default. - Under NATS auth, upgrade core first: the worker permission template that auth-callout grants now matches the job subjects and JetStream API calls the workers make (see Providers). Workers under NATS without auth are unaffected.
- The Garmin worker image no longer runs as root; see the volume note above.
- Connect-RPC requests are capped at 4 MiB per message and upload bodies are capped (see Request limits).