Skip to main content

Run your own cairn

Cairn is a single Go binary, a Postgres database, and an S3-compatible bucket. This guide takes you from nothing to a running instance with a verified web-create → web-read → agent loop.

Every command here was run against a fresh stack while writing this guide, and the outputs are the real ones. The one step that cannot be run for you is registering an OIDC client in your identity provider, since that happens in software this project doesn't ship — that section says exactly what cairn needs and how to tell it worked.

What you need​

RequirementNotes
DatabasePostgresThe schema is plain SQL — 15 migrations as of this writing, no extensions, no version-specific syntax — so any supported Postgres works. Verified on 16; that is what the compose path below brings. Cairn creates and migrates its own schema at startup; there is no separate migrate step.
Object storageAny S3-compatible storeArtifact bodies are content-addressed (SHA-256) and stored in a bucket, not in Postgres (ADR-0008). Verified with Garage, which is the image CI's integration job runs the S3 tests against; S3 itself works the same way, it is plain the S3 API. Cairn creates its bucket on connect.
Identity providerAny OIDC providerNeeded for humans to sign in to the web UI. Without it, the deployment is bearer-token-only — no interactive login exists at all. See Sign-in (OIDC).
TLSA reverse proxyCairn speaks plain HTTP and expects something in front terminating TLS. The compose path below brings Caddy, which obtains certificates automatically.

There is no external secret manager, no message broker, and no sidecar. One process, one database, one bucket.

Which variable is which​

Several names look alike but belong to different programs. The server — the same cairn binary, run as cairn serve — reads its settings from its own environment. The cairn CLI reads different names, from the shell of whoever is running it. Setting a server variable in your shell does nothing for the CLI, and the reverse is also true.

NameRead byWhat it is
CAIRN_API_TOKENSthe serverStatic bearer credentials for the operator's own automation, secret:<user>[:agent|:human]. See Static tokens for the operator.
CAIRN_TOKENthe cairn CLIThe one bearer token the CLI sends, the same as --token. The MCP client configs in Connect your agent expand it from your shell too, but /mcp accepts only a personal access token or an OAuth token there, never a CAIRN_API_TOKENS secret.
CAIRN_BASE_URLthe serverThe public origin the server builds short links and its OIDC redirect URI from.
CAIRN_URLthe cairn CLIThe server the CLI talks to, the same as --url. It defaults to the hosted service, so a self-hoster sets it.
CAIRN_ENCRYPTION_KEYthe serverEncrypts each outbound subscription's signing secret at rest. Receivers never see it: each one verifies against its own subscription's secret. The old instance-wide CAIRN_OUTBOUND_WEBHOOK_URLS and CAIRN_OUTBOUND_WEBHOOK_SECRET are gone, and the server ignores them.
CAIRN_API_TOKEN (singular)nothingA common slip. You want CAIRN_API_TOKENS on the server, or CAIRN_TOKEN for the CLI.

The pairs connect like this: one secret in the server's CAIRN_API_TOKENS is the value a REST or CLI client puts in CAIRN_TOKEN, and the server's CAIRN_BASE_URL is the address a client puts in CAIRN_URL. The MCP endpoint is the exception: it rejects CAIRN_API_TOKENS secrets with 401.

Configuration​

Everything comes from the environment; cairn serve takes no flags. Every variable is read from the CAIRN_ namespace. The one file cairn ever reads is the optional redaction allowlist that CAIRN_REDACTION_ALLOWLIST_FILE names (see Secret redaction).

VariableDefaultPurpose
CAIRN_HTTP_ADDR:8080Listen address. The container image defaults to this too.
CAIRN_DATABASE_URL$DATABASE_URLRequired. Postgres DSN.
CAIRN_BASE_URLhttps://cairn.stump.wtfSet this. The public origin used to build short URLs — and the OAuth redirect URI is always derived as <base>/auth/callback, so it must match the origin your users actually reach.
CAIRN_S3_ENDPOINTlocalhost:9000S3-compatible endpoint (host:port, no scheme).
CAIRN_S3_ACCESS_KEY / CAIRN_S3_SECRET_KEYminioadmin / minioadminDo not ship the defaults on a public host. They are a MinIO pair: a Garage access key id is GK plus 12 hex bytes and its secret is 32 hex bytes, so neither default can authenticate against the store above. Set both.
CAIRN_S3_BUCKETcairnBucket name; created on connect if missing.
CAIRN_S3_REGIONus-east-1Region string most S3 implementations accept.
CAIRN_S3_USE_SSLfalseSet true when the endpoint speaks HTTPS.
CAIRN_API_TOKENS(empty)Static bearer credentials for the operator's own automation, comma-separated secret:<user>[:agent|:human]. <user> is an operator's <issuer>|<subject> or verified email, and they must have signed in once. Tokens default to agent scopes. An entry naming anyone else, and every legacy secret:actor entry, fails boot (details). Empty means the bearer surface accepts no static tokens — it fails closed. Everyone else uses OAuth or a personal access token minted in Settings. Wired through the compose file above.
CAIRN_OIDC_ISSUER(empty)Issuer URL of your OIDC provider. Its presence is the switch that turns OIDC on and, just as importantly, turns the dev-password login off (below).
CAIRN_OIDC_CLIENT_IDcairnClient id registered at your provider.
CAIRN_OIDC_CLIENT_SECRET(empty)Client secret. The redirect URI is not configurable — it is always <base>/auth/callback.
CAIRN_OPERATORS(empty)Who runs this instance: comma-separated <issuer>|<subject> sign-in identities, for example https://id.example.com|abc-123. Listed users get the operator console. Empty (with CAIRN_OPERATOR_GROUP empty too) means there is no operator and every operator route answers 404. A malformed entry fails boot.
CAIRN_OPERATOR_GROUP(empty)An OIDC group whose members are operators, read from the groups claim at sign-in. Setting it makes cairn request the groups scope. Removal from the group at the IdP takes effect only when that user's session ends (details).
CAIRN_OIDC_TRUST_EMAILfalseDeclares your OIDC provider authoritative for emails: its email claim counts as verified even when the ID token's email_verified is absent or false. A verified email links a sign-in to the user who owns it, including the Bin an existing user had before users existed. Set it only for a provider you run whose users cannot set their own email (Pocket ID with LDAP-synced users, say). Never set it for a provider that lets people type any email: that would let them sign in as someone else. GitHub sign-ins always use GitHub's primary verified email and ignore this setting.
CAIRN_GITHUB_CLIENT_ID / CAIRN_GITHUB_CLIENT_SECRET(empty)GitHub as a sign-in provider: the OAuth app's client id and secret. Set both to turn GitHub login on. With either one empty the provider stays out of the registry and /auth/login?provider=github answers 404, indistinguishable from an unknown provider. The callback URL is not configurable — it is always <base>/auth/callback, derived from CAIRN_BASE_URL, so register that exact origin with the OAuth app. Wired through the compose file above.
CAIRN_DEV_LOGIN_PASSWORD(empty)Shared-secret web login accepted for any actor id. Honored only while CAIRN_OIDC_ISSUER is unset — a deployment that configures OIDC can never fall back to it. Empty disables interactive login entirely. Development seam: deliberately not wired through the compose file above.
CAIRN_DEV_INSECURE_BEARER_AUTHfalseMakes the API trust any bearer token as its own actor id with no verification. A local-development shortcut that must never be enabled in production. Development seam: deliberately not wired through the compose file above.
CAIRN_ENCRYPTION_KEY(empty)32 random bytes, base64 (openssl rand -base64 32), that encrypt outbound subscription secrets at rest. Unset, nobody can create a subscription; everything else runs. A malformed value fails boot. Never logged. Wired through the compose file above.
CAIRN_SUBSCRIPTIONS_PER_USER / CAIRN_SUBSCRIPTIONS_PER_TEAM5 / 10Most outbound subscriptions one user, or one team, may own (1 to 1000).
CAIRN_OUTBOUND_ALLOW_HTTPfalseRisky. Admit plain http:// subscription targets. Cairn logs a WARN at startup while it's on (why). Deliberately not wired through the compose file above.
CAIRN_APPROVAL_REACTIONS👍,✅,✔️Comma-separated emoji whose reaction counts as an approval on reaction events. Skin tones and VS-16 are ignored when matching. Only a browser-session reaction is ever an approval. A malformed entry fails startup.
CAIRN_DEFAULT_TTL168hDefault artifact expiry (Go duration; 7 days).
CAIRN_MAX_UPLOAD_BYTES67108864Max upload size, enforced incrementally — an oversize upload is rejected mid-stream with 413, not after buffering.
CAIRN_PREVIEW_MAX_BYTES5242880Bodies above this skip the rich viewer and use the generic file path.
CAIRN_SESSION_TTL168hWeb session and cookie lifetime.
CAIRN_RATE_PER_SECOND / CAIRN_RATE_BURST20 / 40Per-IP rate limit on public/ingress endpoints.
CAIRN_OAUTH_ACCESS_TTL1hOAuth access-token lifetime.
CAIRN_OAUTH_REFRESH_TTL720hRotating refresh-token lifetime (30 days).
CAIRN_OAUTH_RATE_PER_SECOND / CAIRN_OAUTH_RATE_BURST10 / 30Dedicated per-IP limit on the OAuth bootstrap endpoints.
CAIRN_HOOK_INGRESS_RATE_PER_SECOND / CAIRN_HOOK_INGRESS_RATE_BURST5 / 20Per-source-IP limit on the anonymous-write ANY /h/{id} webhook route — deliberately separate from, and tighter than, the general budget.
CAIRN_HOOK_ENDPOINT_RATE_PER_SECOND / CAIRN_HOOK_ENDPOINT_RATE_BURST10 / 50Per-endpoint limit on the same route.
CAIRN_REAP_INTERVAL / CAIRN_REAP_BATCH / CAIRN_REAP_OBJECT_GRACE1h / 500 / 1hBackground expiry-reaper tuning.
CAIRN_SEARCH_INDEX_WORKERS2How many goroutines drain the lexical search index queue. Indexing never runs inside a create, so raising this only clears a backlog faster. Must be at least 1.
CAIRN_SEARCH_LANGUAGEenglishThe Postgres text-search configuration the index is built with: any pg_ts_config name your database has (simple, german, …). Keep it constant for one database — changing it changes how existing rows tokenize, so run cairn search reindex afterwards. A name Postgres rejects fails boot.
CAIRN_SEARCH_MAX_BODY_BYTES262144How much of each body (and each bundle member, per member) is tokenized into the index, in bytes. The artifact itself is stored and served whole; only the index sees the cap. Comments are separately capped at 64 KiB per artifact. Must be at least 1.
CAIRN_STAGING_LIFECYCLE_TTL168hS3 lifecycle expiration applied to the staging/ prefix only, so crashed-upload debris is reaped. Never applied to committed blobs.
CAIRN_REDACTION_REJECT_TYPEScode,bundleShare types whose creates are refused, not masked, when a credential is found. See Secret redaction.
CAIRN_REDACTION_MAX_SCAN_BYTES16777216Per-field credential-scan cap in bytes (16 MiB).
CAIRN_REDACTION_OVERSIZErejectWhat happens to a text field over the scan cap: reject or store_unscanned. Leave it at reject.
CAIRN_REDACTION_ALLOWLIST_FILE(empty)Path to the operator's value-only allowlist TOML. Empty means no allowlist.

Three settings are easy to get wrong:

  • CAIRN_BASE_URL decides whether login works at all. The OIDC redirect URI is derived from it, never configured separately, so it can never drift from the origin your provider's client was registered against — but that also means a wrong base URL points your login flow at the wrong origin.
  • CAIRN_API_TOKENS fails closed. Unset, nothing can call the API with a bearer token until a human mints a personal access token or an agent completes OAuth. That is the safe direction. Set, every entry must name an operator who has already signed in, so on a fresh instance configure sign-in and CAIRN_OPERATORS first, sign in once, then add the tokens.
  • CAIRN_DEV_LOGIN_PASSWORD and CAIRN_DEV_INSECURE_BEARER_AUTH are development seams. The dev password is disabled the moment any real provider is configured, OIDC (CAIRN_OIDC_ISSUER) or GitHub (CAIRN_GITHUB_CLIENT_ID), and its route then answers 404. The insecure bearer shortcut defaults off, and cairn serve refuses to start with it on while CAIRN_BASE_URL is https. Leave both alone on a real deployment.
  • Your IdP must mark emails verified, or you must trust it. A sign-in is keyed on the provider's (issuer, subject). Its email only identifies a person when it is verified: the ID token says email_verified: true, or you set CAIRN_OIDC_TRUST_EMAIL=true for a provider you run whose users cannot change their own email. Otherwise the user is keyed on their subject and does not see artifacts owned by that email until a later sign-in carries a verified one. Pocket ID sends email_verified: false unless EMAILS_VERIFIED is set, so set one of the two before upgrading.

Get the image​

docker pull ghcr.io/stump-wtf/cairn:latest

The image is multi-arch (linux/amd64, linux/arm64), built and Trivy-scanned by the release workflow on every v* tag. If the pull is denied, the GHCR package is still marked private — package visibility is a one-time flip in the GitHub package settings, not something code fixes. There is also a build-from-source path below.

Start it​

There are two paths. Docker is the shorter one; the binary path suits an existing database or a systemd unit.

With Docker​

Save this as compose.yaml — it is the self-hoster shape: published image, no build step, secrets from a .env, Caddy for TLS:

services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: cairn
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: cairn
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cairn"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped

garage:
image: dxflrs/garage:v2.5.0
volumes:
- ./garage/garage.toml:/etc/garage.toml:ro
- garagedata:/var/lib/garage
restart: unless-stopped

# The image's entrypoint runs the server (`cairn serve`); no command
# override is needed.
cairn:
image: ghcr.io/stump-wtf/cairn:latest
depends_on:
postgres: {condition: service_healthy}
garage: {condition: service_started}
environment:
CAIRN_HTTP_ADDR: ":8080"
CAIRN_DATABASE_URL: "host=postgres port=5432 user=cairn password=${POSTGRES_PASSWORD} dbname=cairn sslmode=disable"
CAIRN_S3_ENDPOINT: garage:3900
CAIRN_S3_ACCESS_KEY: ${CAIRN_S3_ACCESS_KEY}
CAIRN_S3_SECRET_KEY: ${CAIRN_S3_SECRET_KEY}
CAIRN_S3_BUCKET: ${CAIRN_S3_BUCKET:-cairn}
CAIRN_S3_REGION: ${CAIRN_S3_REGION:-us-east-1}
CAIRN_S3_USE_SSL: "false"
CAIRN_BASE_URL: https://${CAIRN_DOMAIN:?set CAIRN_DOMAIN in .env}
CAIRN_OIDC_ISSUER: ${CAIRN_OIDC_ISSUER:-}
CAIRN_OIDC_CLIENT_ID: ${CAIRN_OIDC_CLIENT_ID:-cairn}
CAIRN_OIDC_CLIENT_SECRET: ${CAIRN_OIDC_CLIENT_SECRET:-}
CAIRN_OIDC_TRUST_EMAIL: ${CAIRN_OIDC_TRUST_EMAIL:-false}
CAIRN_GITHUB_CLIENT_ID: ${CAIRN_GITHUB_CLIENT_ID:-}
CAIRN_GITHUB_CLIENT_SECRET: ${CAIRN_GITHUB_CLIENT_SECRET:-}
CAIRN_API_TOKENS: ${CAIRN_API_TOKENS:-}
CAIRN_OPERATORS: ${CAIRN_OPERATORS:-}
CAIRN_OPERATOR_GROUP: ${CAIRN_OPERATOR_GROUP:-}
CAIRN_ENCRYPTION_KEY: ${CAIRN_ENCRYPTION_KEY:-}
CAIRN_APPROVAL_REACTIONS: ${CAIRN_APPROVAL_REACTIONS:-}
restart: unless-stopped

caddy:
image: caddy:2-alpine
depends_on: [cairn]
ports: ["80:80", "443:443"]
environment:
CAIRN_DOMAIN: ${CAIRN_DOMAIN}
CAIRN_ACME_EMAIL: ${CAIRN_ACME_EMAIL:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddydata:/data
- caddyconfig:/config
restart: unless-stopped

volumes: {pgdata: {}, garagedata: {}, caddydata: {}, caddyconfig: {}}

and next to it a three-line Caddyfile:

{
email {$CAIRN_ACME_EMAIL}
}

{$CAIRN_DOMAIN} {
encode gzip
reverse_proxy cairn:8080 {
header_up X-Forwarded-For {remote_host}
}
}

Then:

mkdir -p garage
cat > .env <<EOF
CAIRN_DOMAIN=cairn.example.com
CAIRN_ACME_EMAIL=you@example.com
POSTGRES_PASSWORD=$(openssl rand -hex 24)
CAIRN_S3_ACCESS_KEY=GK$(openssl rand -hex 12)
CAIRN_S3_SECRET_KEY=$(openssl rand -hex 32)
CAIRN_S3_BUCKET=cairn
CAIRN_ENCRYPTION_KEY=$(openssl rand -base64 32)
EOF
cat > garage/garage.toml <<EOF
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "$(openssl rand -hex 32)"

[s3_api]
s3_region = "us-east-1"
api_bind_addr = "[::]:3900"

[admin]
api_bind_addr = "[::]:3903"
EOF
docker compose up -d postgres garage

Note the unquoted heredoc delimiter (<<EOF, not <<'EOF'): it is what makes the $(openssl …) substitutions run. With quotes, the file would contain the literal command string as the password — it looks random in the file and the stack still starts, which is the worst kind of wrong. The same applies to garage.toml, whose rpc_secret is what Garage derives its node id from: generated per instance, and yours to keep off any shared machine.

Garage then needs one thing MinIO did not: it refuses to serve S3 until a node has a role in the cluster layout, and credentials exist only once a key has been imported and allowed on the bucket. Its image has no shell, so these admin commands run inside the container. They are safe to re-run on a store that is already configured, which is why the layout step is guarded rather than blind:

set -a; . ./.env; set +a
until docker compose exec -T garage /garage status >/dev/null 2>&1; do sleep 1; done
if docker compose exec -T garage /garage status | grep -q 'NO ROLE ASSIGNED'; then
node="$(docker compose exec -T garage /garage node id | cut -d@ -f1)"
docker compose exec -T garage /garage layout assign -z dc1 -c 10G "$node"
docker compose exec -T garage /garage layout apply --version 1
fi
docker compose exec -T garage /garage bucket create "$CAIRN_S3_BUCKET" || true
docker compose exec -T garage /garage key import --yes -n cairn \
"$CAIRN_S3_ACCESS_KEY" "$CAIRN_S3_SECRET_KEY" || true
docker compose exec -T garage /garage bucket allow --read --write --owner "$CAIRN_S3_BUCKET" \
--key "$CAIRN_S3_ACCESS_KEY"
docker compose up -d

The || true on the two create steps is for the restart case: a bucket and a key that already exist make Garage exit non-zero, and the next bucket allow sets the same permissions again. A key id Garage rejects is different, and shows up there as "not a valid Garage key ID": the GK plus 12 hex bytes shape the .env above generates is what it accepts, so a hand-edited hex string will not do.

Point an A/AAAA record for CAIRN_DOMAIN at the host with 80 and 443 open, and Caddy obtains the certificate on first request.

Why is the object store above Garage?

MinIO's images stopped pulling while this guide was being written (the quay.io repository returns 401 to anonymous pulls and the Docker Hub repository is gone), which left nobody able to follow this page to a running instance. Garage is what CI's integration job has been testing against all along, so pinning it makes the documented deploy path and the tested one the same image rather than two claims about "S3-compatible". Any S3-compatible store still works; substitute MinIO from a registry you trust, or S3 itself, and drop the bootstrap step, which is Garage's own cluster layout rather than anything cairn needs.

Upgrading Garage from v1.x​

The v2 image reads the same metadata and data directories, so no object data moves: back up the garagedata volume first (for rollback only), and check one config key — v2 rejects replication_mode in garage.toml. Replace it with replication_factor (the config block above already uses the v2 form), then docker compose up -d garage restarts the node on the new image. Multi-node clusters restart all nodes at once; the full procedure is Garage's migration guide.

docker compose logs cairn on a healthy start looks like this:

{"time":"…","level":"INFO","msg":"retention reaper started","interval":"1h0m0s","batch":500}
{"time":"…","level":"INFO","msg":"cairn serve listening","addr":":8080"}

Migrations run on first boot before that line — there is no migrate step to run. The reaper line is your confirmation that the schema exists and the background workers came up.

The one thing docker compose up cannot do is authenticate anybody. Without OIDC configured there is no interactive login, and without tokens nothing can call the API — see Sign-in (OIDC) and Tokens for agents before exposing the instance. Every credential belongs to a user who has signed in, so the first one starts with a sign-in: see First run: bootstrap a credential.

As a binary​

git clone https://github.com/stump-wtf/cairn && cd cairn
go build -o cairn ./cmd/cairn

export CAIRN_DATABASE_URL='host=localhost port=5432 user=cairn password=secret dbname=cairn sslmode=disable'
export CAIRN_BASE_URL='https://cairn.example.com'
export CAIRN_S3_ENDPOINT='s3.example.com'
export CAIRN_S3_ACCESS_KEY='…'
export CAIRN_S3_SECRET_KEY='…'
export CAIRN_S3_USE_SSL=true
./cairn serve

Go 1.26.5 or newer (see go.mod). sslmode=disable above assumes the database is on the same host or a private network; across a network, use sslmode=verify-full. Cairn accepts both the keyword/value DSN shown here and the URL form — the keyword form is used throughout this guide. The healthy-start logs are the same two lines as the Docker path.

When it won't start​

MessageCause
db: ping: failed to connect … connection refusedThe DSN is right but nothing is listening — the database isn't up yet, or the hostname is wrong inside the compose network.
s3: … connect errors on bootWrong CAIRN_S3_ENDPOINT (host:port, no scheme) or the store isn't reachable. Cairn retries via the container restart policy in the Docker path; as a binary it exits and it is yours to restart.
CAIRN_API_TOKENS entry N: legacy secret:actor entries are no longer accepted …The Nth token is in the old free-form shape. Rewrite it as secret:<user>[:agent|:human] (details).
CAIRN_API_TOKENS entry N: user is not an operatorThe Nth token names a real user who is not listed in CAIRN_OPERATORS. Give them a personal access token instead.
CAIRN_API_TOKENS entry N: no user; …Nobody with that identity or verified email has signed in yet. Sign in once, then restart.
config CAIRN_ENCRYPTION_KEY: …The key isn't base64, or doesn't decode to 32 bytes. Generate one with openssl rand -base64 32. The error never repeats the value.
config CAIRN_REDACTION_…: …A redaction variable has a value cairn does not accept, such as a CAIRN_REDACTION_OVERSIZE other than reject or store_unscanned, or a scan cap that is not a positive integer.
ingest redaction: redaction allowlist <path>: …The allowlist file is missing, does not parse, or has an entry cairn refuses. The message names the entry. See the allowlist format.

All of them say which one failed in the log line — db:, s3:, config, ingest redaction:, CAIRN_API_TOKENS entry N — and fail closed rather than starting half-configured. A token error names the entry's position, never its secret.

Behind a reverse proxy​

Two things matter more than the rest:

  • Pass a trusted X-Forwarded-For. The per-IP rate limiters key on the client IP, and with TLS terminated upstream every agent otherwise arrives from one address and shares a bucket. The Caddyfile above does the header_up.
  • Don't buffer the MCP route. /mcp speaks Streamable HTTP with long-lived responses; a buffering proxy holds tool results until the connection closes. Caddy is fine by default; on nginx set proxy_buffering off; for the /mcp location.

Don't put forward-auth in front of cairn for sign-in: cairn is a complete OIDC relying party with its own sessions.

Sign-in (OIDC)​

Register cairn as a confidential client in your provider:

  • Redirect URI: <CAIRN_BASE_URL>/auth/callback — derived, never separately configured
  • Grant: authorization code, with PKCE
  • Client id: anything; cairn is the default

Then set CAIRN_OIDC_ISSUER, CAIRN_OIDC_CLIENT_ID, and CAIRN_OIDC_CLIENT_SECRET and recreate the service.

This is the one step written but not run while writing this guide — it needs an identity provider this project doesn't ship. What cairn does with it: on startup it discovers the provider from the issuer URL (failing closed if the discovery document is missing), and the login page gains a sign-in button where without OIDC it offers nothing at all. Users are created on first sign-in, keyed on the OIDC subject, so whoever can authenticate at your provider can sign in here — restrict access at the provider. A sign-in with a verified email (see CAIRN_OIDC_TRUST_EMAIL) joins the user who already owns that email instead of creating one.

To confirm it took: open /login. With OIDC configured you get the provider button; with nothing configured there is no login at all — the deployment relies on bearer tokens only.

The operator​

The operator is whoever runs the instance. Name yourself in CAIRN_OPERATORS as <issuer>|<subject>: the issuer is your CAIRN_OIDC_ISSUER exactly as configured (or https://github.com for a GitHub sign-in, whose subject is the numeric account id), and the subject is your account's ID-token sub. You do not have to work it out: sign in once and Settings → Account shows your Sign-in identity in that exact form. Or set CAIRN_OPERATOR_GROUP to a group your IdP puts in the groups claim. Operator status is decided on every request from the current configuration, so removing an entry takes effect on that user's next request; a group operator must sign in again after being added to the group.

Group membership works the other way too: the groups claim is read only at sign-in and kept with the session. Removing someone from the operator group at your IdP takes effect only when their session ends, up to CAIRN_SESSION_TTL (7 days by default). To demote them now, rename CAIRN_OPERATOR_GROUP (every group operator then signs in again under the new name), or delete their sessions from the database.

An operator is also an ordinary user. Their Bin holds their own artifacts, and being an operator never lets them open anyone else's. The console at /operator, linked from Settings, is deliberately small:

  • a directory of users with how many artifacts each owns and how many bytes they take up. It never shows an artifact's title, body, link, tags or comments;
  • suspension. Suspending a user signs them out everywhere at once: their browser sessions, personal access tokens and OAuth grants (with every access and refresh token) stop working on their next request, and they cannot sign in until reinstated. Reinstating restores nothing; they sign in again and mint new tokens. A user listed in CAIRN_OPERATORS cannot be suspended from the console: remove them from the list first;
  • an audit log. Every action needs a reason, and is recorded in the same database transaction as the action itself. The affected user sees the rows about them on their Settings page.

Every operator route needs the operator's browser session; an API token, even the operator's own, gets 404, and so does everyone else. With neither variable set there is no operator at all and those routes answer 404 to everyone.

:::warning The operator can still read everything underneath The console removes the easy path, not the possibility. Whoever controls the Postgres database and the object store can read every artifact, comment and captured request in them, whatever the product shows. Treat access to those the way you treat the data itself, and tell your users. :::

Outbound subscriptions​

Outbound events go to subscriptions, which each user owns and manages in Settings → Outbound subscriptions or over /v1/subscriptions (guide). An event about an artifact goes only to its owner's subscriptions. There is no instance-wide target list: the operator's own receiver is just the operator's subscription.

  • Set CAIRN_ENCRYPTION_KEY. Each subscription's signing secret is stored encrypted under it. Without it nobody can add a subscription, and cairnd logs outbound subscriptions unavailable at startup. Back the key up with the database: a changed or lost key leaves every existing secret unreadable, and those subscriptions stop delivering (cairnd logs an error per event; it never sends unsigned) until each owner rotates their secret.
  • Targets must be public https. Loopback, private (RFC 1918), link-local and unique-local addresses are refused when a subscription is added, and every address the host resolves to is checked again before each delivery, so a name re-pointed at your internal network is never dialled. Redirects aren't followed. Each attempt times out after five seconds, and delivery never holds up artifact creation.
  • Failing targets switch themselves off. After 20 consecutive failed deliveries a subscription is disabled and says so in its owner's Settings; resuming it resets the count.
  • Ceilings. A user may own CAIRN_SUBSCRIPTIONS_PER_USER subscriptions (5 by default).

:::warning CAIRN_OUTBOUND_ALLOW_HTTP sends signed events in cleartext With CAIRN_OUTBOUND_ALLOW_HTTP=true, subscriptions may name plain http:// targets. Every event then crosses the network unencrypted, including the artifact's link, which opens the artifact for whoever reads it, and anyone on the path can read and replay the signed body. cairnd logs a WARN naming the variable at every startup while it is on. Leave it off on any instance reachable from the internet; it exists for a lab network you control end to end. It does not relax the address rules above. :::

Tokens for agents​

Two ways to authorize an agent, both documented in Connect your agent:

  • A personal access token, minted by a signed-in human in Settings → API tokens. This is the everyday path for headless agents. The token starts cairn_pat_ and is shown exactly once.
  • OAuth 2.1, the path interactive MCP clients take automatically — they discover /.well-known/oauth-protected-resource/mcp and run the flow.

Minting a PAT is deliberately a browser-only action (Settings is session-authenticated and CSRF-guarded); there is no API to mint tokens with a token, by design. Both paths need someone who can sign in, so a new instance starts with the first sign-in below.

First run: bootstrap a credential​

A fresh instance has no users, and every credential belongs to one: a personal access token is minted in a browser session, and a CAIRN_API_TOKENS entry must name an operator who has already signed in. So the first credential always starts with a sign-in. The server logs this WARN at startup while it has no way to authenticate anybody:

no API tokens (CAIRN_API_TOKENS), no OIDC (CAIRN_OIDC_ISSUER), and no dev web login (CAIRN_DEV_LOGIN_PASSWORD) configured: all authenticated endpoints will reject every caller

1. Configure a sign-in provider, OIDC or GitHub, and recreate the server so it reads the new values. docker compose up -d does that when .env changes; docker compose restart does not, because it keeps the old environment. On the binary path, export the variables in the environment cairn serve starts from and restart it instead.

2. Sign in at /login in a browser. Settings → Account shows the <issuer>|<subject> you signed in as.

3. Mint a personal access token in Settings → API tokens and use it as CAIRN_TOKEN:

export CAIRN_TOKEN='cairn_pat_…'
curl -sS https://cairn.example.com/v1/whoami -H "Authorization: Bearer $CAIRN_TOKEN"

If you use the cairn CLI, also set CAIRN_URL=https://cairn.example.com, since the CLI talks to the hosted service unless told otherwise. A personal access token works on the REST API, with the CLI and on /mcp.

For automation that belongs in the server's own configuration, add yourself to CAIRN_OPERATORS and give yourself a static token as described next. A static secret never works on /mcp.

Static tokens for the operator​

CAIRN_API_TOKENS exists for one job: the operator automating their own account from the server's configuration. A credential its user cannot see or revoke must not be able to act as them, so every entry names an operator's own user and nobody else:

# By sign-in identity, with the default agent role:
CAIRN_API_TOKENS=<secret>:https://id.example.com|abc-123
# By verified email, with the human role:
CAIRN_API_TOKENS=<secret>:you@example.com:human

Several entries go in one value, separated by commas.

  • <user> is either your sign-in identity, <issuer>|<subject>, exactly as Settings → Account shows it, or your verified email. Either way it must resolve at boot to an existing user who is an operator through CAIRN_OPERATORS. An operator only through CAIRN_OPERATOR_GROUP cannot hold one, because the group is read from a browser sign-in.
  • The role defaults to agent: artifacts, reads and annotations, never sharing, expiry or delete. :human adds those, and like every credential it still reaches only your own artifacts.
  • A token acts as its user and cannot choose another. Whatever it creates is owned by the operator's user.
  • Each token shows up on its user's Settings page as operator-provisioned, by position, never by secret. To revoke one, remove it and restart.
  • Anything else fails boot: an entry naming a non-operator, a user who has never signed in, a suspended user, or any legacy secret:actor[:role] entry. The legacy form is refused outright; there is no grace period.
  • Upgrading from legacy entries: the artifacts an old secret:actor token owned stay with that actor's user, and no credential acts as it any more. A non-email actor is never claimed by a sign-in either, so its Bin is orphaned. The CHANGELOG upgrade note has the SQL that lists those artifacts and moves them to your user. It needs migration 0023's columns, so run it once that migration has applied.
  • Generate secrets with something like openssl rand -hex 32. Cairn keeps only their SHA-256 digests in memory and never logs one.

Secret redaction​

Cairn scans every text it stores for credentials before it stores it: artifact bodies and titles, bundle members, comments, trace runs and spans, and webhook captures. A hit is either masked, which replaces only the secret value with the literal [REDACTED] and stores the rest, or rejected, which refuses the write with an error that names the field, the rule, and the line, never the value. The engine is the gitleaks v8 library with its default ruleset plus cairn's own rules for the shapes agents leak most: Authorization headers, API-key headers, passwords in URLs, secret-named assignments and flags, and curl -u. See ADR-0023 and SPEC-0017.

There is deliberately no off switch. No variable, request header, or tool argument turns scanning off, and cairn serve refuses to start if the scanner cannot be built. A writer can only downgrade a rejecting type to masking for one request (X-Cairn-Redaction: mask, --redact=mask, or redaction: "mask" over MCP). Any other value is refused: the API answers validation_failed, and the CLI stops with a usage error before it sends anything.

What is masked and what is rejected​

ContentFields scannedDefault
markdown, and a file whose body sniffs as textbody, titlemask
codebody, titlereject
bundleeach text member, titlereject
Trace run (batch create and append)run title, prompt, span name, args, outputmask
Comment (create and edit)bodymask
Webhook capturequery, headers, bodymask

A file upload whose media type names a programming language is stored as code, so it rejects like code. Masking changes the stored bytes, so the stored SHA-256 is of the masked body and the create response says redacted: true (see Troubleshooting). The owner sees the outcome on each artifact: a status (clean, masked, not_scanned_binary, not_scanned_oversize, or unscanned for content stored before scanning existed), a count, and the rule IDs. Never the value.

Variables​

VariableDefaultAccepted values
CAIRN_REDACTION_REJECT_TYPEScode,bundleComma-separated share types, case-insensitive, whitespace trimmed. Only artifact and bundle creates consult it: traces, comments, and webhook captures always mask. Names are not checked against the known share types, so a misspelt type silently masks. Empty or unset means the default.
CAIRN_REDACTION_MAX_SCAN_BYTES16777216 (16 MiB)A positive integer number of bytes, applied to each scanned field. An artifact or bundle body over 1 MiB is scanned in overlapping windows read back from staging, never buffered whole. Zero, a negative number, or a non-integer stops startup.
CAIRN_REDACTION_OVERSIZErejectreject refuses a text field over the cap with too_large_to_scan. store_unscanned stores it unscanned with status not_scanned_oversize. Anything else, including a different case, stops startup.
CAIRN_REDACTION_ALLOWLIST_FILE(empty)A path to an operator allowlist TOML, described below. Empty means no allowlist. A file that is missing or does not parse stops startup.

The compose file above passes none of these through, so the defaults apply. To change one, add it to the cairn service's environment. For an allowlist, also mount the file read-only into the container and point the variable at the path inside it.

Webhook captures are never refused, because the sender is an anonymous third party. Under reject, a capture field over the cap is replaced with a notice and named in the capture's redaction_withheld list instead.

warning
CAIRN_REDACTION_OVERSIZE=store_unscanned stores credentials

With store_unscanned, any text field over CAIRN_REDACTION_MAX_SCAN_BYTES is stored exactly as sent, with no credential scan, and everyone the link reaches can read whatever secret it holds. Cairn logs a WARN naming CAIRN_REDACTION_OVERSIZE at startup, and another WARN, with the artifact's id and the field's size, each time it stores a field unscanned. Leave it at reject. If large text needs sharing, raise the cap instead: a bigger cap costs scan time, while store_unscanned costs the scan.

Operator allowlist​

CAIRN_REDACTION_ALLOWLIST_FILE exempts values, never places. The file is operator configuration read once at startup; no user, token, or request can set or change it. It accepts only these three top-level keys, each optional:

# Values matching one of these regexes are not masked or rejected. Each regex is
# matched against the detected value, and every alternative must be anchored
# with ^ and $.
regexes = [
'''^cairn-docs-fixture-[0-9]{4}$''',
]

# A detected value containing any of these words, ignoring case, is exempt.
stopwords = [
"cairn-demo-placeholder",
]

# Rule IDs to switch off entirely.
disabledRules = []

Cairn refuses to start, naming the file and the entry, when:

  • a regex does not compile, is not anchored, or matches the empty string. Anchored means every alternative starts with ^ and ends with $, so ^a$|^b$ is accepted and ^a|.+$ is refused. A flag such as (?i) is allowed; (?m) is refused, because it makes ^ and $ match at every line;
  • a stopword or a disabledRules entry is empty;
  • a disabledRules entry is not a known rule ID;
  • the file has paths, commits, or any other key. Path allowlists are refused because an uploader chooses their own file names, and an upload has no commits to match.

List only inert fixture values: the placeholder strings your own tests, docs, and demos use, which authenticate nowhere. Never list a real credential, even a revoked one, and never a pattern that could match one. A stopword exempts every detected value that contains it, so keep stopwords long and specific. Prefer an anchored regex for one exact value, and prefer either to disabledRules, which blinds cairn to a whole shape of secret.

What scanning does not catch​

Scanning is defence in depth, not a guarantee. These pass through:

  • Secrets in shapes no rule recognises, such as a bare random string with no label, prefix, or header around it.
  • Archives. Zip, tar, gzip, and the like are never unpacked.
  • Images and other binary bodies. A body whose leading bytes match a known binary signature is stored unscanned with status not_scanned_binary. A body declared as an image that is really text is scanned.
  • Anything over the cap when CAIRN_REDACTION_OVERSIZE=store_unscanned.

Treat a shared link as readable by anyone it reaches, and rotate any credential that was ever pasted into one.

Verify the whole loop​

Do this once, on a fresh instance. It takes a few minutes and proves storage, the REST API, and the agent surface all work together. The outputs below are from the run made while writing this guide, against a local stack.

1. Health. No auth, no dependencies hidden:

curl -sS http://127.0.0.1:8080/healthz
ok

2. Get a token. Sign in at /login in a browser and mint one in Settings → API tokens (First run). It goes in CAIRN_TOKEN.

3. Create an artifact over REST.

export CAIRN_TOKEN='…' # a cairn_pat_… token

curl -sS http://127.0.0.1:8080/v1/artifacts \
-H "Authorization: Bearer $CAIRN_TOKEN" \
-H 'Content-Type: text/markdown' \
-H 'X-Cairn-Title: self-host verify' \
--data-binary @notes.md
{"id":"mibnFwfO","url":"http://127.0.0.1:8080/mibnFwfO","mcp":"mcp://cairn/mibnFwfO","share_type":"markdown","title":"self-host verify","size":52,"media_type":"text/markdown","previewable":true,"checksum":"33894da4…","provenance":{"actor":"you@example.com","channel":"via API","captured_at":"2026-09-13T07:40:44Z"},"visibility":"link","expires_in":"in 6d"}

4. Read it back.

curl -sS http://127.0.0.1:8080/v1/artifacts/mibnFwfO/body \
-H "Authorization: Bearer $CAIRN_TOKEN"
# self-host verify

Hello from a self-hosted cairn.

That round-trip is the split store working end to end: the metadata came from Postgres, the bytes from object storage.

5. Prove auth is enforced. The same create with a junk token:

HTTP 401 {"error":{"code":"unauthorized",…}}

Nothing is stored for a rejected request.

6. Connect an agent over MCP. Any MCP client speaking Streamable HTTP can reach <base>/mcp. The verification used a bare JSON-RPC exchange — what a real client does for you — with a personal access token. A static CAIRN_API_TOKENS secret gets 401 here, because /mcp accepts only a personal access token or an OAuth token:

curl -sS -X POST http://127.0.0.1:8080/mcp \
-H "Authorization: Bearer $CAIRN_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"verify","version":"0"}}}'
HTTP 200
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{},…,"tools":{"listChanged":true}},"instructions":"Cairn is an AI-native artifact-sharing service…"},"serverInfo":{"name":"cairn","version":"0.1.0"}}

Complete the handshake (notifications/initialized, echoing the Mcp-Session-Id response header), then tools/list shows the surface — artifact_create, artifact_read, artifact_comment, artifact_react, bundle_create, run_create, run_append_spans — and a tools/call of artifact_read for the artifact from step 3 returns its body.

If all six steps behave that way, the instance is real: auth is enforced, bodies round-trip through the bucket, and agents can work.

What to do next​