Build, test, deploy
A Cairn app compiles to a single Go binary — no codegen step, no assets to bundle. This guide covers the one build flag that matters, the testing pattern Cairn itself uses, and what a production deployment needs at boot.
go build ./...One flag matters: cgo. Cairn’s sqlite driver is
github.com/mattn/go-sqlite3, a cgo package — sqlite requires
CGO_ENABLED=1 and a C toolchain on the build machine. Postgres-only
deployments may build CGO_ENABLED=0 static binaries: the Postgres driver
(jackc/pgx) is pure Go.
# sqlite anywhere in the deployment: cgo stays onCGO_ENABLED=1 go build ./...
# Postgres-only: a fully static binaryCGO_ENABLED=0 go build ./...This is also the deciding factor for cross-compilation: cgo needs a C
toolchain for the target platform, so sqlite builds are simplest done in a
container matching the deploy target, while CGO_ENABLED=0 binaries
cross-compile with GOOS/GOARCH alone.
Cairn’s own test suite does not mock the framework — it boots the real server
against a throwaway sqlite database inside httptest and drives real RPCs
through the full interceptor chain. Your app can use the same pattern;
srv.Handler() is exposed exactly for this:
func newTestServer(t *testing.T) (*httptest.Server, *cairn.Server) { t.Helper() ctx := context.Background() srv, err := cairn.NewServer(cairn.Config{ ServiceName: "myapp", Database: cairn.DB{Driver: "sqlite", DSN: filepath.Join(t.TempDir(), "test.db")}, }) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = srv.Shutdown(ctx) }) if err := srv.EnsureSchema(ctx); err != nil { t.Fatal(err) }
// Mount your services exactly as production does. path, handler := appv1connect.NewProjectServiceHandler( projectService{db: srv.Store().Unsafe()}, srv.HandlerOptions()...) srv.Mount(path, handler)
hs := httptest.NewServer(srv.Handler()) t.Cleanup(hs.Close) return hs, srv}Each test gets a fresh schema in a t.TempDir() database that vanishes with
the test. Drive RPCs with your generated Connect client —
appv1connect.NewProjectServiceClient(hs.Client(), hs.URL) — and every
request passes through the same auth, rate-limit, and constant-time
interceptors as production. Seed users, workspaces, and memberships through
srv.Store().Unscoped().
To exercise an RPC as a signed-in caller, run the real sign-in: configure a
capturing Mailer on Auth.MagicLink, call RequestMagicLink, pull the
token out of the captured email, and GET /auth/magiclink?token=… on the
test server with a cookie jar — the resulting cairn_session cookie
authenticates subsequent RPCs. For handler-level unit tests, skip HTTP
entirely and stamp the context with cairn.WithActiveWorkspace (see
Extending the model).
Two additions for specific surfaces:
- Dialect-sensitive SQL. The sqlite-backed pattern above covers most
logic, but where your queries diverge by dialect, run them against a real
Postgres — Cairn’s integration tests spin up a throwaway
postgres:16-alpinewith testcontainers and run the same test bodies against it. - Email content. To assert on delivered email rather than a captured
struct, send through a real SMTP hop into Mailpit and read the result back
from its REST API (
GET http://localhost:8025/api/v1/messages). Mailpit is already part of the dev environment; the sign-in page shows the mailer configuration.
Deploy
Section titled “Deploy”Database and schema
Section titled “Database and schema”Production runs on Postgres: cairn.DB{Driver: "postgres", DSN: "postgres://…"}.
Call srv.EnsureSchema(ctx) once at boot, before serving:
if err := srv.EnsureSchema(ctx); err != nil { log.Fatal(err)}EnsureSchema is forward-only and idempotent — it applies Cairn’s
embedded migrations (tracked in cairn_schema_migrations) and re-running it
applies nothing, so every replica can call it unconditionally at startup. Run
your own migrations after it; see
Extending the model.
On shutdown, srv.Shutdown(ctx) stops accepting connections, drains in-flight
requests (bounded by the context), and flushes the telemetry exporters — wire
it to SIGTERM.
Health and readiness
Section titled “Health and readiness”Cairn mounts two probe endpoints on every server:
/healthzis liveness:200 {"status": "ok"}whenever the process is alive and serving HTTP. It performs no downstream checks./readyzis readiness: it runs every registered check on each request and returns200 {"status": "ready"}when all pass, or503 {"status": "not ready", "failed": ["database"]}naming the failures. When a database is configured, a ping check (2-second timeout) is registered automatically.
Register checks for your own dependencies with AddReadinessCheck:
srv.AddReadinessCheck(cairn.ReadinessCheck{ Name: "cache", Check: func() error { return cacheClient.Ping(context.Background()).Err() },})A check returns nil when the subsystem is ready. Checks run on every
/readyz request, so keep them fast — single-digit milliseconds. One honest
caveat: the built-in database check only pings the connection, it does not
verify the schema — a server that skips EnsureSchema can report ready and
still fail on its first query.
Cookies and URLs
Section titled “Cookies and URLs”The session cookie ships hardened — HttpOnly, Secure, SameSite=Lax — so
there is nothing to enable. There is one thing to not do: CookieInsecure
exists for local HTTP development only; never set it in production. Set
Auth.BaseURL to the public HTTPS base where the /auth/* callbacks are
reachable and Auth.SuccessURL to the post-sign-in redirect — both are
required when any sign-in flow is enabled.
Observability
Section titled “Observability”The Observability block configures the OpenTelemetry exporters Cairn wires
up automatically:
| Field | Meaning |
|---|---|
OTLPEndpoint |
OTLP/gRPC endpoint for traces, metrics, and logs. Empty disables OTLP export — signals fall back to stdout as JSON, so a fresh deployment is observable from its logs alone. |
OTLPLogsEndpoint |
Overrides OTLPEndpoint for log signals only. |
SampleRate |
Head-based trace sampling in [0, 1]; defaults to 0.1. |
Storage
Section titled “Storage”If file storage is enabled, cairn.FSBlobStore(dir) needs dir on a
persistent volume — file metadata lives in the database and the content
lives in that directory, and the database is the authority, so losing the
directory strands metadata pointing at nothing. The filesystem store suits
single-instance deployments; multiple replicas need a shared backend via a
custom BlobStore (see Files). Size the
upload cap with Storage.MaxUploadBytes (0 → 32 MiB).
Multiple replicas
Section titled “Multiple replicas”The default rate limiter is in-process, so each replica counts on its own —
behind N replicas the effective limit is N× the configured rate. For one
global limit, point Auth.RateLimit.Limiter at the bundled Redis adapter;
Hardening covers the wiring and the
fail-open/fail-closed choice.
Development environment
Section titled “Development environment”Magic-link sign-in needs somewhere to deliver email locally. Run Mailpit:
docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpitPoint the mailer at it —
cairn.SMTPMailer(cairn.SMTPConfig{Host: "localhost", Port: 1025, TLS: cairn.SMTPNone})
— and read every email, rendered HTML included, at http://localhost:8025.
Without Docker, cairn.LogMailer(nil) logs emails instead of sending them.
Contributors working on Cairn itself get the same thing from the repo:
make dev-up starts Mailpit from dev/compose.yaml, make dev-down stops it.
Run the whole stack locally
Section titled “Run the whole stack locally”To click around a running Cairn instance and this documentation site together,
the repository ships an isolated docker compose stack — a Postgres-backed
Cairn server, Mailpit for email, and these docs served static. From the repo
root:
make stack-up # build + startmake stack-smoke # wait for http://localhost:8080 and http://localhost:8081make stack-down # tear downSee deploy/README.md for the full description, including magic-link testing
through Mailpit. A standing testing environment (Dokploy-hosted,
auto-deployed from main) is configured under deploy/dokploy/ in the
repository.
Poke the API with Postman
Section titled “Poke the API with Postman”The repo ships a Postman collection covering every RPC, generated from the protos
and kept in sync by CI. With the stack running, import
postman/cairn.postman_collection.json and postman/cairn.postman_environment.json,
run the Sign in & provision flow to mint an API key, then call any endpoint.
See postman/README.md.
Related
Section titled “Related”- Getting started — a running app before you productionize it.
- Configuration — every field this guide sets, with defaults.
- Extending the model — running your own migrations after
EnsureSchema. - Hardening — what the rate limiter and constant-time padding defend against.