DocsOperate
#Portability — the move off Cloudflare
Status: the single-venture move is built; the multi-tenant layer is not. The phase-3 crates exist and are published — cratefield-runtime-native (axum on tokio, Redis RateLimiter, in-process cron; crates/runtime-native/), cratefield-adapter-postgres (sqlx; crates/adapter-postgres/), and fz data export / fz data import (crates/cli/src/data.rs, issue #21). So the sections below — stand up Postgres, copy the data, switch the runtime line, cut DNS — are a venture runbook you can run today.
What is genuinely still unbuilt is the multi-tenant layer of ADR 0008: crates/runtime-native/ has no tenant identity today (one process serves one venture — see SECURITY.md, "Native runtime"), and the control database with the tenant registry and global secret store does not exist (issues #23–#44). Nothing below promises it.
#Why the move is supposed to be cheap
Three decisions made on day one exist so that this page stays short:
- Modules only see ports (ADR 0002). A module has never touched a Cloudflare binding, so there is nothing to un-teach it. Swapping the runtime swaps the adapters behind the same traits.
- Compile-time composition (ADR 0003). A venture backend is one
Cargo.toml+ onesrc/lib.rs; changing the.runtime(..)line and the adapter dependencies is the whole edit. - Portable SQL (ADR 0004). Migrations are written in the subset both SQLite and Postgres accept; queries are sea-query trees that render per dialect.
fz doctoralready lints the subset today.
What exists today toward this: the cratefield-adapter-sqlite Database (native rusqlite), sea-query's dual-builder rendering behind Statement::render, the portable-SQL lint, the conformance suite that applies every module's migrations to a fresh SQLite database twice, cratefield-adapter-postgres (sqlx), cratefield-runtime-native (axum on tokio, Redis RateLimiter, in-process cron), and fz data export / fz data import. Still unbuilt: the control database and the rest of the multi-tenant layer (issues #23–#44).
#The move, in order
Architecture section 10, expanded. Per venture:
- Stand up Postgres for the venture and run the same module migrations — the postgres set. Modules that needed dialect-specific SQL ship
migrations/postgres/NNNN_<name>.sqloverrides; everything else reuses the sqlite-authored file content through the Postgres migrator incratefield-adapter-postgres. Migration history is per-tenant (see below).
- Copy the data:
fz data exportreads the venture's D1 andfz data importwrites the Postgres database. ULID ids and ISO-8601 timestamps are engine-neutral by design, so rows move verbatim.
- Switch the runtime in the venture's composition,
src/lib.rs— the one-line diff the architecture promises:
// before
.runtime(Cloudflare::new().db("DB").mailer(Resend::from_env()).captcha(Turnstile::from_env()))
// after
.runtime(Native::new().db(Postgres::from_env()).rate_limiter(Redis::from_env()))Resend and Turnstile stay: they are adapters over the runtime-neutral HttpClient port, so the mailer and captcha move with you unchanged. Build a native binary. The Dockerfile to ship is examples/venture-native/Dockerfile (distroless, the binary and nothing else); the forge build image (docker/Dockerfile) is for compiling, not a runtime image.
- Point
api.<domain>at the new host. DNS is the cutover. The Worker and the D1 database still exist and still answer, so a bad cutover is a DNS revert, not a restore.
No module code changes. That claim is what the parity suite protects: cratefield-testing runs module suites against SQLite and Postgres in CI (.github/workflows/parity.yml; CI's workspace cargo test runs every module with FZ_TEST_POSTGRES_URL set), so "works on D1" and "works on Postgres" cannot drift apart between releases.
#The native runtime is multi-tenant — one database per tenant
ADR 0008, because it changes what a venture's ops team inherits on the self-hosted path:
- One database per tenant. Never one schema per tenant, never shared tables. Migration history, the tenant's secrets and its audit log live inside the tenant's own database, so a venture can be backed up, restored, moved or dropped as one unit. Isolation is the database boundary — no
search_pathswitching, no row-level security. Factory Zero's own venture is an ordinary tenant. - A separate control database. A small, low-traffic database that no venture role has credentials for holds the tenant registry and the global secrets store. Its connection string is the one value that comes from the environment; every tenant's connection string is a global secret resolved from it.
- Two-tier secrets. Global secrets (tenant connection strings, platform keys) live in the control database; tenant secrets (
HARNESS_SECRET,RESEND_API_KEY, …) live in that tenant's database. Each store has its own data key wrapped by a KMS master key. Modules can only ever obtain a tenant store; the global store is unreachable from module code, enforced by both the API and by credentials.
The Cloudflare path is unchanged by all of this: one Worker, one D1, one venture, as ADR 0003 says. If a native-runtime host grows past the tenant count where one pool per tenant stops being fine (issue #32), the answer is one process per tenant — the Worker shape — so the two paths converge instead of forking.
#What a venture should do today to keep the move free
Nothing extra — the guardrails are already the rules:
- keep module queries on sea-query and migrations in the portable subset (
fz doctorfails anything else); - keep ventures on published, pinned
cratefield-*versions so the native-runtime crates arrive as ordinary dependency bumps; - treat
HARNESS_API/ core major bumps as the compatibility contract (see COMPATIBILITY.md) — a module that compiles against the contract today must compile against the native runtime when it ships.
Mirrored from docs/PORTABILITY.md at b6f50cd. The harness is the source of truth: a correction goes there first.