Architecture
How a docs directory becomes a bundle
The pipeline is shaped by two decisions: store Markdown rather than HTML, and keep routing out of the stored artifact. Everything below follows from those.
01
The pipeline
CI publishes; the backend ingests; three surfaces read the index. Each stage hands the next a smaller, more structured artifact than it received.
- publish
-
Scan, validate, assemble a manifest, upload the blobs it references, then
write the manifest.
validateandpublishshare one code path, so they cannot drift into disagreeing about what is publishable. - register
- CI tells the backend a revision exists and which channel should point at it. This is the only mutating call in the whole system that CI makes.
- ingest
- The backend reads the manifest from storage, records the revision and its pages, and — only if a channel points at it — reads every page blob and cuts retrieval chunks.
- serve
- The HTTP router, the search collator, and the MCP actions all read the same index. There is no second source of truth for any of them.
02
Two storage systems, split by artifact type
The split is not a deployment convenience. The two kinds of artifact have opposite properties, and forcing either into the other's store makes something expensive.
| Object storage | Postgres | |
|---|---|---|
| Holds | Markdown bodies, images, attachments | Manifests, nav, pages, chunks, channels, entity links |
| Shape | Large, immutable, content-addressed | Small, relational, constantly queried |
| Written by | CI, once per new blob, never again | The backend, on every ingest and channel move |
| Key | blobs/<ab>/<sha256> |
Revision id, bundle id, slug |
Content-addressing is what makes retained history affordable. Every page body and every asset is stored once, under the sha-256 of its bytes, and shared by every revision that references it.
The two-character fan-out prefix in the key is there so that no single prefix accumulates the entire corpus, which matters for listing performance and for backends that shard on key prefix.
03
Revisions are content; channels are routing
A revision is an immutable snapshot, identified by the sha-256
of its manifest. A channel is a mutable named pointer —
latest, 1.x, pr-42. CI publishes a
revision, then repoints a channel at it.
The channel is deliberately absent from the manifest. A manifest describes content; a channel describes where readers are sent. Keeping the two apart is what makes the next three properties fall out for free.
c25e73f7… at commit 4c47e41 is the one the publish
on the home page actually produced; the rest are drawn to illustrate the
arrangement.
Rollback is a pointer move
A bad docs deploy is repaired by pointing latest at the previous
revision. No rebuild, no re-upload, no CI run, and nothing about the bad
revision is destroyed — it stays addressable for anyone who wants to look at
what went wrong.
Release branches dedupe against each other
Map a release branch onto a channel and the two lines of documentation share every page they have in common. Keeping five supported versions online costs five manifests plus the blobs that genuinely differ.
PR previews are throwaway channels
A pull request publishes to pr-42 and never touches
latest. When the branch is deleted the channel goes with it, and
retention reclaims the revisions nothing points at.
04
Indexing discipline
Two rules keep the index from growing in proportion to how often CI runs rather than to how much documentation exists.
Only channel-pointed revisions are chunked. Chunking is the expensive step — it reads every page blob in the revision. A revision that is published and never pointed at costs a few index rows, not a full pass over the corpus.
Only the default channel projects into Backstage Search.
Otherwise the portal search box returns the same page once per supported
version, and the more carefully a team maintains its release branches the
worse their search gets. Other channels stay reachable through the version
picker and through colophon:search with an explicit
channel filter.
Retention closes the loop. Revisions a channel points at are never collected;
beyond those, the most recent revisionsPerChannel per channel are
kept and the rest become eligible for garbage collection.
05
The manifest is written last
Blobs go up first; the manifest goes up only once they are all present. Until the manifest exists the revision is not readable, so a crash halfway through an upload leaves orphaned blobs — which retention will collect — rather than a revision that resolves to a page whose body was never stored.
This is the same reason the revision id excludes the timestamp: a partially uploaded revision that is retried produces the same id and the same keys, so the retry completes the original rather than starting a parallel one.