Two environments, two 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.
A test holds the two 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.
The account-scoped name 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 is per 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 move by tag
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. bump the root version, commit
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
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 |
| 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.
Migrations run against the direct host
Hyperdrive 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.