Skip to main content
Version: 0.6.1

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=none is 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​

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_KEY are 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 sets CAIRN_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:read for ActivityService, gear:* for GearService and so on, and QueryMetricSeries needs training:read instead of training: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_ENDPOINT hands clients presigned object-store URLs instead of serving binary content through core, and CAIRN_AUTH_OAUTH_DEVICE_CODE_TTL / ..._DEVICE_POLL_INTERVAL tune 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_pools registry (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_core KV 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=false to keep the old call count.

Upgrading to 0.3.3​

  • The project moved to the opencairn-org GitHub organization. Pull the images from ghcr.io/opencairn-org/ (cairn-core, cairn-provider-strava, cairn-provider-garmin); the ghcr.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 only strava and garmin; 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=true and CAIRN_NATS_WORKER_ACCOUNT set; 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_TOKEN alone. 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_SECRET or CAIRN_AUTH_MASTER_ENCRYPTION_KEY now fails startup. Run cairn config help to 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).