ADR-0020: The Single-Binary Runtime — Docs Served From the Cairnd Process
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
embedmakes in-binary serving tractable. - The
ghcr.ioimage contract and the published self-hosting guide must stay true across the change. go buildandgo testmust keep working in environments without Node.
Considered Options
- Embed the built docs bundle in the
cairndbinary and serve/docs/*from it - Keep the separate
cairn-docscontainer (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.
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
mainfor 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
docsbuild 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.