Skip to content

Install and deploy

cairn ships in two supported shapes, and both run the same engine image:

Single hostCluster
What you deploydeploy/prod/docker-compose.ymlthe Helm chart in deploy/helm/cairn
HTTPSCaddy, automatic Let’s Encryptyour Ingress + cert-manager
DatabasePostgres, bundledPostgres, bundled or external
Optional servicescompose profilesprofiles.* values
Check before you startcairn deploy doctor --env .envcairn deploy doctor --values my.yaml

If one VM is enough, use one VM. The compose stack is not a lesser option.

What runs by default:

internet ──▶ caddy :80/:443 (automatic TLS) ──▶ server (engine + console + scheduler)
└ run store, checkpoints ▶ postgres

Only Caddy is exposed. The console is served by the server at /console.

  1. A small Linux VM. 2 vCPU and 4–8 GB of memory is enough to start.

  2. DNS. Point an A record (for example cairn.example.com) at the VM.

  3. Get the code onto the box and go to deploy/prod.

  4. Configure. cp .env.example .env, then fill in the required values:

    VariableWhat it isHow to make one
    CADDY_DOMAINthe public domain Caddy gets a certificate foryour DNS name
    POSTGRES_PASSWORDthe app database passwordopenssl rand -hex 16
    OBS_JWT_SECRETsigns sign-in tokens; auth is on in productionopenssl rand -hex 32
    OBS_API_PASSWORD_HASHthe password of the admin user (OBS_API_USER, default admin)cairn remote hash-password
    OBS_PROFILEthe deployment profile: which operator serves each capability, plus audit and redaction settings. The engine refuses to start without one.the path to prod.toml inside the image, as in .env.example
  5. Check the configuration before anything starts:

    Terminal window
    cairn deploy doctor --env .env

    It lists every required value that is missing and checks that the runtime you chose matches the compose profiles you enabled. On an untouched .env.example it reports the five values above as failures.

  6. Deploy: ./deploy.sh installs Docker if it’s missing, builds the image and starts the stack.

  7. Verify once the certificate is issued:

    Terminal window
    curl https://cairn.example.com/healthz

    Then open https://cairn.example.com/console.

deploy/prod/.env is the only copy of the deployment’s configuration. Back it up before every change (cp .env .env.bak.$(date +%s)), and never copy a local .env over the one on the box.

Add services with compose profiles: list them in COMPOSE_PROFILES in .env. The ones most deployments consider:

ProfileAddsAlso set
temporalTemporal and a worker, for durable execution across restartsOBS_RUNTIME=temporal
backupa nightly pg_dump of the app databasenothing
natsNATS JetStream, a durable inbox for triggersOBS_TRIGGER_DURABLE=1, OBS_TRIGGER_INBOX=nats://nats:4222
infisicalInfisical, to keep secrets out of the plain .envthe Infisical variables in .env.example
otel + observabilityan OpenTelemetry collector, Prometheus and Grafananothing
mlflowan MLflow tracking server and model registryMLFLOW_TRACKING_URI

Without a runtime profile the engine runs work in-process (OBS_RUNTIME=inprocess, the default). cairn deploy doctor fails loudly if you set OBS_RUNTIME=temporal without enabling the temporal profile.

The chart is a translation of the compose file, not a second architecture: a test fails the build if the two set different engine variables.

Terminal window
kubectl create namespace cairn
# only while the engine image on GHCR is private
kubectl -n cairn create secret docker-registry ghcr-pull \
--docker-server=ghcr.io --docker-username=<gh-user> --docker-password=<read:packages token>
cp deploy/helm/cairn/values-postgres.example.yaml my-values.yaml # edit every CHANGE-ME
cairn deploy doctor --values my-values.yaml --strict
helm upgrade --install cairn deploy/helm/cairn -n cairn -f my-values.yaml

--strict turns warnings into failures; it fails on an unpinned latest image tag, so pin image.tag. Three example values files ship with the chart: bundled Postgres (values-postgres.example.yaml), an external database (values-external-dsn.example.yaml) and Temporal (values-temporal.example.yaml).

Then kubectl -n cairn rollout status deploy/cairn-server and curl -fsS https://<your host>/healthz. The console is at /console.

Who can create an account, and how people sign in, are two separate settings.

Who can sign up: OBS_SIGNUP.

ModeWho can create an account
invite (default)only someone holding an invite link from an admin
domainanyone whose work-email domain an account has registered; they join that account as a member. Anyone else is refused, and a personal-mail domain (gmail.com and the like) never counts unless an admin registered it.
openanyone; each sign-up creates a new company account with its founder as owner

On a deployment with no accounts yet, the first person to sign up becomes the owner of a new account, whatever the mode. That is how the first admin is created.

How people sign in. Email and password always work. To offer GitHub or Google as well:

  1. Set OBS_PUBLIC_URL to the public origin, for example https://cairn.example.com. Behind Caddy this is required: without it the callback is built from the internal address and the provider rejects it.

  2. Register an OAuth app with the provider, with this exact callback URL:

    • GitHub: https://cairn.example.com/auth/oauth/github/callback
    • Google: https://cairn.example.com/auth/oauth/google/callback
  3. Put its client ID and secret in .env:

    Terminal window
    OBS_OAUTH_GITHUB_CLIENT_ID=...
    OBS_OAUTH_GITHUB_CLIENT_SECRET=...

    (OBS_OAUTH_GOOGLE_CLIENT_ID and OBS_OAUTH_GOOGLE_CLIENT_SECRET for Google.)

  4. Restart the server.

A provider’s button appears only when auth is on and its client ID is set. Signing in with GitHub or Google does not bypass OBS_SIGNUP: an unknown email still needs an invite in invite mode.