You've now met every storage and compute primitive Cloudflare offers — Workers, R2, D1, KV, Durable Objects, Workers AI. Each one reached your code the same way: through a line in wrangler.toml and an env.SOMETHING handle. This chapter is about that wiring itself, and about its evil twin — the place secrets don't go.
Here's the problem every Cloudflare project hits around week two. Your config can live in four different places: wrangler.toml, wrangler secret put, a .dev.vars file, and a .env.local file. They look interchangeable. They are not. Get it wrong and you either leak a live API key into your public git history, ship a secret to every visitor's browser, or spend an afternoon wondering why env.STRIPE_SECRET_KEY is undefined in production when it "works on my machine."
This is the working guide to all four, grounded in this site's real backend (saas/wrangler.toml) and its Next.js front end. By the end you'll be able to look at any value — an API key, a database id, a CORS origin, a feature flag — and know instantly which file it belongs in.
What wrangler.toml actually is
wrangler.toml is the deploy manifest for a Worker. The wrangler CLI reads it to answer four questions: what is this Worker called, what code runs, where is it served, and what cloud resources may it touch. Here's the top of this site's real API Worker:
name = "simpleappshipper-api"
main = "src/index.js"
compatibility_date = "2024-12-01"
workers_dev = true
routes = [
{ pattern = "simpleappshipper.com/api/*", zone_name = "simpleappshipper.com" },
{ pattern = "www.simpleappshipper.com/api/*", zone_name = "simpleappshipper.com" },
]Read line by line:
name— the Worker's identity in your account. It also becomes the*.workers.devsubdomain (simpleappshipper-api.<account>.workers.dev).main— the entry script wrangler bundles and deploys.compatibility_date— pins the Workers runtime's behavior to a date, so a future platform change can't silently alter how your code runs. The companioncompatibility_flagsopts into specific features — the Next.js worker uses["nodejs_compat"]to get Node built-ins.workers_dev/routes— where the Worker answers.routesbinds it to real URLs (simpleappshipper.com/api/*); the front-end worker usescustom_domain = trueinstead to own the apex.
Bindings: how your code reaches D1, R2, and AI
The most important thing wrangler.toml does is declare bindings. A binding is a typed handle to a Cloudflare resource, injected into your Worker as a property of env. No connection string, no SDK auth, no endpoint URL — just env.NAME. Here are the three real bindings this site's backend uses:
[[d1_databases]]
binding = "DB"
database_name = "simpleappshipper-db"
database_id = "680bd509-4a31-4a80-97dd-2e4cdb10129f"
[[r2_buckets]]
binding = "SCREENS"
bucket_name = "simpleappshipper-releases"
[ai]
binding = "AI"The pattern is identical every time, and the key insight is the split between the two sides:
- The
bindingname (DB,SCREENS,AI) is the name your code uses —env.DB.prepare(...),env.SCREENS.put(...),env.AI.run(...). You choose it. - The other fields (
database_id,bucket_name) say which real resource the handle points at. Cloudflare resolves them at deploy.
So env.DB (D1, Ch 3), env.SCREENS (R2, Ch 2), and env.AI (Workers AI, Ch 6) are all just bindings declared here and consumed in src/index.js.
| Binding type | wrangler.toml block | Reaches your code as |
|---|---|---|
| D1 database | [[d1_databases]] | env.DB |
| R2 bucket | [[r2_buckets]] | env.SCREENS |
| Workers AI | [ai] | env.AI |
| KV namespace | [[kv_namespaces]] | env.MY_KV |
| Queue / Durable Object / service | [[queues...]], [[durable_objects...]] | env.MY_QUEUE, etc. |
[vars] — plaintext config, in the open
[vars] declares environment variables that are baked into the deployed Worker as plaintext and read in code as env.VAR_NAME. Here are this site's real ones:
[vars]
CORS_ORIGIN = "https://simpleappshipper.com"
WEB_ORIGIN = "https://simpleappshipper.com"
# When "true", the */5 cron tops up under-stocked scenes via Workers AI Flux.
# Flip to "false" to pause the spend without redeploying code.
PREGEN_ENABLED = "true"PREGEN_ENABLED is a lovely example of what [vars] is for: a feature flag you can flip in the dashboard to pause a cron's spend without touching code. CORS_ORIGIN is configuration that's meaningful but not sensitive — the whole world already knows this site's origin.
Secrets — what does not go in wrangler.toml
Secrets are set with wrangler secret put NAME, which prompts for the value and stores it encrypted in Cloudflare. The value is never written to a file, never committed, and never shown again — but at runtime it appears on env exactly like a var:
cd saas
wrangler secret put STRIPE_SECRET_KEY
# ? Enter a secret value: ******** (encrypted and stored; not echoed, not in git)
wrangler secret list # shows the NAMES only, never the valuesThis site's backend leans on a dozen of them. Notice how wrangler.toml documents them — by name only, as a comment — so the next developer knows what to set without any value ever touching the repo:
# Secrets (set via `wrangler secret put <NAME>`):
# STRIPE_SECRET_KEY — Stripe REST API
# STRIPE_WEBHOOK_SECRET — Stripe webhook signature verification
# GOOGLE_CLIENT_SECRET — OAuth 2.0 client secret
# JWT_SECRET — signs sas_session JWTs
# OPENROUTER_API_KEY — fallback for /api/ai/vision
# ...The beautiful part: in your code, a var and a secret are both just env.X.
// env.CORS_ORIGIN came from [vars]; env.STRIPE_SECRET_KEY came from `wrangler secret put`.
// The handler can't tell the difference — and shouldn't have to.
const origin = env.CORS_ORIGIN;
const stripe = new Stripe(env.STRIPE_SECRET_KEY);Your handler doesn't care where a value was stored; only the visibility differs. That's the whole design.
.dev.vars — your local stand-in for production secrets
There's a gap the two sections above leave open: when you run wrangler dev on your laptop, there are no Cloudflare-stored secrets injected — those live in the deployed environment. So how does env.STRIPE_SECRET_KEY work locally?
That's what .dev.vars is for. It's a KEY=value file that wrangler dev reads and injects as env.X, purely for local development:
# saas/.dev.vars — git-ignored; local only; use TEST keys
STRIPE_SECRET_KEY=sk_test_51FAKEdevkeyForLocalOnly
JWT_SECRET=any-long-random-string-for-dev
OPENROUTER_API_KEY=sk-or-fake-local-key
PREGEN_ENABLED=falseThink of .dev.vars as the local mirror of wrangler secret put (plus any [vars] you want to override in dev). This repo's .gitignore already excludes .env* and .wrangler/, so it never gets committed — but the responsibility is yours: use test keys here, never live ones.
.env.local — the framework's env file, not Cloudflare's
Here's the one that confuses everyone, because it looks like the others but is read by a completely different tool. .env.local is your front-end framework's env file — Next.js here — and wrangler never reads it. Next.js auto-loads .env, .env.local, and friends for next dev and next build. Two rules matter more than all the rest:
# website-next/.env.local — git-ignored; read by Next.js, NOT by wrangler
#
# 1) Server-only — readable in server code as process.env.ADMIN_EMAIL, never shipped to the browser:
ADMIN_EMAIL=you@example.com
#
# 2) NEXT_PUBLIC_* — INLINED INTO THE CLIENT BUNDLE at build time → shipped to EVERY visitor:
NEXT_PUBLIC_API_BASE=https://simpleappshipper.com/apiPrecedence, briefly: .env.local overrides .env. The convention is to commit a .env with non-secret defaults and keep .env.local (git-ignored) for secrets and per-machine overrides.
The twist that bites everyone: build-time vs runtime on Cloudflare
This site's front end is Next.js compiled to a Worker via OpenNext (the deploy story). That means there are two different env worlds, and conflating them is the single most common Next-on-Cloudflare bug:
Figure 1 — .env.local lives only on your machine and at build time; it is never uploaded. The deployed Worker gets its runtime env from wrangler.toml + wrangler secret put + bindings.
The practical rule that falls out of this:
If your deployed site needs a value at runtime (a server-side API key, a signing secret), it must be a
wranglervar or secret — not only a.env.localentry..env.localis git-ignored and stays on your laptop, so the production Worker never sees it.
This is exactly why env.X undefined "only in production" happens: the value was in .env.local, worked in next dev, and was never set as a Worker secret. The fix is wrangler secret put X (on the front-end worker) — or moving the call into the API Worker that already has the secret.
The one-screen mental model
Pin this table somewhere. It answers "where does this value go?" for every case:
| Where it lives | Read by | In git? | Secret-safe? | Reaches the browser? | Use it for |
|---|---|---|---|---|---|
wrangler.toml [vars] | wrangler (deploy) | ✅ yes | ❌ no | only if your code sends it | non-secret config, feature flags |
wrangler secret put | wrangler (runtime, encrypted) | n/a — in Cloudflare | ✅ yes | only if your code sends it | API keys, signing secrets |
.dev.vars | wrangler dev (local) | ❌ git-ignored | ✅ local only | no | local mirror of secrets/vars |
.env.local, NEXT_PUBLIC_* | Next.js build | ❌ git-ignored | ❌ no — shipped to client | ✅ yes | public client config |
.env.local, plain keys | Next.js (server) | ❌ git-ignored | ✅ local only | no | local server config |
.env | Next.js | ✅ usually | ❌ no | depends on prefix | committed non-secret defaults |
And the four questions to ask of any value:
- Is it secret? (Could a leak cost money / data / identity?) → secret store, never
[vars]orNEXT_PUBLIC_. - Does production need it at runtime, or just my laptop? →
wranglervar/secret vs.dev.vars/.env.local. - Does the browser need it? → only then
NEXT_PUBLIC_, and only if it's already public. - Is it the Worker's config or the framework's? →
wrangler.tomlvs.env.local.
Worked example: adding a Stripe key the right way
You need STRIPE_SECRET_KEY in the backend Worker. Walk the questions:
- Secret? Yes (it can charge cards). → secret store.
- Production runtime? Yes. →
wrangler secret put. - Browser? No — it's server-only. → never
NEXT_PUBLIC_. - Worker or framework? The API Worker. →
saas/, notwebsite-next/.
So:
# Local development — a TEST key in the git-ignored local file:
echo 'STRIPE_SECRET_KEY=sk_test_yourTestKey' >> saas/.dev.vars
# Production — the live key, encrypted in Cloudflare, never in git:
cd saas && wrangler secret put STRIPE_SECRET_KEY # paste sk_live_… at the prompt// Code is identical in dev and prod — env.X abstracts the storage away:
const stripe = new Stripe(env.STRIPE_SECRET_KEY);Contrast a non-secret: the CORS origin. Secret? No. So it's a [vars] line in wrangler.toml, committed, and that's correct — exactly what this site does.
Common mistakes, from production
- A secret in
[vars]. It's now in git history and the dashboard. Don't just delete the line — the value is in past commits forever. Rotate the key. NEXT_PUBLIC_on a secret. Rungrep -r NEXT_PUBLIC_ .env*and read every hit as "this is in every visitor's browser." If a real secret is there, rotate it and move it server-side.- Expecting
.env.localto work in production. It isn't deployed. Usewrangler secret put(or a[vars]entry for non-secrets). - Forgetting
.dev.varsis git-ignored. A teammate'swrangler devcrashes onenv.X undefined. Ship a committed.dev.vars.example. - Editing
[vars]and expecting the live Worker to notice. Config changes ship only onwrangler deploy(orcf:deploy). Nothing is live until you redeploy. - Renaming a binding in the toml but not in code. Change
binding = "DB"to"DATABASE"and everyenv.DBis suddenlyundefined. The toml name and theenv.name are one contract.
Challenges
- Classify ten values. Take this list —
JWT_SECRET, a Google OAuth client id, a Google OAuth client secret,CORS_ORIGIN, a Stripe publishable key, a Stripe secret key, your D1database_id, aMAINTENANCE_MODEflag,NEXT_PUBLIC_SITE_URL, an ElevenLabs API key — and put each in exactly one of:[vars],wrangler secret put,.env.local(NEXT_PUBLIC_), or.env.local(server). Justify the two that are closest calls. - Find a leak in any repo. In a project you have, run
git log -p -- '*wrangler.toml' | grep -iE 'key|secret|token'andgrep -rn NEXT_PUBLIC_ .env*. Did a secret ever land somewhere public? If so, write the rotation steps. - Write the
.dev.vars.example. For this site's backend, produce the committed example file: every key from the toml's secret comment, with safe dummy values and a one-line comment each. What belongs in it, and what must never? - Reproduce the runtime gotcha. In a tiny Next-on-Workers app, put
MY_TOKENonly in.env.local, read it in a server route, run it innext dev(works), thencf:build && cf:deployand hit the deployed route. Explain the failure in one sentence, then fix it withwrangler secret put.
Key Points
wrangler.tomlis committed config — Worker identity, runtime, routes, bindings, and non-secret[vars]. Because it's in git, no secret ever goes in it.- Bindings (
[[d1_databases]],[[r2_buckets]],[ai], …) turn a config block into anenv.NAMEhandle. Thebindingname is your code's contract; the ids/names are not secrets. [vars]is plaintext and public. Feature flags and non-sensitive config only.- Secrets use
wrangler secret put— encrypted in Cloudflare, never in a file, surfaced asenv.Xat runtime. In code, vars and secrets are indistinguishable; only their storage differs. .dev.varsis the git-ignored local mirror forwrangler dev..env.localis the framework's git-ignored env file — a different tool entirely.NEXT_PUBLIC_ships to every browser. Never prefix a secret with it.- Build-time ≠ runtime on Cloudflare.
.env.localisn't deployed; anything production needs at runtime must be awranglervar or secret. That's the cure for "undefined only in production."
The toolkit, assembled and secured
That closes the loop on the whole series. You've seen the six primitives — Workers, R2, D1, KV, Durable Objects, and Workers AI — and now the config layer that wires every one of them: each binding you learned is a block in wrangler.toml, each external service a secret set with wrangler secret put, each knob a [vars] flag, and the line between "in git" and "in the browser" is a line you can now draw in your sleep.
If you want to see all of it in one place, the backend that this chapter quotes is open source: saas/wrangler.toml declares the bindings, documents the secrets by name, and flags the cron — and saas/src/index.js consumes every one of them through nothing but env.
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper