All concepts
11 — Concept

Deployment

Staging and production as two wrangler files a test keeps identical, and a release you ship by pushing a tag.

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:

The two wrangler files: what the parity test locks together, and where the values must differ wrangler.toml wrangler.staging.toml binding names binding names class_name (DO, Workflow) class_name (DO, Workflow) compatibility_date + flags compatibility_date + flags [limits] [limits] [triggers] crons [triggers] crons [assets] [assets] [[migrations]] [[migrations]] [vars] keys [vars] keys name rocketflare name rocketflare-staging APP_URL https://app host APP_URL https://staging host hyperdrive, kv ids production hyperdrive, kv ids staging queue rocketflare-jobs queue rocketflare-jobs-staging workflow rocketflare-agent-run workflow …-agent-run-staging bucket rocketflare-files bucket rocketflare-files-staging
Locked rows are compared verbatim between the two files. The rows that differ are the worker's identity and its account-scoped resources — every one of those names ends in -staging in the staging file.
Must differMust 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

KindHome
Non-secret configuration[vars] in each toml, committed
Worker secretswrangler secret put per environment; .dev.vars locally, git-ignored
Continuous-integration secretsGitHub environments, one set per environment
Resource idsthe 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.