Before Perron had real users, we wanted to know how it would behave when it did. Perron puts a chat room at every Dutch train station, with live departures and disruption alerts, and its backend is a single Cloudflare Worker that leans on almost everything Cloudflare sells: thirteen Durable Object classes, a D1 database, a KV namespace, two R2 buckets, a Queue, Workers AI, three Analytics Engine datasets and two cron schedules.
The plan was to throw a few thousand virtual travellers at it and see where it broke. We didn't want to do that against mocks. A load test against a mock tells you about the mock, and the things we were worried about (Durable Object request counts, SQLite rows read, how the free-plan quotas would hold up) only exist on the real platform. We also didn't want to do it against production, for the obvious reason.
Wrangler environments got us a real copy in an afternoon. They also handed us a bug that ran unnoticed for a while and made our first measurements less trustworthy than we thought. Here's the whole setup, warts included.
A second Worker, not a second version
An environment is a named block under env in your Wrangler config. The top level is your default, which for us is production. Whatever's inside env.staging applies when you deploy with --env staging.
The part that's easy to miss is what that deploy produces. According to the environments docs, every environment becomes its own Worker, named <name>-<env>. So perron-worker with a staging block gives you a second script called perron-worker-staging. It isn't a version or a preview of the first. It has its own settings, its own secrets, its own logs and its own metrics.
That turns out to be the most useful property of the whole feature, because of how Durable Objects work.
Durable Objects isolate themselves
A Durable Object namespace belongs to the script that declares it. When the staging Worker asks for the StationRoom with the ID for Amsterdam Centraal, it gets a different object from the one production uses, even though the class, the code and the ID string are identical. Chat history, rate-limit counters, statistics: all of production's lives in objects the staging script can't address.
We wrote zero lines of code for that. A load test can't post into a real traveller's room even if the load generator is buggy, because that room is in another namespace entirely.
Nothing else gets that treatment. D1 databases, KV namespaces, R2 buckets, Queues and Analytics Engine datasets belong to the account, not the script. If staging binds the same database name as production, it's the same database. Which brings us to the rule you need to have in your head before you write your first env block.
Some keys inherit, some don't
Wrangler sorts its config keys into two groups, and the configuration reference lists both. It's worth reading that page top to bottom once. We hadn't, and it showed.
Non-inheritable keys have to be written again inside the environment. Leave one out and the environment simply doesn't have it. Every binding lives in this group: durable_objects, kv_namespaces, r2_buckets, d1_databases, queues, services, vectorize and friends, plus vars, define, tail_consumers and secrets.
That makes environment blocks verbose, and we've come to like it. You can't accidentally write to the production database from staging by forgetting a line, because forgetting a line means staging has no database at all and fails loudly. If staging points at a production resource, somebody typed that on purpose.
Inheritable keys go the other way. If the environment doesn't mention them, it gets the top-level value. This group includes main, compatibility_date and its flags, workers_dev, routes, observability, limits, placement, migrations, and triggers.
triggers is where your cron schedules live. Hold that thought.
What our staging block looks like
Here's a trimmed version of the real thing. IDs are removed and twelve of the thirteen Durable Object bindings are elided.
{
"name": "perron-worker",
"main": "src/index.ts",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": false,
"vars": { "ENVIRONMENT": "production", "ALT_ENV_LABEL": "staging" },
"durable_objects": { "bindings": [{ "name": "STATION_ROOM", "class_name": "StationRoom" } /* … */] },
"d1_databases": [{ "binding": "ACCOUNTS_DB", "database_name": "perron-accounts" }],
"kv_namespaces": [{ "binding": "LEADERBOARDS", "id": "…" }],
"r2_buckets": [{ "binding": "CONFIG_BACKUPS", "bucket_name": "perron-backups" }],
"analytics_engine_datasets": [{ "binding": "USAGE_AE", "dataset": "perron_usage" }],
"queues": { "producers": [{ "binding": "PUSH_QUEUE", "queue": "push" }] },
"ai": { "binding": "AI" },
"triggers": { "crons": ["*/5 * * * *", "0 3 * * *"] },
"env": {
"staging": {
"workers_dev": true,
"vars": { "ENVIRONMENT": "staging", "ALT_ENV_LABEL": "production",
"LOADGEN_MODE": "on", "DEVICE_BINDING": "optional" },
"durable_objects": { "bindings": [{ "name": "STATION_ROOM", "class_name": "StationRoom" } /* … */] },
"d1_databases": [{ "binding": "ACCOUNTS_DB", "database_name": "perron-accounts-staging" }],
"kv_namespaces": [{ "binding": "LEADERBOARDS", "id": "…a different id…" }],
"r2_buckets": [{ "binding": "CONFIG_BACKUPS", "bucket_name": "perron-backups-staging" }],
"analytics_engine_datasets": [{ "binding": "USAGE_AE", "dataset": "perron_usage_staging" }],
"ai": { "binding": "AI" },
"triggers": { "crons": [] }
}
}
}
Every binding is repeated and points at a -staging resource. The Durable Object list is the same thirteen classes, because the script name already isolates them, but the list still has to be written out since it isn't inherited.
The native rate limiters are repeated too. It's easy to forget they're bindings like any other, and a staging Worker with no limiter bindings behaves very differently under load.
Staging has no Queue at all. Our push code checks for the PUSH_QUEUE binding and sends notifications inline when it's missing, which is a path we keep for tests anyway. The push notifications post explains why that inline path exists.
A couple of variables are set to empty strings on purpose. Production fetches German train data through two relays we run ourselves; staging has those URLs blanked, so a load test never generates traffic to someone else's servers. Staging also relaxes the device-binding check to "optional", since load-test clients aren't real phones, and it turns on a load-generator mode that only the staging script understands.
And then there's that last line, "triggers": { "crons": [] }, which is the reason this post exists.
The cron that ran on staging
Perron has two schedules. Every five minutes, a disruption poller fetches the national train-updates feed and opens the StationHub Durable Object of every station that has a disruption, so the app can show it and notify the people who follow that station. Every night at 03:00 UTC, retention sweeps delete data whose keep-until date has passed.
When we set up staging, the comment at the top of the block said "crons are off in staging". We looked through the staging block, didn't see a triggers key, and believed the comment. That's the mistake. For an inheritable key, an absent value doesn't mean off. It means "whatever the top level says".
So Wrangler had been running the disruption poller on the staging Worker every five minutes, the whole time. It downloaded a national feed and woke up a hub at every disrupted station, on staging, 288 times a day.
Wasted quota was the smaller problem. The bigger one was that it polluted the measurement. The point of staging was to count Durable Object requests during a load test, and here was a background job adding Durable Object traffic into exactly the window we were measuring, at stations no virtual traveller had visited. We'd been reading numbers with noise in them and calling it signal.
The fix is one line. To switch off an inherited key, set it explicitly. For cron triggers, an empty list does it.
"staging": {
// Environments INHERIT the top-level triggers.
// An empty array turns them off. A missing key inherits them.
"triggers": { "crons": [] }
}
After deploying, don't check by reading the config again. The config is what fooled us. Ask Cloudflare: the staging Worker's settings in the dashboard list its cron triggers, and so does the API. Ours reported no schedules, and that's the check we trust.
Alarms are background work too
Crons weren't the only thing running with nobody watching. A Durable Object can schedule its own wake-up with an alarm, and an object that re-arms its alarm every time it fires will run until the heat death of the universe.
We had one. The admin dashboard has a page of Cloudflare usage numbers, cached in a PlatformStats Durable Object. On production that object refreshes itself every five minutes with two calls to Cloudflare's API, so the page is warm whenever someone opens it. On staging, the same loop would start the first time anybody opened the page and then keep polling the account forever.
Here's the thing to remember, and it's going to come up again: staging is a separate script, but it isn't a separate account. It draws from the same plan allowances and the same API rate limits as production. Anything staging does in the background, production pays for.
So the object checks ENVIRONMENT and doesn't arm its loop on staging. It also cancels any alarm a previous deploy left behind. The staging page still works; it just fetches fresh numbers when somebody actually looks at it.
We then went through every setAlarm in the codebase asking the same question. Most of them only re-arm while there's work to do, so an idle staging object goes quiet on its own. Two daily sweeps re-arm unconditionally, but once a day costs nothing and keeps staging's retention behaviour realistic, so we left them.
These days, whenever we add anything to Perron that runs without a request, we ask whether staging should run it. That covers cron triggers, self-rearming alarms and queue consumers. It's a short list, and it's the one that bit us.
Shared quota, so budget the load test
Because both scripts spend from one account, a load test can eat production's daily allowance. On the free plan that's a cliff, not a slope: hit the Durable Object request limit and the whole app stops answering until midnight UTC.
The load generator therefore does arithmetic before it starts. It reads how much of the day's budget is already spent, estimates what the run will cost, and refuses to start a run the budget can't fit. During the run it keeps checking, and it stops itself once the account crosses 70% of the daily limit. That leaves production room to breathe, even if the estimate was off.
Perron moved to Workers Paid on 2026-10-15, so the cliff is now a bill rather than an outage. The guard stays. A load test that quietly costs money is only a nicer failure than one that takes the app down.
Giving staging some data
An empty staging Worker isn't a realistic one. Perron reads its station catalogue, line catalogue, track shapes and timetables from R2, and its feature flags and copy from a nightly config backup. On day one, staging had none of that and every screen was empty.
A small Node script mirrors what's needed from the production bucket into the staging one over the R2 API. By default it copies only the load-test essentials, a few hundred objects under the stations, lines, shapes, schedule and config prefixes. The production bucket has about 6,500 objects, most of them station images and German timetable data a load test never asks for. An --all flag copies everything, and --dry-run lists what would be copied without downloading a byte. It overwrites but never deletes, so running it twice is harmless.
Copying the config backup matters more than it sounds. It's what makes staging behave like production, with the same flags, the same tuning numbers and the same copy overrides, and not just look like it.
One permissions detail caught us out. The API token that runs wrangler deploy in CI has Workers permissions and nothing else, so it can't write to R2. The mirror script uses a separate token scoped to R2. We'd keep them apart even if it weren't required.
Staging's admin and Cloudflare Access
Perron's admin dashboard sits behind Cloudflare Access. On staging, it returned 404 to everyone at first, us included.
Access protects hostnames on your zones, and a bare *.workers.dev hostname isn't on your zone, so it can't sit behind Access. Our admin code refuses to answer without a valid Access token, and on workers.dev there was never going to be one.
The fix was to give the staging Worker a custom domain under our own site, with its own Access application. Staging has its own Access audience tag as a variable, different from production's, so a token minted for one is rejected by the other. We wrote about verifying those tokens in Cloudflare Access JWTs in a Worker.
Staging keeps workers_dev: true as well, because the load generator talks to the public API on that address. Production sets workers_dev: false, so the only way in is through our hostname.
We attach production's custom domain in the dashboard and not through routes in the config. A routes entry would mean every deploy needs zone-edit rights on its token, and we don't want a CI token that can re-point a hostname. It's one of those choices that seems fussy until the day a token leaks.
Make it obvious which one you're on
The mistake a staging setup is meant to prevent is doing something to production while thinking you're on staging. So the admin makes the difference loud. An environment badge reads ENVIRONMENT and shows a big amber STAGING on staging and a calm label on production. Next to it, a switch link takes you to the other environment's admin. Two variables drive it, ALT_ENV_LABEL and ALT_ENV_ADMIN_URL, and the link stays disabled until the URL is set.
It's a small piece of UI, and it's the cheapest insurance in the project.
Deploys: production on push, staging on purpose
Production deploys when code lands on the main branch, filtered to the paths that make up the Worker. Staging never deploys automatically. The same GitHub Actions workflow has a manual trigger with an environment input, and running it with staging runs wrangler deploy --env staging. Type checks and tests run first either way.
That test gate is younger than you'd hope. Until 2026-07-14 there was no CI on the Worker at all. Not a weak one, none. It arrived the day after staging did, which is no coincidence: once you deploy two scripts from one codebase, you want something to stop a broken build reaching either.
The manual trigger also turned out useful for production. Once, a Worker commit landed on main and the path filter decided it didn't count, so no deploy ran. Having a button to redeploy by hand fixed that in a minute.
Secrets are per environment, so every secret gets set twice, with different values:
wrangler secret put SESSION_SECRET # production
wrangler secret put SESSION_SECRET --env staging # staging, not the same value
A staging secret that also works on production is just a production secret kept somewhere less careful.
Durable Object migrations
migrations is inheritable, and here that's what you want, since both scripts need the same classes created in the same order. When we add a class, we add one migration at the top level and deploy both environments. Each script tracks which migration tags it has applied, so one list serves both. Perron is on its tenth migration, and both scripts went through all ten.
What we'd tell a team starting out
Repeat every binding inside the environment and point each one at its own staging resource. Durable Objects isolate themselves through the script name, but the binding list still has to be written out.
For every inheritable key, decide whether staging should really get production's value. triggers is the one that surprises people. routes is the one that can hurt, because an inherited route on a production hostname sends real users to staging.
List everything that runs without a request and make a decision about each item. Remember the account is shared, and budget load tests against it. Seed staging with real data from a script you can rerun safely. Make the environment impossible to mistake on any screen where you can change things.
Mostly, though, treat "I don't see it in the config" as "I don't know yet". For half of Wrangler's keys, absence means inheritance. The configuration reference is the source of truth, and the deployed Worker is the proof.
For the cost side of running a Worker like this one, see our notes on the Workers free plan limits.