31. Fossil as bundled infrastructure, built from source
Date: 2026-10-03
Status
Accepted. The repository is seeded on start and the webapi reads it through a read-only proxy; nothing writes it yet.
Context
Fossil is a distributed version control system whose one self-contained binary is also its server: repositories, a web UI (timeline, files, diffs), a wiki, tickets and a forum, plus the HTTP sync endpoint that fossil clone and fossil sync talk to. A repository is a single SQLite file. That makes it a cheap thing to stand up next to CouchDB and Garage: one container, one directory of state, no database of its own to run.
We want it available in the bundled stack now, so later work can version things (projects touched by sessions, artifacts derived from them) without first having to solve deployment. Two facts shape how:
- Fossil publishes no container image. It ships a
Dockerfilein its source tree (containers.md) and release tarballs. So the mirror-an-upstream-image route of ADR 0024 has nothing to mirror. - Every deploy shape must still work with no registry.
--upstream(dev) andinstallstart the stack from a bare machine; the Kubernetes base (ADR 0030) pulls by name.
Decision
- A
fossilbacking service in the app model, beside CouchDB and Garage, so compose, the Kubernetes base, the env schema, the installer's port block (FOSSIL_PORT, default 7658), the services menu and the architecture diagram all project from one entry. No feature flag. The diagram draws only what exists: the webapi's read edge (decision 7), and no write edge, because nothing writes it yet. - Built from the official release source, by
deploy/fossil/Dockerfile: upstream's own recipe (a static musl binary onscratch), pinned to a release tarball and verified by sha256 instead of tracking trunk. The model'sdefaultTagand the Dockerfile'sFOSSIL_VERSIONare held equal by a test. - A new
image.buildfield marks "we build this, there is no upstream image". The projections treat it as a third kind of image alongside mirrored and our own: - the base compose file and the Kubernetes base pull
claude-transcripts-fossil:<version>from the registry, like any mirrored image; mirror-imagesbuilds and pushes it under that name (toImageBuildPlan);- the upstream override builds it locally, from a build context that needs nothing outside
deploy/fossil/— whichinstallships — so the no-registry paths still need no registry. - Repositories named in config, seeded by the container on start. One today:
fossil.repositoriesinconfig/is a keyed map likecouchdb.databasesands3.buckets(defaultsessions: claude-transcripts-sessions, always merged in, as the Meilisearch indexes are) and lands in the model'sstores; a name Fossil can't serve fails the model at load. Fossil has no HTTP call that creates a repository, so the seed runs where the binary and the volume are: the image's entrypoint creates each repository named inFOSSIL_REPOSITORIESthat doesn't exist yet (fossil new, as an idempotent step like CouchDB creating its admin from env), then execsfossil server --repolist /museum. The runners pass that variable from the live config atuptime (toStoreEnv), so renaming a repository needs no regeneration. Not upstream's--create, which makes a generic repository with a printed admin password. The image carries one static busybox for the entrypoint script, as upstream's container docs suggest when a shell is needed. - Root in the container, for now. Unlike upstream's image it runs as root, because the state directory is a bind mount or volume owned by whoever started the stack; Fossil's own jail drops each request's privileges to the owner of the directory it serves whenever that owner isn't root. Most of the other bundled services also start as root. Running every service as the stack owner's UID (
PUID/PGID) is tracked separately. - No login to read; no anonymous writes. In the spirit of ADR 0020, the seed lets Fossil's built-in
nobodyuser browse, clone and download (ghjorz) with no login. It is not given write capabilities, although that was the first plan: Fossil's JSON API takes its parameters from the query string, so with write rights a plain GET changes the repository, and any web page the user visits can send a GET to a localhost port — an<img>tag is enough (CSRF, and DNS rebinding besides). CouchDB has an admin password and Meilisearch writes need a JSON POST, so Fossil would have been the one bundled service a drive-by page could write to. A repository still needs one real user,claude-transcripts; its generated password is discarded, andfossil user passwordsets one when an admin needs the UI. Who writes, and how it authenticates, comes with the first writer (#210). Single sign-on with CouchDB and Garage comes later; Fossil supports it throughREMOTE_USERwhen run as CGI/SCGI behind a proxy. - Read through the gateway (ADR 0016), like CouchDB and S3:
/api/fossil/<repoKey>/json/...proxies Fossil's JSON API, which the image is built with (--json). Fossil's own port stays published on the host (7658), as CouchDB's, Garage's and Meilisearch's are. The proxy is an allowlist of read-only commands, not a GET filter: the JSON API reads parameters from the query string, so a GET can write (/json/user/save?…grants setup rights), and a bare/json?command=…dispatches to any command.
No Fossil CLI on the client side. Clients that ever need to reach a repository do it over HTTP, through the web UI or a future gateway route; nothing installs fossil on the user's machine.
Consequences
- Fossil's JSON API is documented upstream as unfinished; a release may change it, and the allowlist is reviewed on each Fossil bump.
- One more container and one more port in every bundled deploy. An existing install's
FOSSIL_PORTis the next port after its own block; if that's taken — the old 8-port block put a second instance's block exactly there —installmoves it to the next free port. - A Fossil upgrade is a reviewed change to three values in one Dockerfile plus the model's tag, not a moving
latest. - The first
--upstreamstart compiles Fossil (about a minute); later starts reuse the local image. - A seeded repository is not quite empty:
fossil newalways records an initial empty check-in. - Renaming a repository in config seeds a new one; the old file stays in the data directory, served, until someone removes it (#211).
- The seed's contents beyond an empty, open repository — users and roles, wiki, ticket schema, tags, settings — are a placeholder (
seed_content) until #210 defines them. - Kubernetes reads
FOSSIL_REPOSITORIESfrom the instance Secret like every other${VAR:-default}reference, so an existing.envneeds the new key.