Tutorials Cloudflare Feature Focus

wrangler.toml & .env.local: Config, Bindings, and Secrets

CloudflareCloudflare Feature Focus24 minJune 10, 2026Intermediate

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:

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:

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 typewrangler.toml blockReaches 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 values

This 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=false

Think 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/api

Precedence, 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:

Loading diagram…

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 wrangler var or secret — not only a .env.local entry. .env.local is 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 livesRead byIn git?Secret-safe?Reaches the browser?Use it for
wrangler.toml [vars]wrangler (deploy)✅ yes❌ noonly if your code sends itnon-secret config, feature flags
wrangler secret putwrangler (runtime, encrypted)n/a — in Cloudflare✅ yesonly if your code sends itAPI keys, signing secrets
.dev.varswrangler dev (local)❌ git-ignored✅ local onlynolocal mirror of secrets/vars
.env.local, NEXT_PUBLIC_*Next.js build❌ git-ignoredno — shipped to clientyespublic client config
.env.local, plain keysNext.js (server)❌ git-ignored✅ local onlynolocal server config
.envNext.js✅ usually❌ nodepends on prefixcommitted non-secret defaults

And the four questions to ask of any value:

  1. Is it secret? (Could a leak cost money / data / identity?) → secret store, never [vars] or NEXT_PUBLIC_.
  2. Does production need it at runtime, or just my laptop?wrangler var/secret vs .dev.vars/.env.local.
  3. Does the browser need it? → only then NEXT_PUBLIC_, and only if it's already public.
  4. Is it the Worker's config or the framework's?wrangler.toml vs .env.local.

Worked example: adding a Stripe key the right way

You need STRIPE_SECRET_KEY in the backend Worker. Walk the questions:

  1. Secret? Yes (it can charge cards). → secret store.
  2. Production runtime? Yes.wrangler secret put.
  3. Browser? No — it's server-only. → never NEXT_PUBLIC_.
  4. Worker or framework? The API Worker.saas/, not website-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

Challenges

  1. 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 D1 database_id, a MAINTENANCE_MODE flag, 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.
  2. Find a leak in any repo. In a project you have, run git log -p -- '*wrangler.toml' | grep -iE 'key|secret|token' and grep -rn NEXT_PUBLIC_ .env*. Did a secret ever land somewhere public? If so, write the rotation steps.
  3. 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?
  4. Reproduce the runtime gotcha. In a tiny Next-on-Workers app, put MY_TOKEN only in .env.local, read it in a server route, run it in next dev (works), then cf:build && cf:deploy and hit the deployed route. Explain the failure in one sentence, then fix it with wrangler secret put.

Key Points

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.

Ch 6: Workers AI — Free Inference at the EdgeComing Soon →
Course PlatformBuild a Course Platform on CloudflareBuild a paid video course platform with Cloudflare Workers, R2, D1, auth, Stripe, and paywalls.Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.WebUltimate Web Development SeriesWeb development tutorials for HTML, CSS, JavaScript, Next.js, Workers, databases, and production shipping.

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
5 free articles remainingSubscribe for unlimited access