Deployment
Production deployment is one compose file plus one .env. The engine ships
as ghcr.io/evs-cmd/cairn-engine; everything else is a compose profile
you toggle on.
The base stack
Section titled “The base stack”Always on:
- caddy — TLS + reverse proxy (needs
CADDY_DOMAIN) - server — the cairn engine (
cairn-server, port 9090) - postgres — app store + checkpointer (pgvector image)
Optional profiles
Section titled “Optional profiles”Enable with COMPOSE_PROFILES in the .env (and match --profile flags):
| Profile | Adds |
|---|---|
temporal | a cairn worker + Temporal server + Temporal UI (the durable runtime) |
dedicated-temporal-db | a separate Postgres for Temporal |
llm-gateway | a LiteLLM proxy (port 4000) — point LLM_GATEWAY=proxy + LLM_GATEWAY_URL at it for routing, spend tracking, and its admin UI |
mlflow | an MLflow tracking server — set MLFLOW_TRACKING_URI and model/register promotes into a real registry |
otel | an OpenTelemetry collector (traces/metrics fan-out) |
observability | Prometheus + Grafana |
nats | a JetStream-backed durable trigger inbox |
infisical | self-hosted secret management (+ its Postgres and Redis) |
backup | nightly pg_dump |
One .env
Section titled “One .env”Copy deploy/prod/.env.example to deploy/prod/.env on the box (secrets
never ship through git or CI — the deploy workflow ships only the image and
compose file). Four variables are required:
CADDY_DOMAIN=cairn.example.comPOSTGRES_PASSWORD=...OBS_JWT_SECRET=... # presence turns auth ON; OBS_REQUIRE_AUTH=1 makes it mandatoryOBS_API_PASSWORD_HASH=... # from: cairn remote hash-passwordThen validate before starting anything:
cairn deploy doctor --env deploy/prod/.env --profile temporal --strictdeploy doctor cross-checks env ↔ profiles: OBS_RUNTIME=temporal without
the temporal profile is a failure; checkpointer, MLflow, OTel, Infisical,
and NATS settings are all verified for agreement. (Known quirk: the
llm-gateway profile currently triggers an “unknown profile” warning in
doctor even though it’s valid.)
Auth model
Section titled “Auth model”The control plane is open when OBS_JWT_SECRET is unset — fine for local
dev, never for a reachable box. With the secret set, every route outside a
small public list (/healthz, /token, /console assets, signed webhook
ingress) requires a bearer JWT from POST /token. Users are admin-invited
(POST /admin/users) — there is no public signup.
Observability
Section titled “Observability”The engine emits OpenTelemetry traces and metrics when
OTEL_EXPORTER_OTLP_ENDPOINT is set; LLM prompt/completion content export is
gated by OBS_TRACE_CONTENT (off / redacted (default) / raw). Runs
carry their trace_id so the console can deep-link into Langfuse/Grafana
(LANGFUSE_BASE, GRAFANA_BASE).
For the full variable list see the environment reference.