Kubernetes
cairn-core ships a self-contained kustomize bundle under
deploy/k8s/:
core, both workers and single-instance backing services. Its README.md is
the authoritative deploy guide; this page summarises it.
deploy/k8s/
README.md deploy steps, scaling, hardening
namespace.yaml cairn namespace
config.yaml non-secret config (ConfigMap cairn-config)
secrets.example.yaml copy to secrets.yaml and fill in; do not commit
postgres.yaml TimescaleDB StatefulSet + headless Service + 20Gi PVC
nats.yaml NATS + JetStream StatefulSet (max_payload 8MB) + 5Gi PVC
minio.yaml MinIO StatefulSet + 20Gi PVC (the server creates the bucket)
migrate-job.yaml one-shot `cairn migrate up`
core.yaml cairn-core Deployment + Service (:80 -> :8080)
worker-strava.yaml Strava worker Deployment
worker-garmin.yaml Garmin worker Deployment (emptyDir token cache)
ingress.yaml TLS entry point (nginx + cert-manager example)
kustomization.yaml ties them together + image tags
Deploy
-
Copy
secrets.example.yamltosecrets.yamland fill it in. GenerateCAIRN_AUTH_SESSION_SECRETandCAIRN_AUTH_MASTER_ENCRYPTION_KEYwithopenssl rand -base64 32. The DB password must be identical inCAIRN_DATABASE_URLandPOSTGRES_PASSWORD, the storage credentials identical toMINIO_ROOT_*. -
Set
CAIRN_HTTP_PUBLIC_BASE_URLinconfig.yamland the host iningress.yamlto your domain. They must match: OIDC redirects, webhook callbacks and share/reset links derive from it. -
Pin the images in
kustomization.yaml. The bundle tagged v0.3.1 still pins core v0.2.6, Strava v0.2.5 and Garmin v0.2.2, so setnewTagto the release you deploy; for this release:kustomization.yamlimages:- name: ghcr.io/johnnycube/cairn-corenewTag: v0.3.1- name: ghcr.io/johnnycube/cairn-provider-stravanewTag: v0.3.1- name: ghcr.io/johnnycube/cairn-provider-garminnewTag: v0.3.1or
kustomize edit set image ghcr.io/johnnycube/cairn-core:v0.3.1and likewise for the workers. -
Apply the secrets first (they are not part of the kustomization), then the bundle:
kubectl apply -f secrets.yamlkubectl apply -k .The
cairn-migrateJob runsmigrate upbefore core serves traffic; core refuses to start against an older schema. Re-running it is a no-op; after a version bump, delete and re-apply the Job. -
Verify with
kubectl -n cairn get pods,kubectl -n cairn logs job/cairn-migrateandkubectl -n cairn rollout status deploy/cairn-core, then log in at the Ingress host as the bootstrap admin (CAIRN_INSTANCE_BOOTSTRAP_ADMIN_EMAIL/..._PASSWORD).
Configuration is the CAIRN_* environment (see
Configuration), split between the ConfigMap and
the Secret.
State and scaling
- Workers reach core only through NATS (
nats:4222): job/result bus and blob presign request/reply. They need no DB or S3 credentials. - Core and the Strava worker are stateless; raise
replicasfreely, JetStream pull consumers share the work. Exception: the Strava webhook owner is single-instance. Keep the Deployment that setsCAIRN_STRAVA_WEBHOOK_VERIFY_TOKENatreplicas: 1; to scale Strava, run the pull workers without the token and one webhook-owner replica beside them. - The Garmin worker keeps a Garth token store under
CAIRN_GARTH_HOME. The bundle mounts it as anemptyDir, so a restarted pod logs in again from the credentials delivered over NATS. Mount a persistent volume there if MFA accounts should not be re-prompted on every restart (see Providers). Replicas do not share it. The image runs as uid/gid 10001; anemptyDiris writable for it, a persistent volume needssecurityContext.fsGroup: 10001on the pod (or a one-offchown -R 10001:10001if it holds files from an older, root-run image). - Postgres, NATS and MinIO are single-instance in the bundle. For HA, swap in a Postgres operator (e.g. CloudNativePG), a NATS cluster and distributed MinIO or a managed S3 bucket, and point the same env at them.
Hardening
-
The bundle runs NATS unauthenticated on the cluster network. For untrusted networks enable enrollment-token auth-callout (core v0.3.1 or later):
- bootstrap the account key (
POST /api/admin/nats/bootstrap-account-key); - replace
nats.confin thenats-configConfigMap with the auth-callout config and setCAIRN_NATS_AUTH_ISSUER(the returned public key) andCAIRN_NATS_PASSWORDon the NATS container, from a Secret; - add
CAIRN_NATS_USERNAME=cairn-core,CAIRN_NATS_PASSWORD,CAIRN_NATS_AUTH_CALLOUT=trueandCAIRN_NATS_WORKER_ACCOUNT=CAIRNto core; - create an enrollment (name and provider) per worker and set its
CAIRN_WORKER_ENROLLMENT_TOKENon the Strava and the Garmin worker (both v0.3.1 or later).
Enrolled workers get the standard permission template.
- bootstrap the account key (
-
secrets.example.yamluses plainstringData; use SealedSecrets, External Secrets or SOPS for real clusters. Back upCAIRN_AUTH_MASTER_ENCRYPTION_KEYseparately from the database, it encrypts every credential at rest. -
Terminate TLS at the Ingress and add a strict Content-Security-Policy there; it is not baked into the server.
-
Backups: Postgres is the source of truth (including the encrypted NATS account key); MinIO holds re-fetchable raw blobs; JetStream is transient.
The worker images are built and published from their own repositories
(cairn-provider-strava, cairn-provider-garmin); the manifests just pin the
image tags.