Decision record
ADR-0004: Storage portability and self-hosting
ADR-0004: Storage portability and what “self-hosting” promises
Status: accepted · 2026-09-16 (rewritten the same day; first version proposed 2026-08-30)
Context
Bladbase is Firebase-native by design (Strategy §8): Firestore for metadata, Cloud Storage for document content, RTDB for presence, Firebase Auth for identity, Cloud Tasks for async work, Typesense for search. That choice is why a small team can run it: no database to operate, no queue to babysit.
It also fixes what “self-hosting” can mean. Today the whole platform deploys to any Firebase project its operator owns: infra/terraform/main.tf stands up every resource, scripts/deploy.sh --target deploys the services against the project named in infra/env/<target>.sh, and two projects already run the one configuration — bladbase (the hosted product) and bladbase-dev (docs/firebase-projects.md, the bootstrap runbook). What does not exist is Bladbase on a customer’s own hardware, on another cloud, or on a Postgres box, because five managed Google services are wired in directly.
The first version of this decision (2026-08-30) kept that door ajar: introduce interfaces at the four non-storage dependencies — identity, async work, presence, search — so a “run-anywhere” edition on Postgres and object storage stayed possible, and say “your own GCP project” in public until it shipped. The review on 2026-09-16 asked for the other answer: do not aim at Postgres at all. Make the project exportable and portable between Firebase projects, so the source can be taken and run in a wholly separate project, and let that be what self-hosting means — with the intent of open-sourcing the code and running a paid hosted service on it.
Two promises were on the table, and they cost very differently:
- Your own Firebase project (what exists): the customer owns the project, the data and the bill. Google runs the primitives.
- Run-anywhere: Bladbase on any Linux host with Postgres and object storage, no Google dependency. Its cost is not the document store — documents are opaque JSON and a Yjs binary, JSONB and
byteaif it ever came to that — but identity (an OIDC abstraction and a self-hosted default), a queue with Cloud Tasks’ retry and dead-letter behaviour (and CLAUDE.md forbids Redis), presence moved in-process, and a Compose-shaped operational surface with its own migrations, backups and upgrades. Roughly a rewrite of everything that is not the editor.
Decision
“Self-hosting” means your own Firebase project. Portability means two things and only two: the code runs on any Firebase project, and a workspace moves between installs whole. There is no run-anywhere edition, and no seams are kept for one.
- The unit of deployment is a Firebase project. One code base, one Terraform configuration, one deploy script with a
--target. The hosted product at bladbase.com is an instance of it; a customer’s project is another; nothing in the code knows which it is running in beyondinfra/env/<target>.sh. The hosted service is never a fork. - Data leaves whole. Two exports, for two needs. The Markdown archive that exists (
export-workspace) is the human-readable guarantee: a tree of files anything can read. A workspace transfer archive — every page with its metadata and identities (shortId,externalRef,adrNumber), revisions, comments, activity, groups, uploads, and the member list as email invitations — is the portability guarantee: another install imports it through the same upsert-by-identity path Phase 16’s folder import uses, and the workspace is the same workspace with the same links. The transfer archive is a milestone of its own; this decision fixes its shape (JSON plus storage objects, identities preserved) so it is built once. - Firebase is the substrate, not a dependency to abstract.
packages/firebasestays the only module that imports a Firebase SDK — as hygiene, so the code stays readable and testable, not as a port seam. No interface is introduced whose only purpose is a second implementation nobody has asked for. TheEntitlementStoreinterface inpackages/domain/src/limits.tsstays because tests supply a map, which is reason enough. - One code base, open, with a paid host. The intent is to open-source the repository (license chosen at launch) and to sell the hosted service: operation, billing, plans and support, running the same code. Open-core — features held back from the source — is rejected: it forks the code base and the trust with it.
- Public copy says “deploy it to your own Firebase project”, never “runs anywhere”. The compare page’s “self-hosting arrives with general release” stays true and stays until the runbooks let a stranger do it (Phase 7).
Assumptions
- The teams Bladbase is for — small engineering teams working with agents — accept a Google Cloud bill of their own; “no Google at all” is not a requirement in this market.
- A small install on Firebase’s pay-as-you-go pricing stays close to free, so “your own project” is not a hidden cost.
- The transfer archive can be built on the export worker and the Phase 16 import path without a new storage design; identities are already stable and already used for upserts.
- Google keeps Firestore, Cloud Storage, RTDB, Firebase Auth and Cloud Tasks available on terms a customer can accept.
Invalidation triggers
- Two prospects in one quarter lost on “must not depend on Google” or on a data-residency need Firebase cannot meet.
- Google deprecates or reprices one of the five primitives so that a port stops being a choice.
- The transfer archive cannot round-trip revisions, comments or attachments — then “moves whole” is a claim, not a property, and this decision has to be read again.
- A Firebase type appearing in
packages/domain— the hygiene boundary has failed and the code is no longer the one code base an operator can read.
Blast radius
- Public copy:
apps/marketing/src/pages/compare.astro, the how-we-build page that renders this record, and every place that says what self-hosting means. - Phase 7 (a stranger deploys from the docs alone), whose scope is now exactly this promise and nothing wider.
packages/firebase/and every import of@bladbase/firebaseinapps/— the boundary this decision keeps.- The export worker and the import path, which the transfer archive will extend.
- Licensing: the choice at launch is downstream of point 4.
Alternatives rejected
- Run-anywhere on Postgres. Pays the identity, queue and operations cost before anyone has asked, and makes two products of one.
- Seams now, second implementation later — the previous version of this record. Abstractions shaped without the implementation that would test them; every one a place for the code to be wrong for no one’s benefit.
- Open-core with hosted-only features. Two code bases in one repository, and a self-hoster who can never be sure what they are missing.
- Markdown export as the whole portability story. Readable anywhere, but a workspace re-imported from it has no revisions, no comments, no identities: a copy, not a move.
Consequences
- Phase 7 stays “a stranger can deploy from the docs alone”, scoped to a Firebase project, achievable with the Terraform and runbooks that exist.
- The transfer archive becomes a named milestone with a fixed shape; the workspace export gains a second format rather than the product gaining a second storage layer.
- No abstraction work is scheduled against Firebase; the JSONB-versus-relational question is closed as moot rather than answered.
- The hosted service and a self-hosted install are the same software, which is what lets a workspace move between them in either direction.
- Marketing may say “your own Firebase project” now and “open source” only when the license is chosen — not before.
Escape plan
This is a one-way door because it declines to keep the other one open. If a port is ever forced, its cost is the four dependencies named in Context — identity, async work, presence, search — behind the one boundary that is kept, packages/firebase; documents themselves move as JSONB and bytea with no schema design. Nothing here makes that harder than it was; it only stops paying for it in advance.
What happened
- 2026-09-16 — Built the same day the decision was accepted: Export for transfer and Import a transfer in Settings (worker jobs, ADR-0004 plan §3–§4). A workspace moved from A to B on one install kept every page at its short id, its links, revisions, comments, a group and a restriction, an upload, and invited its members — the e2e
transfer-flowproves it on every run. Not yet proven: a move across two Firebase projects;docs/runbooks/move-workspace.mdis written and waits forbladbase-devto exist.