About Author
A formerly masked, now weaponized ASD-1 nihilist with an well-honed hatred for bullshit, who architects software systems just enough to fund a life spent exposing nepo-babies, working on taxing billionaires into extinction, and teaching everyone with a paycheck that their soul isn't a subscription.
I want to start with an inventory, because the inventory is the argument. My previous deployment surface, per service: a Helm chart, a values file per environment, a Deployment, a Service, an Ingress, an HPA, a ConfigMap, a Secret, a ServiceAccount, and a CI pipeline that stitched them all together. Call it 400 lines of YAML before a single line of application code existed. I wrote that YAML so many times I stopped seeing it, the way you stop seeing your own front hallway.
This is part 2 of 10 in the RORBT field notes—dispatches from building a governance platform on Cloudflare. The whole series lives on the RORBT case study.
The Cloudflare equivalent is one file:
name = "rorbt-site"
pages_build_output_dir = "packages/rorbt-ui/dist"
compatibility_date = "2026-01-01"
compatibility_flags = ["nodejs_compat"]
[[r2_buckets]]
binding = "ORBITS"
bucket_name = "rorbt-orbits"
[[d1_databases]]
binding = "DB"
database_name = "rorbt_users"
database_id = "…"
migrations_dir = "drizzle/migrations"
[[durable_objects.bindings]]
name = "ORBIT_COORDINATOR"
class_name = "OrbitCoordinator"
script_name = "rorbt-orbit-room"
That’s the whole control plane. No replicas, no resource limits, no liveness probe, no autoscaling policy, no ingress rules, no TLS config. The platform owns all of it, and I mean that as a compliment—every line of YAML you don’t write is a line you can’t get wrong.
But a small config surface is not the same as a safe one. It just means the mistakes are quieter. I hit four sharp edges in a single day, and every one of them failed silently.
compatibility_date is your base image tag
This is the closest analogue to pinning a container image, and it’s easy to underweight because it looks like metadata. It isn’t. It selects runtime semantics, not just API availability—changing it can alter the behaviour of code you didn’t touch, which is precisely the property that makes an unpinned base image a firing offense in any container shop I’ve worked in.
compatibility_flags = ["nodejs_compat"] is the other half. It’s what lets you import { AsyncLocalStorage } from 'node:async_hooks', which turned out to be load-bearing for request-scoped logging (post 10). Without it, a large slice of the npm ecosystem simply doesn’t run.
The mental model I settled on: compatibility_date ≈ FROM node:22.3.1. Pin it, change it deliberately, test when you do. Treat a casual bump with the same suspicion you’d give a Dockerfile diff in a Friday deploy.
Pages has exactly two environments, and it won’t tell you
Workers support arbitrary named environments. Pages does not, and I found out the way you find out most things about this platform—by watching it succeed at something it shouldn’t have. I added [env.staging], ran wrangler pages dev, and it started up cleanly using top-level bindings. No warning, no error. The section was silently ignored, which is the config-file equivalent of a smoke detector that responds to fire by saying nothing.
The only supported names are preview and production. I learned this from an error message in a different command:
✘ [ERROR] Pages does not support the --env flag during local development.
Use the --branch flag to target your production or preview environment instead.
Which is itself wrong—--branch doesn’t exist on wrangler pages dev either:
✘ [ERROR] Unknown argument: branch
So [env.preview] cannot be exercised locally at all. It only takes effect on a deployed preview build. Coming from k8s, where I could kubectl apply any namespace into kind or minikube and get identical behaviour on my laptop, this is a real regression in the dev loop, and I’m not going to pretend otherwise. The first tool told me to use a flag the second tool had never heard of. That set the tone for the day.
Named environments inherit nothing
This is the one that cost me the most time, because it inverts a decade of muscle memory. Kustomize overlays patch a base: you specify what’s different, and everything you don’t mention is inherited. Wrangler works the other way around, and nobody hands you a pamphlet.
The moment [env.preview] declares any bindings, it inherits none of the top-level ones. Every R2 bucket, D1 database, DO binding, and var has to be repeated. Miss one and it’s simply absent at runtime—no error at deploy, just a hole where a capability used to be.
It goes further than bindings. migrations_dir is a property of the D1 entry, and it isn’t inherited either:
▲ [WARNING] No migrations folder found. Set `migrations_dir` in your wrangler.toml
✘ [ERROR] No migrations present at /Users/…/rorbt-site/migrations.
The fix is a line that looks redundant and isn’t:
[[env.preview.d1_databases]]
binding = "DB"
database_name = "rorbt_users_preview"
database_id = "…"
migrations_dir = "drizzle/migrations" # not inherited
Now the part that actually matters. The dangerous corollary: if you don’t declare [env.preview], top-level config applies to both environments—which means every preview and branch deployment reads and writes your production database and buckets. That was silently true of my project until the moment I thought to look. On k8s the equivalent would be a staging Deployment pointed at the prod connection string, and you’d catch it in review because the string is right there in the diff, wearing a name tag. Here there is no string. There is no diff.
The absence of configuration is the thing that misconfigures you, and absence doesn’t show up in code review.
graph LR
subgraph Before["Before [env.preview]"]
P1[production deploy] --> DB1[(rorbt_users)]
V1[preview deploy] --> DB1
style V1 fill:#7f1d1d,color:#fff
end
subgraph After["After"]
P2[production deploy] --> DB2[(rorbt_users)]
V2[preview deploy] --> DB3[(rorbt_users_preview)]
end
.dev.vars does not override [vars]
I assumed the usual precedence—local dotfile beats committed config, the convention every tool since dotenv has honored. It doesn’t hold here, and I didn’t take the docs’ word for it either way. I tested it directly: added BETTER_AUTH_URL to .dev.vars, started wrangler pages dev, and wrangler resolved the value from wrangler.toml anyway. The .dev.vars entry was ignored entirely.
.dev.vars supplies secrets that aren’t in the config. It does not shadow [vars].
This has a nasty second-order effect, and I inherited a live specimen of it. If you need different values locally—say, BETTER_AUTH_URL=http://localhost:8788 instead of your production origin—your only apparent option is editing the committed file and never staging the edit. Which is exactly what this repo had been doing: two lines held permanently unstaged, guarded by a project rule that wrangler.toml must never be committed. A workflow built entirely out of remembering not to do something. Those workflows have a failure rate, and the failure rate is a career.
The actual answer is the CLI:
// package.json
"dev:pages": "npm run build && wrangler pages dev packages/rorbt-ui/dist --port 8788 \
--binding BETTER_AUTH_URL=http://localhost:8788 \
--binding TURNSTILE_SITE_KEY=1x00000000000000000000AA"
--binding does override [vars]. You can tell it worked because wrangler stops printing the value and shows (hidden) instead—CLI-supplied bindings are treated as secrets in the startup banner, which is the closest this tool comes to a thumbs-up.
Now wrangler.toml holds production values only and is safe to commit, and the per-developer divergence lives in an npm script everyone shares. One change, and it deleted a workflow that had been quietly distorting this repo for months. Those are the best fixes: the ones that remove a rule instead of adding one.
Also worth knowing: the environment-scoped variant .dev.vars.preview is a Workers feature. wrangler pages dev reads exactly one file—.dev.vars—and --env-file is listed in --help but silently ignored for Pages. I verified this with a marker variable that never appeared in the bindings list, because by this point in the day I had stopped believing anything I couldn’t reproduce.
The comparison, honestly
| Kubernetes | Wrangler | |
|---|---|---|
| Config volume | ~400 lines YAML/service | ~40 lines TOML/project |
| Environments | Arbitrary namespaces | Pages: exactly 2. Workers: arbitrary |
| Inheritance | Kustomize overlays patch a base | None—repeat everything |
| Local parity | kind/minikube ≈ prod | Preview env untestable locally |
| Rollback | kubectl rollout undo | Dashboard, or redeploy |
| Secret rotation | External Secrets, sealed-secrets | wrangler secret put, manual |
| Debugging a running instance | kubectl exec | Nothing. Logs only |
| Scaling config | HPA, requests/limits, PDBs | Doesn’t exist |
| Time to first deploy | Days | Minutes |
I want to be fair to both columns, because I’ve spent real years living in the left one. Read it top to bottom and Kubernetes wins on capability almost every row. Then read the last row. That last row is not a small thing—the absence of the entire middle column is the product. The platform is worth the sharp edges precisely because the sharp edges are the whole list, and I just showed you all four of mine.
What I’d tell my past self
Read wrangler.toml as an IAM policy, not a config file. It’s the document that grants capability—every binding is a permission with a nicer haircut. Review it with that seriousness.
Check whether your preview environment exists before assuming it’s isolated. The default is shared-with-production, and nothing warns you. I was wrong about this for longer than I’d like to admit, and the only reason it cost me nothing is that I got curious before a preview branch got destructive.
Reach for --binding before editing committed config. Any per-developer divergence belongs in a script, not in an unstaged diff that everyone has silently agreed not to mention.
Expect the docs and the CLI to disagree. Two of the four edges above surfaced as error messages recommending flags that don’t exist.
Verify empirically. The platform moves faster than its help text.
Previous: Cloudflare vs AWS: The Mental Model That Doesn’t Transfer · Next: Pages Functions vs a Cluster of Microservices
Related
Observability on the Edge
Retrofitting structured, request-correlated logging onto Cloudflare Pages Functions: why winston won't run in workerd and pino loses its speed there, how AsyncLocalStorage carries request ids through 58 orphaned console.error calls without touching a signature, and a request-id scheme that survives system boundaries.
Six Security Findings from One Day of Code Review
One day of manual review on a Cloudflare governance platform: a CORS allowlist trusting an unclaimed pages.dev domain, hashed low-entropy tags posing as anonymization, a scalar trust score, GDPR erasure vs. an append-only hash chain, an HMAC unsubscribe token, and PII in logs. A scanner found none of them.
better-auth After Auth0
Replacing Auth0 with the better-auth library on Cloudflare Pages: sessions as joinable rows, per-request plugin composition, three distinct 403s (trusted origins, missing Origin headers from React Native, and a pages.dev hostname we didn't own), and the silent Secure-cookie trap.