Five Ways I Took a Hostname Offline
Cloudflare Workers environments, custom domains versus zone routes, and the deploy-time traps that each cost me a production outage.

Parts one and two were about code that works. This one is about deployment, which is where I actually broke things.
Everything below is written down in the repo’s CLAUDE.md, in the same blunt tone, because
every entry started as an outage and I did not trust myself to remember the shape of it.
1. Zone routes depend on DNS records nobody is maintaining
Cloudflare gives you two ways to attach a hostname to a Worker, and they look interchangeable.
A zone route is a pattern:
[[env.production.routes]]
pattern = "rba.dev/admin/api/*"
zone_name = "rba.dev"
A custom domain is a hostname the Worker owns:
[[env.production.routes]]
pattern = "api.rba.dev"
custom_domain = true
The difference that matters is DNS. A custom domain makes wrangler create and own the DNS record. A zone route assumes a record already exists — made by hand, at some point, by someone.
api.rba.dev and worker.rba.dev were zone routes. Their DNS records went missing. Both
hostnames stopped resolving while the routes stayed perfectly correct, because a route with
nothing pointing at it is a rule about traffic that never arrives.
Everything that owns a whole hostname is a custom domain now. The admin worker in part two
still uses zone routes, and has to: custom domains claim an entire host, and
rba.dev/admin/api/* is a path split on a host that Astro already owns. Zone routes are the
only tool for that job — they just aren’t the right tool for “this Worker is this hostname.”
2. routes is inherited, and CI won’t ask
This one is my favourite, in the way that a scar is a favourite.
routes is an inheritable field in wrangler.toml. An [env.*] block that doesn’t declare
its own inherits the top-level one. Deploying that environment then reassigns those hostnames
to it.
So a config where [env.test] forgets its routes block will, on deploy --env test, move
rba.dev onto the test worker.
Wrangler warns about this. Interactively it prompts. In CI, stdout is not a TTY, and wrangler takes that as consent:
override_existing_origin = true
override_existing_dns_record = true
No prompt, no changeset, exit code 0. A green build that moved production onto the test worker.
The config now carries the rule in capitals, directly above the block it protects:
# EVERY [env.*] BELOW MUST DECLARE ITS OWN [[env.*.routes]]. `routes` is an inheritable
# field: an environment that omits it inherits this block, and deploying that environment
# REASSIGNS these custom domains away from the top-level worker.
[[routes]]
pattern = "rba.dev"
custom_domain = true
3. --env is silently ignored after a build
To set a secret on the test worker, the obvious command is:
cd apps/rbadev
wrangler secret put PUBLIC_CONFIGCAT_KEY --env test
That command writes the secret to production.
Astro’s Cloudflare adapter generates a real deploy config at dist/server/wrangler.json and
drops a pointer at .wrangler/deploy/config.json. After any build, wrangler follows the
pointer. The generated config is already environment-resolved — it has no [env.*] sections
at all — so --env test matches nothing, is not an error, and the secret lands on whichever
script that build targeted. If you built for production last, that’s production.
The safe form names the script explicitly, from the repo root, where no build output is redirecting anything:
bunx wrangler secret put PUBLIC_CONFIGCAT_KEY --name rbadev-test
4. Renaming means deleting, in the wrong order
Workers cannot be renamed. There is no rename in the dashboard or in wrangler — the script
name is the resource id. Changing name creates a new script and leaves the old one running.
Two things follow, and I found both the hard way.
Secrets do not follow. A fresh script starts with none. /health on the new worker
reported missing Cloudinary credentials until the setup script was re-run against it — the
code was identical, the deploy was green, and the thing simply had no credentials.
Order matters. Retiring worker.rba.dev in favour of api.rba.dev looked like a rename,
so I removed the old host first. But the deployed site still had the old hostname compiled
into its bundle, and the map lost all of its data until the new frontend shipped.
The rule I wrote down afterwards: deploy the site that resolves the new hostname first, remove the old one second. Overlap costs nothing. A gap is an outage.
5. Two deployers, racing
Cloudflare Workers Builds was connected to the repository and firing on every push. It had
never once deployed successfully — every version in the dashboard was source: wrangler, from
my laptop.
Harmless while it failed. Actively dangerous once GitHub Actions started working, because then two systems reconcile routes and custom domains against the same account from different commits.
Workers Builds is disconnected and stays disconnected. GitHub Actions is the only thing that deploys.
The environment count
There are two environments — test and production — and it is worth saying why, because three felt obviously better.
dev is a branch name, not an environment. It publishes to test, behind Cloudflare Access.
The third tier was removed because it was fictional in two independent ways. ConfigCat has
exactly two environments, so a dev worker got no feature-flag evaluation at all: with no SDK
key, the middleware logs [Middleware] No ConfigCat SDK key, returns the response
untransformed, and every runtime flag silently stops applying. And the gallery API bound the
same D1 database in every environment — so api-dev.rba.dev wasn’t a sandbox, it was a
third door onto production data.
Local development is yarn nx dev, which the dev environment never improved on. Each
environment is now one Worker with one hostname. rbadev was once a single script owning
every custom domain, which made “deploy to test” mean “deploy to production”, and that is a
mistake I only need to make once.
The one that surprised me most
Local deploys bake secrets into the bundle. CI deploys don’t.
astro.config.mjs has a build-time fallback that reads .dev.vars and .env.development.
Those files exist on my machine. They do not exist in CI. So a deploy from my laptop compiles
a ConfigCat key and a session secret into the shipped JavaScript, and the identical command in
GitHub Actions produces a bundle with neither, resolving both from the runtime environment.
The pipeline is safer than the laptop, and not for any reason I designed.
What it all comes down to
Every item on this list is the same mistake wearing a different hat: a configuration change that succeeds, exits zero, and takes something down anyway. There is no test suite for “did this deploy detach a hostname” — the feedback arrives as a site being down.
So the defence isn’t cleverness, it’s writing the trap down at the exact place someone would walk into it. The comments in these config files are longer than the configuration. That ratio looks absurd until the afternoon it saves you, and then it looks like the only sane way to work.
That’s the series: an auth flow with no library, a dashboard sharing one origin with its API, and a deployment setup shaped almost entirely by its own failures.