Skip to main content

ADR-0020: The Single-Binary Runtime — Docs Served From the Cairnd Process

Amended 09/29/2026 by ADR-0031. The decision below that the cairn CLI "stays a separate binary" and that this ADR "deliberately does not merge the CLI" is superseded: the server now lives behind cairn serve in one binary, and the cairnd/cairn split described here (including the cairnd archive and the import-graph CLI gate) is folded away. The docs embedding itself — this ADR's core decision — stands, served by cairn serve. Where this file says cairnd, read "the cairn server (cairn serve)".

Context and Problem Statement​

Cairn's runtime is two artifacts that must be kept in step: the cairnd server container and a separate cairn-docs static container that hosts the Docusaurus bundle behind its own edge-route config. Self-hosting is now a real audience (the guide is live and ghcr.io/stump-wtf/cairn is publicly pullable), and the story a stranger meets first is "one binary, one container, a Postgres and an S3 bucket" — except the docs container breaks it. The question: what is the single cairnd binary responsible for, and what does serving the docs from inside it cost?

Decision Drivers​

  • Two images built and deployed separately drift; the docs a self-hoster reads should describe the version they are actually running.
  • The docs bundle is static output of a Node/Docusaurus build; Go's embed makes in-binary serving tractable.
  • The ghcr.io image contract and the published self-hosting guide must stay true across the change.
  • go build and go test must keep working in environments without Node.

Considered Options​

  • Embed the built docs bundle in the cairnd binary and serve /docs/* from it
  • Keep the separate cairn-docs container (status quo)
  • Serve docs from a CDN / object store rather than the binary

Decision Outcome​

Chosen option: "Embed the built docs bundle in the cairnd binary and serve /docs/* from it", because it collapses the runtime to one artifact whose docs are version-exact by construction, removes an entire deployment surface (image, edge route, compose service), and costs only build-order coupling and a few MB of binary size — both acceptable for a server artifact shipped in a container.

The cairn CLI stays a separate binary. It is a pure REST client (SPEC-0008, ADR-0003) with no server code; folding it into cairnd as a subcommand would couple the Homebrew tap and the CLI release cadence to the server for no gain. This ADR deliberately does not merge the CLI.

Build-time coupling​

The Docker build gains one stage: Docusaurus builds website/ to website/build/ (Node 22, per the site's engines), then the Go build embeds that directory via go:embed behind a build tag (docs). A bare go build without the tag compiles an empty fallback (a stub page explaining the binary was built without docs) so dev loops, go test, and CI's Go-only jobs never require Node. The Dockerfile and the release workflow are the only places the tagged build is produced — one path, wired once.

Version accuracy and binary size​

Embedded docs are built from the same commit as the binary, so "the docs describe this version" holds by construction rather than by deploy discipline. The Docusaurus bundle is a few MB, gzip-friendly, and irrelevant inside a container image that already carries a Go runtime footprint.

Migration path (cloud01)​

The cairn-docs service and its /docs/* edge route are removed from the compose stack once cairnd with embedded docs is deployed; the edge config proxies /docs/* to cairnd:8080 like every other route. Because the bundle was already built with the /docs/ base path for the live site, no URL contract changes. The self-hosting guide and docker-compose.prod.yml are updated in the same PR that lands the serving code. [2026-10-17: the repo-root docker-compose.prod.yml and DEPLOY.md were retired in #362; the self-hosting guide's own compose.yaml is the deploy surface, and its variables are what the sign-in docs now have to keep in step.]

Consequences​

  • Good, because the runtime is one binary and one container: "a Postgres, an S3 bucket, and cairnd".
  • Good, because docs can never drift from the running version.
  • Good, because the self-host compose file and the edge config both shrink.
  • Bad, because a docs change now requires a server rebuild and redeploy (the GitHub Pages twin, built independently, still publishes from main for public discoverability — ADR-0014's "adding an ADR publishes it" survives there).
  • Bad, because the build now has an ordered Node-then-Go dependency, with an escape hatch (the docs build tag) that must not silently rot.

Confirmation​

SPEC-0015 scenarios: the binary serves /docs/ page content (asserted on body text, never status codes — the bundle is an SPA that returns 200 for every path); an untagged build serves the stub; the public image contract keeps pulling and working. The cloud01 converge shows cairn-docs gone and the live guide rendering from cairnd.

Pros and Cons of the Options​

Embed in the binary​

go:embed of the Docusaurus output behind a build tag; cairnd serves it.

  • Good, because one artifact, version-exact docs, no extra deployment surface.
  • Good, because self-hosters get the guide at the same origin they point their CLI at — no cross-origin storytelling.
  • Bad, because docs-only fixes ride a server release.
  • Bad, because the Go build depends on a prior Node build in the packaged path.

Status quo: separate cairn-docs container​

  • Good, because docs and server deploy independently.
  • Bad, because two images drift, and the self-host story is two services for one product.
  • Bad, because a self-hoster can run docs for a version they are not running.

CDN / object store​

  • Good, because the binary stays small and docs deploy independently.
  • Bad, because it adds a third-party dependency to the minimal self-host path — the exact opposite of the convergence this ADR exists for.
  • Bad, because version-exactness needs the same build-time coupling anyway.

Architecture Diagram​

graph TD
subgraph "cloud01 edge"
CADDY[Caddy edge] -->|/ →| CAIRND
CADDY -->|/docs/* →| CAIRND
end
subgraph "cairnd binary"
API[REST /v1 API]
MCP[MCP endpoint]
UI[web app shell]
DOCS[embedded /docs bundle]
end
CAIRND --> PG[(Postgres)]
CAIRND --> S3[(S3 / Garage)]
subgraph "build, once per commit"
NODE[Node: docusaurus build] --> BUILD[website/build]
BUILD -->|go build -tags docs| CAIRND
end
CLI[cairn CLI — separate binary] -->|REST| API

More Information​

  • stump.wtf/cairn#246 — the epic this ADR answers. Not linked: the issue tracker is a repository host, which SPEC-0010 REQ "No Repository Links" excludes from the rendered record.
  • ADR-0014 (the public site is the design record) still governs content; this ADR changes only delivery of the self-host copy. The Pages deployment keeps its own pipeline and least-privilege posture.
  • SPEC-0008 keeps the CLI a separate artifact; no tap changes.