Decision record

ADR-0004: Storage portability and self-hosting

Written by

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:

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.

  1. 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 beyond infra/env/<target>.sh. The hosted service is never a fork.
  2. 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.
  3. Firebase is the substrate, not a dependency to abstract. packages/firebase stays 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. The EntitlementStore interface in packages/domain/src/limits.ts stays because tests supply a map, which is reason enough.
  4. 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.
  5. 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

Invalidation triggers

Blast radius

Alternatives rejected

Consequences

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

References

Written and maintained by , who made this decision — about · GitHub.

← All decisions