DocsStart
#Venture guide — from fz init to production
Every Factory Zero venture gets its own backend: its own Worker, its own D1 database, its own secrets, its own domain. This guide is the ordered path from fz init to a production API on api.<your-domain>: the manifest describes the venture, fz build generates the crate from it, and every step after that is ordinary Cargo and wrangler.
Which steps need a human. Everything that touches a Cloudflare dashboard, a Resend dashboard, DNS, or a production secret is a human step — see the table in Who does what. Everything else is one command or runs in CI.
Commands in this guide were executed against examples/venture/ — the smallest complete venture in this repository — with output pasted from real runs. Commands that need Cloudflare or Resend credentials were not run in CI-like conditions here; they are marked human. The rest are executed by tools/doc-commands.sh, including the fz init / fz add opening of step 1, which runs in a fresh temporary directory. Substitute your venture's name for venture-example and your domain for example.factory0.dev.
#0. Prerequisites
- stable Rust (the repo pins the toolchain in
rust-toolchain.toml), plus the wasm target:
rustup target add wasm32-unknown-unknown
cargo install --locked worker-build- wrangler via bun:
bunx wrangler --version(this guide was run with wrangler 4.129.0; any wrangler v4+ works). - a GitHub account with access to the repository your venture lives in (deploys are GitHub Actions workflows).
#1. Create the venture
A venture starts as a manifest. fz init writes one — name and host, no modules yet — and fz add appends a module to it. Both touch the manifest and nothing else. This is run for real in CI, in a fresh directory, by tools/doc-commands.sh:
fz init --name venture-example --host venture-example.factory0.dev
fz add waitlistfz init writes exactly one file, venture.json: the name, the host, cors_origins seeded with the host's own origin, and an empty module list. fz add knows every optional module the catalog ships — the same list fz build composes from — and refuses an unknown slug, naming the ones that exist. A module reference is a bare slug or an object carrying that module's config; both forms appear in the real manifest crates/manifest/examples/acme-signups.json.
The manifest is the one file a venture edits:
{
"name": "venture-example",
"host": "venture-example.factory0.dev",
"cors_origins": ["https://venture-example.factory0.dev"],
"modules": [
"waitlist",
{ "slug": "email-signup" }
]
}Then generate the crate:
# --out defaults to dist; --harness-path ../.. points the generated
# Cargo.toml at this workspace instead of the published cratefield
fz build venture.json --out ventures/<venture> --harness-path ../..fz build writes the crate and nothing else:
venture.json the manifest — the only file a venture edits ← edit this
Cargo.toml GENERATED — cratefield, worker, and the venture's own fz bin
src/lib.rs GENERATED — the harness() composition and the fetch/scheduled events
src/fz_main.rs GENERATED — the venture-linked fz: migrations collect, doctor
wrangler.toml GENERATED — worker-build --release, the DB binding, migrations/
provenance.json GENERATED — module pins, composition hash, who built what when
src/tables.rs GENERATED — only when the manifest declares tables
seed.sql GENERATED — only when the manifest sets seed_sqlEvery generated source file opens with a GENERATED by fz build header, and the deal is the one the header states: when the manifest changes, re-run fz build instead of editing them. The build never writes migrations/ — those come from fz migrations collect (step 4). Two honest footnotes: wrangler.toml is regenerated too, so the account-specific lines added to it in step 3 are re-applied after a rebuild; and --harness-path is an in-this-repository convenience — leave it off and the generated crate depends on the published cratefield from crates.io.
Human. Add the venture to the workspace members in the root Cargo.toml — ventures/* is deliberately not globbed, so the new directory is invisible to cargo until you list it beside ventures/cratefield-waitlist (ADR 0013).
examples/tables-canary is the pipeline above, run for real and committed: a generated venture, regenerated with cargo run -p cratefield-manifest --example write-venture -- examples/tables-canary/venture.json examples/tables-canary and byte-compared against the generator by crates/manifest/tests/canary_is_current.rs. CI compiles it as a workspace member, and examples/tables-canary/tests/boots.rs composes its generated harness() — so the generator cannot drift from what a venture looks like without a red build.
(ventures/cratefield-waitlist is older than the generator: hand-written, composing in src/lib.rs. It stays in the workspace and builds, but new ventures follow the manifest path above.)
What catches a bad composition. Harness::build() — the call behind fz migrations collect, fz doctor, and the first request the Worker serves — refuses a module requiring a port the runtime does not provide, two modules claiming one table, duplicate module names, unknown or duplicated port declarations, a harness_api mismatch, and a production venture that is not production-ready. A generated composition ends in .expect("generated venture harness is valid"), so a bad manifest is a panic at the first compose — and examples/tables-canary/tests/boots.rs performs that compose under cargo test --workspace, which turns it into a red CI build instead of a production surprise.
fz doctor does not re-check any of that: by the time it runs, Harness::build() has already succeeded by construction. Its checks are the deployment-level rules on a harness that did compose — migration-lockfile consistency, the production captcha rule, push wiring, sidecar mounts, and the portable-SQL and card-data lints.
#2. Run it locally
From the venture directory (the runs below are against examples/venture, the hand-written smallest venture):
$ cargo test # compiles the venture and runs its testswrangler dev compiles the Worker to wasm (via worker-build, declared in wrangler.toml's [build]) and serves it on a local workerd with a local D1. Migrations first, then dev:
$ bunx wrangler d1 migrations apply <database-name> --local
$ bunx wrangler dev --local --port 8792Set HARNESS_SECRET for local dev. Modules with signed links (the signup and waitlist confirm flows) require the Signer port, which the runtime wires from HARNESS_SECRET. Without it, those endpoints answer 500 internal (see the run below). Put a generated dev value in .dev.vars (git-ignored; never a real secret):
$ printf 'HARNESS_SECRET=%s\n' "$(openssl rand -hex 32)" > .dev.varsReal run against examples/venture (venture name venture-example, modules sample, email-signup, waitlist):
$ bunx wrangler d1 migrations apply venture-example --local
🌀 Executing on local database venture-example (local-venture-example) from .wrangler/state/v3/d1:
🚣 2 commands executed successfully.
┌─────────────────────────────────┬────────┐
│ name │ status │
├─────────────────────────────────┼────────┤
│ 0001_sample_0001_init.sql │ ✅ │
├─────────────────────────────────┼────────┤
│ 0002_email-signup_0001_init.sql │ ✅ │
├─────────────────────────────────┼────────┤
│ 0003_waitlist_0001_init.sql │ ✅ │
└─────────────────────────────────┴────────┘(The .sql files in migrations/ are written by fz migrations collect — step 4 — and committed. The example ships them pre-collected.)
Then, after ~40 s of first-compile:
$ curl -fsS http://127.0.0.1:8792/__health
{"captcha":"absent","env":"development","harness_api":1,"harness_build":null,
"mailer":"configured","mailers":[{"configured":false,"name":"resend"}],
"modules":[{"emits":[],"name":"sample","version":"0.1.0"},
{"emits":["email-signup.confirmed","email-signup.unsubscribed"],"name":"email-signup","version":"0.1.0"},
{"emits":["waitlist.joined","waitlist.confirmed"],"name":"waitlist","version":"0.1.0"}],
"venture":"venture-example"}
$ curl -fsS http://127.0.0.1:8792/__ready
{"ok":true}The two pasted bodies are illustrative rather than a literal capture — the mailer key is port presence and mailers is the adapter's own verdict, and tools/doc-commands.sh checks only the $ lines above, so keep both in step with examples/venture/src/lib.rs by hand.
Every response echoes x-request-id (a ULID unless the client sent a valid one); errors are RFC 9457 application/problem+json with that id as instance.
Degraded mail is easy to miss — look in the logs. This example wires the Resend mailer with no API key on purpose — the port exists, nothing is ever sent. A join nevertheless answers the documented success, because the confirmation mail is deferred past the response and the answer must not vary with the mailer's state (real output):
$ curl -sS -X POST http://127.0.0.1:8792/v1/waitlist \
-H 'content-type: application/json' \
-d '{"email":"[email protected]","product":"kontinuum"}'
{"ok":true}The row is there as pending and the only trace of the missing mailer is an ERROR line in the dev server's log — check there before concluding that mail works. If you want to watch 503 mail-not-configured on the wire, call an endpoint that cannot defer: the orgs invitation route, POST /v1/orgs/{org_id}/invitations, answers 503 when the mailer is not configured.
Separately: without HARNESS_SECRET in .dev.vars this same call is a 500 internal — that is the signer port being missing, not the mailer, and it is your cue that the secret is missing.
#3. Create the databases
Human (Cloudflare dashboard or authenticated wrangler). One D1 per environment; this guide names them <venture>-api-staging and <venture>-api:
wrangler d1 create <venture>-api-staging
wrangler d1 create <venture>-apiPaste the returned database_id for each environment into the matching [[d1_databases]] block of wrangler.toml. The example's local stand-in uses a placeholder id:
[[d1_databases]]
binding = "DB"
database_name = "venture-example"
database_id = "local-venture-example"
migrations_dir = "migrations"#4. Collect and apply migrations
From the venture directory — fz build gives every generated venture an fz bin target (src/fz_main.rs), so this runs where the manifest is (see the cratefield-cli README):
cargo run --bin fz -- migrations collect # writes migrations/ + .harness-lock.json, commit them
cargo run --bin fz -- doctor # portable-SQL + captcha + contract checkscollect never renumbers: .harness-lock.json pins each module migration to its global file, so adding a module later appends. From the repository root rather than the venture directory, name the package — several crates define a bin called fz, so a bare --bin fz is ambiguous there: cargo run -p <venture> --bin fz -- doctor. Then apply, per environment:
# local (ran for real above)
wrangler d1 migrations apply <database-name> --local
# staging / production (human, needs wrangler auth)
wrangler d1 migrations apply <venture>-api-staging --remote --env staging
wrangler d1 migrations apply <venture>-api --remote --env production#4b. If you mount a sidecar: two repositories, one database
A sidecar module owns tables in the same database as its host but ships its migrations from its own repository, so two independent streams apply to one database. They coexist — wrangler records file names in d1_migrations and tolerates rows it did not write, so the sidecar repository's numbering restarting at 0001 is not a problem. The full measurement is in MIGRATION-STREAMS.md; this is the recipe.
Apply the host's stream first, then the sidecar's, and never in parallel. Order between the two matters only if one depends on the other's tables (it should not — a module owns its own). Parallel is the part that actually breaks: run both at once and one of them dies, loudly.
# 1. host repo
cargo run --bin fz -- migrations collect
wrangler d1 migrations apply <venture>-api --remote --env production
# 2. sidecar repo, same database, after the host finishes
cargo run --bin fz -- migrations collect
wrangler d1 migrations apply <venture>-api --remote --env productiond1_migrations then holds both streams, ordered by when they ran rather than by the numbers in their names:
id name
1 0001_email-signup_0001_init.sql (host)
2 0002_waitlist_0001_init.sql (host)
3 0001_acme-pricing_0001_init.sql (sidecar)Three things to know before you do this.
A name that already exists is skipped and reported as success. If the sidecar ships a file whose name the host has already applied, wrangler says ✅ No migrations to apply! and the SQL never runs. Nothing warns. Collected names embed the module, and a module lives in exactly one stream, so this needs two repositories claiming the same module name — which the host now catches at runtime: a mounted sidecar whose tables clash with a compiled-in module's shows up in /__health as "probe": "table-collision" naming both the table and the module to rename.
Moving a module between mounts re-applies its migrations. The NNNN prefix is the repository's counter, so the same migration arrives under a new name in the adopting repository and wrangler has never seen that name:
sidecar: 0001_acme-pricing_0001_init.sql (applied)
host: 0003_acme-pricing_0001_init.sql (looks new)CREATE TABLE IF NOT EXISTS hides it; an ALTER, an index, or seed data does not — the re-run fails the deploy. The fix needs no code: before collecting in the adopting repository, hand-edit .harness-lock.json so the module's entry names the file the other stream already used. That one line is the whole difference between a clean move and a re-applied migration.
A failure stops the run and records nothing. The migration that errored is not written to d1_migrations and no file after it runs, so re-running retries from the failed file. Forward-only, nothing half-recorded.
#5. Turnstile (captcha) — if you have public writes
Human (Cloudflare dashboard). The signup and waitlist modules have public write endpoints declared as human-form writes, so production needs captcha (fz doctor fails a production venture without it). A venture whose public writes are signed-link writes instead needs a Signer — HARNESS_SECRET — and no Turnstile; the full rule including both escape hatches is ARCHITECTURE.md section 6. Create a Turnstile widget for the venture's domains; the sitekey goes to the frontend, the secret key becomes TURNSTILE_SECRET.
#5b. Rate limiting — declare it, then mount it (issue #562)
The modules you compose declare the RateLimiter port (the signup and waitlist modules, the auth methods, telemetry), and a module declaring it is a claim that its public routes are throttled — public reads included, not just writes: a search endpoint is enumeration surface all the same. Production holds the venture to that claim. With no limiter mounted (or a binding that never resolves), the build refuses and the error names the module and its routes — waitlist (POST /v1/waitlist, GET /v1/waitlist/search) — so you fix the named thing.
Mount one on the runtime you serve with:
- Workers: the Rate Limiting binding —
.rate_limiter("RATE_LIMITER")in composition plus the[[ratelimits]]stanza inwrangler.toml(thecratefield-waitlistventure is a working example) — or, for per-key budgets (per plan, per address), the D1-backed limiter.d1_rate_limiter(..)(issue #538). - Native (self-hosted):
.rate_limiter_arc(..)— aRedisRateLimitercomes with the runtime.
A binding named on the Worker but missing from the deployment (wrong name, wrong type, dropped from wrangler.toml) does not degrade to serving unlimited: /v1/* answers 503 not-production-ready naming the binding, in every environment, until the deployment and the composition agree again. That refusal is not lifted by HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES=<reason> — the waiver governs the readiness gate only, so the escape from the 503 is to make the deployment match the composition (or drop the binding from it and rebuild). If you truly want the readiness gate to allow serving without a budget, say so with the waiver — a recorded decision, logged at boot, that a blank reason does not satisfy.
#6. Secrets, per environment
Human for production; local development uses .dev.vars (step 2). The four required secrets, plus two optional ones that shape the signer's key ring (issue #137, ADR 0014; architecture section 9):
| Secret | Used by | Set with |
|---|---|---|
HARNESS_SECRET | Signer (confirm/unsubscribe links; ≥ 32 bytes) | wrangler secret put HARNESS_SECRET |
RESEND_API_KEY | Mailer (absent → NotConfigured, 503 on send paths) | wrangler secret put RESEND_API_KEY |
TURNSTILE_SECRET | Captcha (absent → port not provided) | wrangler secret put TURNSTILE_SECRET |
ADMIN_TOKEN | admin endpoints (unset → disabled entirely) | wrangler secret put ADMIN_TOKEN |
HARNESS_SECRET_PREVIOUS | Signer: the demoted key, verification-only (≥ 32 bytes) | wrangler secret put HARNESS_SECRET_PREVIOUS |
HARNESS_SECRET_REVOKED | Signer: key ids whose signatures are refused even while configured (comma-separated) | wrangler secret put HARNESS_SECRET_REVOKED |
HARNESS_VENTURE | Signer: venture label stamped into tokens (iss) so links never cross ventures or environments | plain var (not secret — a name, not a key) |
Production is set by a person with wrangler secret put <NAME> --env production. For local development use .dev.vars (step 2). Rotating HARNESS_SECRET: set HARNESS_SECRET_PREVIOUS to the old value, roll the new one, drop PREVIOUS once no live bounded token can still carry it (confirm tokens expire after 7 days, status after 90 — mint-time ceilings, ADR 0014) — tokens name their key id, and the ring still verifies them, so links in flight keep working. A compromised key is revoked by id through HARNESS_SECRET_REVOKED, which refuses its signatures even while its secret stays configured. The full runbook, including what revocation costs an in-flight link, is docs/KEY-ROTATION.md.
#6b. Push secrets, if the venture sends notifications
Each transport's secrets are all-or-none and are listed in docs/PUSH-ENV.md, generated from the one table every caller reads. fz doctor reports which transports this deployment routes and fails a production deploy that half-wires one (issue #191).
Web Push needs a P-256 key pair the venture generates once and keeps. Key generation is local — it runs in CI — but putting it into Workers is a human step (wrangler auth):
fz push vapid keygen --file vapid.key # prints the public applicationServerKey# human: needs wrangler auth against your Cloudflare account
wrangler secret put VAPID_PRIVATE_KEY --env production < vapid.key
wrangler secret put VAPID_SUBJECT --env production # mailto: or https: contact URIRotating that key invalidates every existing browser subscription and no server can recreate one — each browser has to subscribe again — so fz push vapid keygen refuses to overwrite an existing key file without --force. APNs (a .p8, its key id, the team id, the bundle id) and FCM (the service-account JSON, whole) are wrangler secret put like the rest. fz push send sends one notification through whatever this environment configures, and fz push inspect-subscription checks a browser subscription and prints the aud the adapter will sign for it.
The send needs an fz built with the push-send feature — a separately installed binary (cargo install cratefield-cli --features push-send), or the Docker image, which carries it. Never add that feature to the venture's own cratefield-cli dependency: it pulls the native runtime, which does not compile to wasm, into the crate that builds the Worker.
#7. Mail domain (Resend)
Human (Resend dashboard). Verify a sending subdomain send.<your-domain> — never the apex, which carries inbound email routing MX (architecture section 9). Until the domain is verified, real sends fail; with no RESEND_API_KEY the adapter reports NotConfigured. The orgs invitation endpoints turn that into 503 mail-not-configured so forms can show a direct address meanwhile; the waitlist does not — it logs the same condition and answers 202 anyway (both seen in step 2).
Owlpost is the same step, a different provider. Switching a venture is changing the adapter crate — the facade feature (resend → owlpost) and the mailer line in src/lib.rs — and two secrets: OWLPOST_API_KEY, and optionally OWLPOST_BASE_URL for a self-hosted or proxy deployment (default https://api.owlpost.to). Owlpost is Resend-compatible, so the verified-subdomain rule and the degraded NotConfigured behavior are unchanged: the orgs invitation endpoints answer 503 mail-not-configured, the waitlist logs it. Use an op_test_… key in development (it never reaches a provider) and the venture's op_live_… key in production: wrangler secret put OWLPOST_API_KEY.
#7b. Mail in the venture's own style
Agent. Every module that sends mail renders through cratefield-mail-templates (ADR 0030). Give the venture a MailTheme built from its website's CSS tokens and logo, and compose it into each mail-sending module instead of its default_templates() — .templates(cratefield::waitlist::themed_templates(&mail_theme())), and the same for every other module that sends mail.
The logo is a PNG the website hosts at twice its display size (/assets/email/logo-64.png for the default 32×32). A deployment can adjust the composed theme without a rebuild with MAIL_THEME, a JSON object of any subset of the fields ({"light":{"button":"#123456"}}). With neither, mail renders in a neutral theme built from the venture's name, public URL and core Brand. Preview every mail in the theme with tools/render-emails --theme theme.json --screenshots in the harness repository.
#8. Custom domain
Human (DNS + Cloudflare). Point api.<your-domain> at the Worker — a routes/custom-domain entry in wrangler.toml or the dashboard. public_url and cors_origins come from the manifest, so change them in venture.json and re-run fz build — and they must list the real origin: the harness rejects a wildcard CORS origin outright.
A venture whose client is a browser extension lists that extension's origin the same way (issue #579). Three schemes are accepted, each as an exact-match origin — no wildcard, port, path or trailing slash:
chrome-extension://<id>, the id exactly 32 characters, eacha–p;moz-extension://<uuid>, a canonical lowercase RFC 4122 UUID (8-4-4-4-12); Firefox emits lowercase;safari-web-extension://<uuid>, the same UUID shape but accepting uppercase hex too, because Safari emits it.
Any other hyphenated scheme is still rejected. CORS credentials stay off (bearer tokens only), which is what an extension driving the PKCE flow wants: it holds an auth-client token and the browser never attaches a cookie it could be tricked into lending out. The login redirect Chrome's launchWebAuthFlow lands on is a plain https URL, https://<extension-id>.chromiumapp.org/, registered as a client redirect URI — see crates/auth-core/src/redirect_uri.rs.
#8b. Add the forms to your site
With cratefield-ui mounted, every module's forms are served by the API and your site writes no form code. examples/venture mounts it in src/lib.rs (.ui(Ui::from_spec(include_str!("../ui.json")))); the generator does not emit a UI mount today, so that line is a hand edit — one a rebuild overwrites, like any change to a generated file. Two ways in:
- Embed. One script tag and one element per form:
<script type="module" src="https://api.<your-domain>/ui/cf.js"></script>
<cf-form module="waitlist" action="join" product="<slug>"></cf-form>
<cf-form module="email-signup" action="subscribe" source="footer"></cf-form>Your site's origin must be in cors_origins: the embed fetches the form from the API. Attributes that name a field pre-fill and hide it.
- Link.
https://api.<your-domain>/ui/waitlist/joinis a complete page; the confirm and status links in the emails already land on/uipages.
Styling contract. The markup is fixed cf-* classes and data-cf-* attributes, no shadow DOM. The base stylesheet (/ui/cf.css) sits in @layer cf and is written entirely against --cf-* custom properties, so any unlayered rule on your site wins on its own: set the properties on the container that holds the form, add rules against the classes, and never reach for !important. examples/venture/site/ shows one card with the default look next to one restyled with properties and plain rules; the base stylesheet's dark scheme applies only to the harness's own pages, so an embed keeps your site's colours under a dark OS. Copy, field order and theme tokens that should live with the venture rather than the site go in ui.json (docs/UI.md). The full contract, including the admin pages, is docs/UI.md; docs/ui-llms.txt is the same contract for a generator.
#9. Ship
fz build prints the rest of the path and stops there — it constructs, never deploys (issue #142): worker-build --release compiles the venture to wasm, then wrangler deploy stands up the Worker. Migrations are applied as in step 4, not by deploy — the auth workflow below does it in a pre-deploy step. Both commands need what this guide cannot run for you — the toolchain's wasm target, and Cloudflare credentials.
For the workflow around those commands, copy what already works: .github/workflows/deploy-auth.yml deploys this repository's auth instances (one per app, each from instances/<app>/wrangler.toml) — a push to main deploys staging, an auth-v* tag deploys production behind a GitHub Environment approval gate, D1 migrations are applied before each deploy, and a smoke script runs after it. It is not a venture deploy, and no venture deploy workflow ships in this repository today; pointing that shape at your venture directory is a one-time human step.
After each deploy, smoke the API (this is the local equivalent, run for real in step 2):
curl -fsS https://api.<your-domain>/__health
curl -fsS https://api.<your-domain>/__ready/__ready runs SELECT 1 through the Database port — it proves the D1 binding, not just the process. /__health lists every module with its version, so a stale deploy is visible immediately.
#Who does what
| Step | Human? | Who / what does it |
|---|---|---|
fz init → fz add → fz build, add to workspace members | no | you |
cargo test, fz doctor | no | CI's test job runs cargo test --workspace on every PR, composition test included; fz doctor is your pre-deploy gate |
| D1 create (staging + production) | yes | human with Cloudflare dashboard, once per venture |
d1 migrations apply --remote | yes | human with wrangler auth (staging), gated (production) |
Resend domain verification (send.<domain>) | yes | human with Resend dashboard + DNS |
| Turnstile widget + secret | yes | human with Cloudflare dashboard |
Custom domain api.<domain> + DNS | yes | human with DNS control |
Production secrets (wrangler secret put) | yes | human with wrangler auth |
| New module versions on crates.io | yes | harness maintainers merge the release-plz PR; Renovate opens venture bumps |
| Deploys | yes | no venture deploy workflow ships: worker-build --release + wrangler deploy, or copy .github/workflows/deploy-auth.yml |
| Local dev | no | you + wrangler dev --local |
#When the venture outgrows Workers
Compute and storage stay swappable by design: the move to a self-hosted native binary changes one runtime crate and zero module code, and the crates for it (cratefield-runtime-native, cratefield-adapter-postgres) are published — PORTABILITY.md is the runbook.
Mirrored from docs/VENTURE-GUIDE.md at b6f50cd. The harness is the source of truth: a correction goes there first.