Skip to content

Set Up dev → test → prod on a Project

A beginner's walkthrough for giving a project a real dev → test → prod promotion ladder: a per-project ceiling that caps how far automation may push, a test environment that mirrors prod (with PII redacted), and a single shared deploy gate that warp-drive and you-at-the-keyboard both obey.

This guide is what you follow to do #805 (prove the test-env pipeline end-to-end on one Cloudflare project) and #809 (roll the model out across every managed project). Worked example: ~/Sites/seebod/seebod.

New to the model? Read the 90-second mental model first; then do Part A on one project. Once that works, Part B is just "repeat per project".


The mental model (90 seconds)

Promotion is governed by two independent dials — change one without touching the other:

DialWhat it controlsWhere it lives
automation level (1/2/3)how much human ceremony — supervised → autonomous.claude/settings.local.json_automation.active_level (set via /automation)
promotion ceiling (pr/external/test/prod)how far the pipeline may traveldev.jsonpromotion.ceiling (set via promotion.js set)

The deploy behaviour is the product of the two — f(level, ceiling):

                 ceiling: pr        external           test                prod
   level 2   PR + confirm      PR (no deploy)     deploy→test (ask)   deploy→prod (ask)
   level 3   PR (auto)         merge, no deploy   deploy→test         deploy→prod
   external is git-only: merge but never deploy (e.g. Acquia owns the pipeline)

Pick a ceiling per project:

Project kindCeilingWhy
greenfield / personalprodmove fast — merges go straight to production
deployed production app (seebod, nanaawards)testwork deploys to a test mirror and stops; you promote to prod deliberately
review-gatedpreven autonomous coding stops at a PR
externally deployed (nanawalld8 / Acquia)externalBoB does git only; deploy happens out-of-band

The test environment is a real, separate set of cloud resources (its own D1 database, its own Pages/Worker) that mirrors prod. Prod data is copied in only when you opt in, and always redacted (emails/secrets stripped) on the way.

The pieces that make this work (all already built — you're just configuring and running them):

PieceScriptRole
Ceilingscripts/promotion/promotion.jsthe single source of truth for f(level, ceiling)
Deploy gatescripts/deploy/deploy.shrefuses to deploy past the ceiling — same gate everywhere
Test resources + mirrorscripts/deploy/test-env.jsprovision test D1/Pages, mirror prod→test with redaction
Fleet viewscripts/fleet/audit.js viewshows each project's declared ceiling; flags undeclared

Full references: Deploy Adapters · Test-Env Mirror · Dev Lifecycle.


Prerequisites

  • The project is BoB-initialised (has a .claude/ and a dev.json). If not, run cdi in it first (Project Setup).
  • node, jq, and wrangler on your PATH; sqlite3 for the D1 mirror.
  • wrangler login (or CLOUDFLARE_API_TOKEN) authenticated on the account that owns the project's resources.
  • BoB deployed to ~/.claude (so the scripts below exist). If a command is "not found", run scripts/deploy.sh from ~/projects/bigbrain once.

Throughout, the scripts are referenced at their runtime path ~/.claude/scripts/…. Every wrangler-mutating command defaults to dry-run — it prints what it would do and changes nothing until you add --apply. Get into the habit of running it once without --apply, reading the plan, then re-running with it.


Part A — set it up on one project

Do this end-to-end on one Cloudflare project first (this is exactly #805). We use seebod.

seebod is a pnpm monorepo: apps/web (SvelteKit → Cloudflare Pages, seebod-web) and apps/api (Cloudflare Worker, seebod-api, with a D1 seebod-db + R2 seebod-assets). A single-Worker project (e.g. nanaawards) is the same recipe with one wrangler config instead of two — differences are called out inline.

The whole ladder is one command (#931). Before walking the six steps by hand, know that /setup-promotion (and the terminal setup-promotion / bin/setup-promotion) composes every safe step into one dry-run-first run — detect → dev.json → wrangler → provision (handoff) → todo → verify — and reports exactly what it changed:

bash
/setup-promotion              # dry-run — preview every step, write nothing
/setup-promotion --apply      # write config + file the redaction/secrets gate todo
setup-promotion --json        # terminal equivalent, machine-readable

It is idempotent and stops cleanly at the human gates: test-env.js (live CF provisioning + the prod→test mirror) and audit.js stay terminal-only and are surfaced as a handoff — never run for you. A red verify gate blocks "done". The six steps below explain and finish exactly what it scaffolds.

Scaffold Steps 1–4 with one wrapper (#928). Rather than run each scaffolder by hand, the provision wrapper composes detection (#925), the dev.jsonenvironments scaffold (#926), and the wrangler [env.test] + database_id wire-back (#927) into a single dry-run-first operation:

bash
node ~/.claude/scripts/setup-promotion/orchestrate.js plan .      # preview every change; touches nothing
node ~/.claude/scripts/setup-promotion/orchestrate.js apply .     # fail-closed: applies config only if the preview is clean
node ~/.claude/scripts/setup-promotion/orchestrate.js teardown .  # reverse exactly what was scaffolded

apply is fail-closed — it re-runs the preview and refuses to write anything if any step errors, so the config is never left half-scaffolded. It is idempotent and no-clobber, and it writes config only: the live CF provisioning and secrets below (Steps 3 & 5) stay yours to run, reported as a handoff. The Step 5 redaction/secrets decisions are handed back as a single todo by the human-gate emitter (#929) that resumes only on the completed label — see Step 5. teardown round-trips the config back to its original state. You still walk Steps 1–6 below to understand and finish what the wrapper hands off.

Step 1 — Choose and set the ceiling

seebod is a deployed production app, so its ceiling is test. Set and confirm it — both forms do the same thing:

Slash command:

/promotion set test
/promotion status

Terminal:

bash
cd ~/Sites/seebod/seebod
node ~/.claude/scripts/promotion/promotion.js set test
node ~/.claude/scripts/promotion/promotion.js status   # confirm

status prints the effective merge= / deploy= for the current automation level so you can see exactly what will happen. (This writes promotion.ceiling: "test" into dev.json.)

Step 2 — Declare the environments in dev.json

Scaffold it instead of hand-writing it (#926). The environments block below can be generated from the detected stack rather than typed by hand — the scaffolder is the second step of /setup-promotion (#924):

bash
node ~/.claude/scripts/setup-promotion/scaffold-dev-json.js .          # dry-run: print the proposed dev.json
node ~/.claude/scripts/setup-promotion/scaffold-dev-json.js . --write  # persist it

It derives test/prod profiles and the inferred promotion.ceiling from detection (#925), is idempotent (re-running produces no diff), never clobbers an existing key, and validates against the dev.json schema before writing. Monorepos scaffold each app's own apps/<name>/dev.json; a non-promotable (n-a) repo is declined rather than force-fit. You still fill in the concrete details below — URLs, D1/Pages names, and the mirror redaction policy — which the scaffolder deliberately leaves as null (the wrangler test target + database_id are wired in Step 4 / #927).

Add an environments block describing test and prod. This is the BoB-level declaration the gate, the mirror, and the audit read. For seebod's API (the Worker that owns the data):

jsonc
{
  // …existing server/migrations/access blocks…
  "promotion": { "ceiling": "test" },
  "environments": {
    "prod": {
      "url": "https://seebod-api.<account>.workers.dev",
      "deploy": { "adapter": "cloudflare", "target": "seebod-api" },
      "db": { "adapter": "d1", "binding": "DB", "database": "seebod-db" }
    },
    "test": {
      "url": "https://test.seebod-api.<account>.workers.dev",
      "deploy": { "adapter": "cloudflare", "target": "seebod-api" },
      "db": { "adapter": "d1", "binding": "DB", "database": "seebod-db-test" },
      "provision": { "d1_database": "seebod-db-test" },
      "mirror": {
        "enabled": false,
        "source": "prod",
        "redaction": {
          "drop_tables": ["sessions", "api_keys"],
          "columns": ["users.email", "users.phone"],
          "patterns": [
            { "match": "[\\w.+-]+@[\\w.-]+\\.\\w+", "replace": "redacted@example.test" }
          ]
        }
      }
    }
  }
}

Notes:

  • prod.db.database is the existing prod D1 (seebod-db). test.db.database is a new, separate test D1 (seebod-db-test) — never point test at prod.
  • provision.d1_database / provision.pages_project name the resources BoB will create in Step 3.
  • mirror.enabled stays false until you've written a real redaction policy and are ready (Step 5). mirror may live only on the test env (you mirror into test from prod).
  • The web app (Pages) gets its own simpler block — it has no database, so it only needs deploy: { "adapter": "cloudflare", "target": "seebod-web" } per env and a provision: { "pages_project": "seebod-web-test" } on test.
  • Single-Worker project (nanaawards): one environments block at the repo root, prod.db.database: "nanaawards-db", test.db.database: "nanaawards-db-test".

Validate the file (terminal only — schema validation has no slash form):

Terminal:

bash
node ~/.claude/scripts/checks/check-schemas.js   # dev.json must stay schema-valid

Step 3 — Provision the test resources (idempotent)

Preview, then apply (terminal only — test-env.js has no slash form):

Terminal:

bash
node ~/.claude/scripts/deploy/test-env.js provision --env test --project "$(pwd)"          # dry-run
node ~/.claude/scripts/deploy/test-env.js provision --env test --project "$(pwd)" --apply  # create

This creates the declared test Pages project and test D1 only if missing (a resource that already exists is a no-op, so it's safe to re-run). For the API, it creates seebod-db-test; for the web app, seebod-web-test.

Copy the new D1's database_id. wrangler d1 create prints it — you need it for the wrangler test-env block in Step 4.

Step 4 — Point wrangler at the test environment

Scaffold it — including the database_id wire-back (#927). The [env.test] block below, and the error-prone copy-paste of the test D1's database_id from Step 3, can be generated automatically — the wrangler step of /setup-promotion (#924):

bash
node ~/.claude/scripts/setup-promotion/scaffold-wrangler.js .          # dry-run: propose [env.test], report would-create ids
node ~/.claude/scripts/setup-promotion/scaffold-wrangler.js . --apply  # look up / create the test D1 and wire its database_id

It mirrors the top-level d1 bindings into [env.test] (database_name → <name>-test), looks up or creates the test D1, and writes its database_id back into the block — no hand-editing. Like the rest of the scaffolder it is no-clobber and idempotent (an existing [env.test] is left as-is), and it follows the test-env.js safety model: nothing touches your Cloudflare account without --apply. It skips cleanly when there is no wrangler config, and declines a commented wrangler.jsonc (which can't be reserialized without losing comments) — do those by hand below. You still add any non-D1 bindings (R2, vars) and set secrets (they never copy from prod).

BoB's dev.json declares intent; wrangler still needs a test target to deploy to. For a Worker (seebod-api), add a named [env.test] block to apps/api/wrangler.toml mirroring [env.production] but bound to the test D1, and crucially without remote = true on its bindings:

toml
[env.test]
[env.test.vars]
ENVIRONMENT = "test"

[[env.test.d1_databases]]
binding = "DB"
database_name = "seebod-db-test"
database_id = "<id-from-step-3>"
migrations_dir = "migrations"

[[env.test.r2_buckets]]
binding = "ASSETS"
bucket_name = "seebod-assets-test"   # provision this too if the app writes R2

seebod gotcha — the remote = true trap. Today seebod's dev bindings use remote = true, i.e. local dev reads and writes production D1/R2 directly. A real test env (separate DB + redacted mirror) is the safe replacement: point dev/test at the test resources and keep prod for prod. Flipping these is part of the per-project work in #809.

For Pages (seebod-web) there's no named-env block; the CF deploy adapter maps --env test to a preview deployment, and --env prod to production. Nothing extra is needed beyond the provision.pages_project.

Set any secrets the test env needs (they do not copy from prod):

bash
wrangler secret put ANTHROPIC_API_KEY --env test
# …repeat for OPENAI_API_KEY, CLOUDFLARE_STREAM_TOKEN, etc.

Step 5 — Mirror prod → test (redacted, opt-in)

The scaffolder hands this step back as one todo (#929). Steps 5's two irreducible decisions — the redaction policy and the test-env secrets — are the only things the scaffolder will not do for you. It emits them as a single, self-contained todo (assigned to you) rather than ever auto-enabling the mirror:

bash
node ~/.claude/scripts/setup-promotion/human-gate.js emit --apply   # file the gate todo (idempotent)
node ~/.claude/scripts/setup-promotion/human-gate.js gate           # is the gate cleared? (resume vs still-blocked)

The mirror stays off until you do the work below and add the completed label to that todo — the canonical done-signal. Marking it completed is what lets the scaffolder resume the mirror step on its next run; closing the todo without completed cancels it and never resumes. (This is the same completed lifecycle the todo consumer drives.)

Only now turn the mirror on. First write a real redaction policy — inspect your schema and list every PII/secret column and every table to drop:

bash
wrangler d1 execute seebod-db --remote --command "SELECT name FROM sqlite_master WHERE type='table'"
# then, for a suspect table:
wrangler d1 execute seebod-db --remote --command "PRAGMA table_info(users)"

Edit environments.test.mirror in dev.json: set enabled: true and fill redaction (the example in Step 2 is a starting point — make it match your real columns). The mirror refuses to run if enabled is true but no redaction policy is declared — that's the safety gate, not a bug.

Preview, eyeball the redacted dump, then apply (terminal only — test-env.js has no slash form):

Terminal:

bash
node ~/.claude/scripts/deploy/test-env.js mirror --env test --project "$(pwd)"          # dry-run: shows export→redact→import plan
node ~/.claude/scripts/deploy/test-env.js mirror --env test --project "$(pwd)" --apply  # do it
# inspect the sanitised dump BEFORE trusting it:
grep -iE "@|secret|token" .mirror-redacted.sql | head   # should show NO real emails/secrets

Check freshness any time:

Terminal:

bash
node ~/.claude/scripts/deploy/test-env.js status --env test --project "$(pwd)"

Step 6 — Deploy through the ladder and verify

Deploy to test via the shared entrypoint (it re-checks the ceiling gate, runs check → build → migrate → deploy → verify). Both forms run the same dispatcher and re-check the ceiling gate; the slash form acts on the current project, so it needs no --project:

Slash command:

/deploy --env test --dry-run
/deploy --env test

Terminal:

bash
~/.claude/scripts/deploy/deploy.sh --env test --project "$(pwd)" --dry-run   # preview
~/.claude/scripts/deploy/deploy.sh --env test --project "$(pwd)"             # ship to test

The scaffolder's final step is the verification gate (#930). Rather than eyeballing the dry-run, run the gate — it invokes deploy.sh --env test --dry-run, emits a report (ceiling + scaffolded files + outcome), and exits non-zero if the dry-run is not green so a red result can't be mistaken for a pass:

bash
node ~/.claude/scripts/setup-promotion/verify-gate.js run .          # human summary; exit 0 green / 1 red
node ~/.claude/scripts/setup-promotion/verify-gate.js run . --json   # machine-readable report

A green gate on the live seebod project is the accepted proof for #805 — see the note at the end of this step.

Confirm the gate is doing its job — at ceiling test, a prod deploy is refused unless you deliberately raise the ceiling or pass --force:

Slash command:

/deploy --env prod

Terminal:

bash
~/.claude/scripts/deploy/deploy.sh --env prod --project "$(pwd)"   # expect: blocked (exit 3)

A green verify-gate.js run (a passing --env test dry-run) on this one project is the acceptance bar for #805. Capture the gate's report, comment the result on #451, and add the completed label to #805 so downstream tooling resumes.


Part B — roll it out across the fleet (#809)

With the recipe proven on seebod, the rollout is mechanical — and fleet mode (#932) performs it as one serial command over the project registry:

bash
node ~/.claude/scripts/setup-promotion/fleet.js run           # dry-run preview per project
node ~/.claude/scripts/setup-promotion/fleet.js run --apply   # scaffold the whole fleet

Each project is reported as scaffolded / no-change / gated / skipped / failed (gated = config landed, done blocked on the human handoffs); n-a repos (a CLI/tooling repo with no deploy target) are skips, not failures, and projects run strictly one at a time (config-sync pinch point — never fan out). Live CF provisioning and secrets remain per-project human handoffs, so after an --apply pass walk the emitted gate todos.

Done by hand instead, the same rollout is, for every managed project:

  1. Set a ceiling (Step 1) — prod for greenfield, test/pr for production apps, external for nanawalld8.
  2. Commit a dev.json with the environments profiles (Step 2) where the project actually has a higher environment. A CLI/tooling repo with no deploy target legitimately needs none.
  3. Migrate the four deploy-cloudflare projects to the adapter model — give each an environments.<env>.deploy.adapter: "cloudflare" and deploy once via deploy.sh to confirm no regression versus their old deploy path.

Then verify the whole fleet at a glance (terminal only — audit.js has no slash form):

Terminal:

bash
node ~/.claude/scripts/fleet/audit.js view

The CEILING column shows declared/total per machine and fleet-wide; a !N suffix flags projects still undeclared. The rollout is done when every managed project shows a declared ceiling (no !), which is the #809 / R8 acceptance check. Comment the before/after on #453 and add completed to #809.

Commit the config to each project's own repo. promotion.ceiling, environments, and the wrangler [env.test] blocks live in the project, not in BoB — they travel with the code and CI.


How the two TODOs map to these steps

TODOWhat it isSteps
#805Prove the pipeline e2e on one CF projectPart A, Steps 3–6 (on seebod or any one CF app)
#809Declare ceilings + backfill dev.json + migrate the 4 CF projects, fleet-widePart B (Steps 1–2 per project, adapter migration, then the audit)

Do #805 first — it shakes out the per-project mechanics before you repeat them.


Troubleshooting

SymptomCause / fix
deploy.sh exits 3 "promotion ceiling forbids…"Working as designed — the env exceeds the ceiling. Raise the ceiling (promotion.js set), bump automation level, or pass --force for a deliberate one-off.
mirror exits 2 "redaction declares no policy"mirror.enabled is true but redaction is empty. Add drop_tables/columns/patterns — sanitisation is mandatory.
mirror says "not enabled — skipping"mirror.enabled is false (the default). Set it true once your redaction policy is ready.
Redacted dump still shows real emailsYour columns/patterns don't match the real schema. Inspect tables with PRAGMA table_info(...) and widen the policy; re-run the dry-run and re-grep.
Dev/test reads production dataA wrangler binding has remote = true (the seebod trap). Point test bindings at the test resources and drop remote = true.
Test deploy can't find the DBThe wrangler [env.test] block is missing or its database_id is wrong. Re-check Step 4 against the id from Step 3.
audit.js view shows !N in CEILINGN projects have no declared promotion.ceiling yet — finish Part B Step 1 for them.

See also