Two environments, two wrangler files
Staging and production are two standalone wrangler files — wrangler.staging.toml
and wrangler.toml — rather than one file with an environment section layered on
top. Cloudflare's [env.*] blocks do not inherit bindings from the top level, so a
binding declared once and assumed everywhere is simply absent in the environment that did not
repeat it. Two files that each say everything are honest about that; one file with a hidden gap
is not.
Why duplication beats inheritance here. The failure mode of the clever version is silent: the code compiles, the deploy succeeds, and the binding is missing at runtime in exactly one environment. Duplication is only a maintenance problem if nothing checks it — so something does.
The parity test that holds both files together
wrangler-parity.test.ts runs in the ordinary test suite on every commit and reads
both files relative to the package rather than the working directory, so it behaves the same
from the repository root and from inside apps/web. It asserts what may differ and
what may not:
-staging in the staging file.
| Must differ | Must be identical |
|---|---|
the worker name, routes and custom domains, the [vars] values, Hyperdrive and KV ids, and every account-scoped resource name | the entry point, compatibility_date and flags, every binding name, every Durable Object class_name, the migrations block, [limits], the cron triggers, the assets block, and the set of [vars] keys |
The identical column is the point: application code never branches on the environment. A route reads a binding by name and the name is the same in both places, so "works on staging" means something.
Before a deploy, the same test runs again with REQUIRE_PROVISIONED=1, which
additionally refuses any <PLACEHOLDER> value still sitting in a file. That
keeps continuous integration green on a fresh, unprovisioned copy of the kit while making it
impossible to ship one.
Account-scoped names: the staging and production hazard
Workflow names, queue names, R2 bucket names and Analytics Engine dataset names are unique per Cloudflare account, not per Worker. Whichever script last deployed a given name owns it — and every instance created under that name, including instances created by the other environment's binding, then runs with the owning script's bindings, against the owning script's database.
This is not hypothetical. In one of the applications this kit was extracted from, staging and production briefly shared a workflow name. The production API created a run and started an instance; the instance executed under the staging worker against the staging database; and production was left holding a pending row and a UI stuck on its last progress event. Nothing errored anywhere, which is precisely what made it expensive to find.
Why the suffix is a rule and not a habit. Every account-scoped name in the
staging file ends in -staging, while the binding and
class_name stay identical — so no application code becomes
environment-aware — and the parity test refuses a collision. The guard has to be mechanical,
because the failure it prevents produces no error to notice.
The CPU limit applies per workflow step
A workflow's CPU budget is applied per step.do, from the script's
cpu_ms setting. CPU is not wall clock: a step that is almost entirely waiting on
the network still dies if it processes enough items, because the per-item cost adds up
inside the same budget.
This is why [limits] is in the identical column. Declared in one file only, the
same workflow class dies in one environment and works perfectly in the other — a difference
that reads as a flaky bug rather than a configuration one. Splitting a heavy phase into its own
step draws a fresh budget, which is usually the better fix.
Releases: push a tag to ship staging, publish to ship production
The git tag must equal the version in the root package.json;
the deploy job fails if it does not. One tag ships the web app and the CLI together, so the
per-package versions are informational.
1. pnpm kit:release X.Y.Z, commit -> bumps the version, writes the porting note
2. git tag X.Y.Z && git push origin X.Y.Z -> staging deploys
3. check staging
4. gh release create X.Y.Z -> production deploys the same tag A release of the kit itself also ships a porting note, and the deploy refuses a tag without one. Copies of the kit are detached and renamed, so they never merge from upstream — they replay a translated diff guided by those notes. A release with no note would be a gap every copy has to step over, permanently. The changelog is the list.
Publishing the GitHub release is the promotion gate. Production does not re-run the full gate;
it ships the exact commit staging validated, with the release version injected at deploy time
and surfaced by the session endpoint, the nav footer and rocketflare status.
Rolling back has two speeds. wrangler rollback restores the previous worker version
in seconds — code and bindings, but not the database. Migrations are
forward-only, so undoing a schema change means writing a compensating migration and running the
dance again.
Where each kind of secret lives
| Kind | Home |
|---|---|
| Non-secret configuration | [vars] in each toml, committed |
| Worker secrets | wrangler secret put per environment; .dev.vars locally, git-ignored |
| Continuous-integration secrets | GitHub environments, one set per environment — or none at all with an external deployer |
| Resource ids | the tomls — Hyperdrive and KV ids are not secrets |
Setting a worker secret requires the worker to exist, so the first deploy of a fresh environment runs before its secrets do and returns errors until they are set. That ordering is inherent to the platform rather than a quirk of the kit, and the setup guide says so plainly.
Use different key material per environment. Rotating the encryption key invalidates every stored provider credential, since there is no re-encrypt path — administrators re-enter the key and the row keeps everything else.
Deploying without a Cloudflare token in CI
A Cloudflare token that can deploy a Worker can also bind any resource in the account into it —
another app's bucket, queue or database. A CI job that holds one can therefore reach far more
than the app it is shipping. Setting the repository variable DEPLOYER_URL takes
the token out of CI altogether: the deploy job holds no Cloudflare token and no database
credential, proves who it is with a short-lived GitHub OIDC token, and hands its build to an
external deployer.
start -> a ticket opens; the deployer may wait for a person to approve it
upload -> the dry-run build is checked against the app's allowed bindings, stored undeployed
migrate -> only now: short-lived migration credentials, pnpm db:migrate:ci
activate -> the stored version goes live
finish -> always runs; credentials revoked, ticket closed Why the build is checked before any credential exists. A refused build must not have touched the database. So the deployer inspects the bindings first and issues migration credentials only after it has accepted the build; a rejected or never-approved ticket gets nothing.
The kit ships the client, scripts/deployer.mjs, and the contract any deployer
implements — a versioned protocol in docs/DEPLOYER.md. It does not ship a deployer.
With the variable unset, the job deploys with its own token exactly as described above.
Migrations run against the direct database host
An app on the neon driver holds Neon's pooled connection string as a Worker secret. An
app on postgres goes through Hyperdrive, which is itself a connection pooler, so it
points at the database's direct host rather than its pooled one — stacking a pooler on a pooler
adds a hop and a second transaction-mode layer for nothing. The migration script goes further and rewrites a pooled host to the direct host
itself, whichever form the connection string arrives in.
The reason is specific: a pooled backend can carry a stale read-only transaction setting that blocks schema changes. Migrations then fail in a way that looks like a permissions problem and is not. Rewriting the host in one place means nobody has to remember.
The migration also runs fine as Neon's owner role, which is not a superuser. Postgres lets only
a superuser even mention the SUPERUSER, BYPASSRLS or
REPLICATION attributes, so the step that locks down the app's role sets those three
only when it is running as one, and otherwise relies on a new role having them off already.