Skip to content

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.

Terminal window
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.

Terminal window
# sqlite anywhere in the deployment: cgo stays on
CGO_ENABLED=1 go build ./...
# Postgres-only: a fully static binary
CGO_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-alpine with 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.

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.

Cairn mounts two probe endpoints on every server:

  • /healthz is liveness: 200 {"status": "ok"} whenever the process is alive and serving HTTP. It performs no downstream checks.
  • /readyz is readiness: it runs every registered check on each request and returns 200 {"status": "ready"} when all pass, or 503 {"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.

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.

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.

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).

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.

Magic-link sign-in needs somewhere to deliver email locally. Run Mailpit:

Terminal window
docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit

Point 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.

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:

Terminal window
make stack-up # build + start
make stack-smoke # wait for http://localhost:8080 and http://localhost:8081
make stack-down # tear down

See 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.

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.