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
| Requirement | Notes | |
|---|---|---|
| Database | Postgres | The 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 storage | Any S3-compatible store | Artifact 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 provider | Any OIDC provider | Needed 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). |
| TLS | A reverse proxy | Cairn 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.
| Name | Read by | What it is |
|---|---|---|
CAIRN_API_TOKENS | the server | Static bearer credentials for the operator's own automation, secret:<user>[:agent|:human]. See Static tokens for the operator. |
CAIRN_TOKEN | the cairn CLI | The 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_URL | the server | The public origin the server builds short links and its OIDC redirect URI from. |
CAIRN_URL | the cairn CLI | The server the CLI talks to, the same as --url. It defaults to the hosted service, so a self-hoster sets it. |
CAIRN_ENCRYPTION_KEY | the server | Encrypts 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) | nothing | A 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).
| Variable | Default | Purpose |
|---|---|---|
CAIRN_HTTP_ADDR | :8080 | Listen address. The container image defaults to this too. |
CAIRN_DATABASE_URL | $DATABASE_URL | Required. Postgres DSN. |
CAIRN_BASE_URL | https://cairn.stump.wtf | Set 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_ENDPOINT | localhost:9000 | S3-compatible endpoint (host:port, no scheme). |
CAIRN_S3_ACCESS_KEY / CAIRN_S3_SECRET_KEY | minioadmin / minioadmin | Do 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_BUCKET | cairn | Bucket name; created on connect if missing. |
CAIRN_S3_REGION | us-east-1 | Region string most S3 implementations accept. |
CAIRN_S3_USE_SSL | false | Set 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_ID | cairn | Client 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_EMAIL | false | Declares 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_AUTH | false | Makes 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_TEAM | 5 / 10 | Most outbound subscriptions one user, or one team, may own (1 to 1000). |
CAIRN_OUTBOUND_ALLOW_HTTP | false | Risky. 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_TTL | 168h | Default artifact expiry (Go duration; 7 days). |
CAIRN_MAX_UPLOAD_BYTES | 67108864 | Max upload size, enforced incrementally — an oversize upload is rejected mid-stream with 413, not after buffering. |
CAIRN_PREVIEW_MAX_BYTES | 5242880 | Bodies above this skip the rich viewer and use the generic file path. |
CAIRN_SESSION_TTL | 168h | Web session and cookie lifetime. |
CAIRN_RATE_PER_SECOND / CAIRN_RATE_BURST | 20 / 40 | Per-IP rate limit on public/ingress endpoints. |
CAIRN_OAUTH_ACCESS_TTL | 1h | OAuth access-token lifetime. |
CAIRN_OAUTH_REFRESH_TTL | 720h | Rotating refresh-token lifetime (30 days). |
CAIRN_OAUTH_RATE_PER_SECOND / CAIRN_OAUTH_RATE_BURST | 10 / 30 | Dedicated per-IP limit on the OAuth bootstrap endpoints. |
CAIRN_HOOK_INGRESS_RATE_PER_SECOND / CAIRN_HOOK_INGRESS_RATE_BURST | 5 / 20 | Per-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_BURST | 10 / 50 | Per-endpoint limit on the same route. |
CAIRN_REAP_INTERVAL / CAIRN_REAP_BATCH / CAIRN_REAP_OBJECT_GRACE | 1h / 500 / 1h | Background expiry-reaper tuning. |
CAIRN_SEARCH_INDEX_WORKERS | 2 | How 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_LANGUAGE | english | The 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_BYTES | 262144 | How 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_TTL | 168h | S3 lifecycle expiration applied to the staging/ prefix only, so crashed-upload debris is reaped. Never applied to committed blobs. |
CAIRN_REDACTION_REJECT_TYPES | code,bundle | Share types whose creates are refused, not masked, when a credential is found. See Secret redaction. |
CAIRN_REDACTION_MAX_SCAN_BYTES | 16777216 | Per-field credential-scan cap in bytes (16 MiB). |
CAIRN_REDACTION_OVERSIZE | reject | What 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_URLdecides 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_TOKENSfails 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 andCAIRN_OPERATORSfirst, sign in once, then add the tokens.CAIRN_DEV_LOGIN_PASSWORDandCAIRN_DEV_INSECURE_BEARER_AUTHare 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 answers404. The insecure bearer shortcut defaults off, andcairn serverefuses to start with it on whileCAIRN_BASE_URLishttps. 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 saysemail_verified: true, or you setCAIRN_OIDC_TRUST_EMAIL=truefor 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 sendsemail_verified: falseunlessEMAILS_VERIFIEDis 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
| Message | Cause |
|---|---|
db: ping: failed to connect … connection refused | The 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 boot | Wrong 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 operator | The 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 theheader_up. - Don't buffer the MCP route.
/mcpspeaks Streamable HTTP with long-lived responses; a buffering proxy holds tool results until the connection closes. Caddy is fine by default; on nginx setproxy_buffering off;for the/mcplocation.
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;
cairnis 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_OPERATORScannot 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 logsoutbound subscriptions unavailableat 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_USERsubscriptions (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/mcpand 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 throughCAIRN_OPERATORS. An operator only throughCAIRN_OPERATOR_GROUPcannot 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.
:humanadds 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:actortoken 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 migration0023'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
| Content | Fields scanned | Default |
|---|---|---|
markdown, and a file whose body sniffs as text | body, title | mask |
code | body, title | reject |
bundle | each text member, title | reject |
| Trace run (batch create and append) | run title, prompt, span name, args, output | mask |
| Comment (create and edit) | body | mask |
| Webhook capture | query, headers, body | mask |
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
| Variable | Default | Accepted values |
|---|---|---|
CAIRN_REDACTION_REJECT_TYPES | code,bundle | Comma-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_BYTES | 16777216 (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_OVERSIZE | reject | reject 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.
CAIRN_REDACTION_OVERSIZE=store_unscanned stores credentialsWith 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
disabledRulesentry is empty; - a
disabledRulesentry 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
- What Cairn is for — the model your users will work in
- Create your first share and connect your agent — the everyday surfaces
- Outbound webhooks — subscribe a Switchboard, or
anything else listening, to your
artifact.createdevents - Troubleshooting