zudo-cloudflare-wisdom
GitHub repository

Type to search...

to open search from anywhere

Wrangler Config

wrangler.toml configuration for Workers and Pages

Basic Structure

The wrangler.toml file configures your Cloudflare project. For Pages projects, it primarily defines bindings and compatibility settings:

# Cloudflare Pages project configuration
compatibility_date = "2024-12-01"

For standalone Workers, it also includes the entry point and routing:

name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-12-01"

TOML Table Scoping

wrangler.toml is plain TOML, and TOML's table rule applies here: once you write a table header like [vars], [assets], or [env.preview], every bare key = value line that follows belongs to that table -- not to the top level -- until the next [...] header appears. This bites people who append a field to the bottom of the file without checking which table they are currently inside:

[assets]
directory = "./dist"
binding = "ASSETS"

# Intended as a top-level setting, but it lands inside [assets] because
# no new table header appeared above it.
workers_dev = false

Wrangler does not silently accept the misplaced key as harmless noise -- it validates each table's known fields and reports what does not belong:

Unexpected fields found in assets field: "workers_dev"

That is a warning, not a hard error: the build still runs, but the field is dropped rather than applied where you meant it. workers_dev never takes effect, and the mistake is easy to miss in a long deploy log.

Keep top-level keys grouped above the first table header

List name, main, compatibility_date, compatibility_flags, workers_dev, and preview_urls together at the very top of the file, before any [...] table. Anything meant for the top level has to sit above every table header, not just the one you were last editing.

Bindings

Bindings connect your code to Cloudflare services.

KV Namespaces

[[kv_namespaces]]
binding = "MY_KV"
id = "abc123def456ghi789"

D1 Databases

[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "abc123-def456-ghi789"

R2 Buckets

[[r2_buckets]]
binding = "FILES"
bucket_name = "my-files"

Environment Variables

[vars]
API_ENDPOINT = "https://api.example.com"

For secrets (API keys, tokens), use wrangler secret put:

npx wrangler secret put MY_SECRET

Never Put Secrets in wrangler.toml

The [vars] section is for non-sensitive configuration only. Secrets should be set via wrangler secret put or the Cloudflare dashboard. wrangler.toml is committed to git.

Declaring Required Secrets

Secrets themselves never belong in wrangler.toml, but you can declare which secret names your Worker expects with a [secrets] table:

[secrets]
required = ["STRIPE_KEY", "SESSION_SECRET"]

This does not set values -- it is a checklist wrangler enforces at dev and deploy time:

  • wrangler dev: a missing required secret is a warning, not a hard stop -- Missing required secrets: STRIPE_KEY. Add them to .dev.vars, .env, or set as environment variables. The Worker still starts; the secret is simply absent from env.

  • First deploy of a new Worker: a missing required secret is a hard error that stops the deploy -- The following required secrets have not been set: STRIPE_KEY. Use wrangler secret put <NAME> to set secrets before deploying.

  • Redeploying an existing Worker: wrangler asks the API to inherit the secret's current value from the previous version instead of failing immediately. If the secret was truly never set, the API rejects the inherit and wrangler re-surfaces the same "required secrets have not been set" error -- the failure still happens, just one round-trip later.

--strict-vars is unrelated

--strict-vars (default true) is a flag for wrangler types -- it controls whether generated TypeScript types for [vars] are literal/union types or widened primitives. It has nothing to do with [secrets].required; the similar-sounding names describe two unrelated features.

[secrets

Like [vars] and bindings, this is Worker-level config that named environments don't see automatically. wrangler deploy --env preview checks only [env.preview.secrets] -- without that table, the required-secret validation above never runs for that environment, and a preview deploy with the secret unset succeeds silently.

[secrets]
required = ["STRIPE_KEY", "SESSION_SECRET"]

[env.preview.secrets]
required = ["STRIPE_KEY", "SESSION_SECRET"]

See the environments non-inheritance rule for the same trap applied to bindings and [vars].

wrangler deploy Replaces [vars] Wholesale

By default, every wrangler deploy deletes all vars currently live on the Worker and replaces them with exactly what wrangler.toml declares. If a var was added through the Cloudflare dashboard for a quick fix, the next config-driven deploy silently removes it -- there is no merge.

npx wrangler deploy --keep-vars

--keep-vars skips the delete-then-recreate step, leaving dashboard-set vars in place alongside whatever wrangler.toml declares. Use it whenever vars might have been set outside the config file. Secrets are unaffected either way -- wrangler deploy never deletes secrets, with or without this flag, so this trap is specific to [vars].

Check before you deploy

If you are not sure what is currently live, check the dashboard's Variables & Secrets tab before an environment's first config-driven deploy, so you know whether --keep-vars is needed.

Pages-Specific Options

For Pages projects, you can specify the build output directory:

pages_build_output_dir = "./dist"

The [cache] Table

[cache]
enabled = true

[cache] is a real, valid wrangler.toml table with exactly one allowed field, enabled (boolean) -- any other key inside it fails the same "Unexpected fields" check described above. It is newer and less documented than the other tables on this page, so generic TOML/JSON-schema linters in some editors flag it as an unknown key even though wrangler itself accepts it without complaint. Treat an editor's red squiggle under [cache] as a stale schema, not a real error -- confirm against wrangler deploy --dry-run (which validates the whole file) rather than the editor's underline.

Multiple Bindings Example

A real-world wrangler.toml from a project using KV, D1, and R2:

# Cloudflare Pages project configuration
compatibility_date = "2024-12-01"
pages_build_output_dir = "./dist"

[vars]
AUTH0_DOMAIN = "placeholder.us.auth0.com"
AUTH0_CLIENT_ID = "placeholder"

[[d1_databases]]
binding = "DB"
database_name = "my-app"
database_id = "placeholder"

[[r2_buckets]]
binding = "FILES"
bucket_name = "my-app-files"

[[kv_namespaces]]
binding = "CACHE"
id = "placeholder"

Placeholder IDs

Use placeholder values in wrangler.toml for database IDs and KV namespace IDs in source control. The actual IDs are environment-specific and should be managed per environment.

Named Environments & Service Bindings

Real projects rarely run with a single set of bindings. You typically want a preview deploy that points at throwaway data, a production deploy that points at the real data, and sometimes a staging deploy in between. Wrangler models this with named environments: [env.preview], [env.production], [env.staging]. You deploy a specific environment with --env:

npx wrangler deploy --env preview
npx wrangler deploy --env production

The #1 Silent Bug: bindings and [vars] Are NOT Inherited

This is the single most common way a multi-environment Worker breaks. Bindings (D1, R2, KV, AI, services) and [vars] declared at the top level do NOT carry into a named environment. When you deploy --env preview, the Worker sees only what is declared under [env.preview.*] — the top-level [vars] and bindings are silently dropped.

The Worker then runs in preview with no D1 connection, no KV, and an undefined API_ENDPOINT, often failing only at request time. Wrangler does emit a warning at deploy:

Processing wrangler.toml configuration:
  - "vars" exists at the top level, but not on "env.preview".
    This is not what you probably want, since "vars" is not inherited by environments.
    Please add "vars" to "env.preview".

The fix is to re-declare every binding and var inside each environment:

name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-12-01"

# Top-level [vars] is NOT inherited by named environments below.
# Re-declare it under each [env.*] or the Worker runs without it.
[vars]
API_ENDPOINT = "https://api.example.com"

[env.preview]
# Wrangler does NOT copy the top-level [vars] here. Without this block the
# preview Worker runs with API_ENDPOINT undefined.
[env.preview.vars]
API_ENDPOINT = "https://api-preview.example.com"

[[env.preview.kv_namespaces]]
binding = "CACHE"
id = "placeholder-preview-kv-id"

[env.production]
[env.production.vars]
API_ENDPOINT = "https://api.example.com"

[[env.production.kv_namespaces]]
binding = "CACHE"
id = "placeholder-production-kv-id"

Re-declare everything per environment -- except [assets

There is no partial inheritance for bindings, vars, or secrets. If [env.preview] exists, it must list its own D1, R2, KV, AI, services, [env.preview.vars], and [env.preview.secrets]. A missing binding does not error at deploy — it surfaces as a runtime undefined inside the Worker. A missing [env.preview.secrets] fails differently: the required-secret check from Declaring Required Secrets simply doesn't run for that environment, so the deploy succeeds even with the secret unset. The one exception is [assets] (Static Assets), covered below.

The one exception: [assets

Static Assets breaks the rule above. If the top level declares [assets] and [env.preview] does not declare its own [env.preview.assets], env.preview automatically uses the top-level block -- directory, binding, and all. Wrangler treats assets the same way it treats workers_dev: as a normal inheritable setting, not as a binding that needs re-declaring.

name = "my-site"
main = "./dist/_worker.js"
compatibility_date = "2024-12-01"

[assets]
directory = "./dist"
binding = "ASSETS"

# env.preview declares no [env.preview.assets] block at all,
# so it inherits the top-level [assets] above unchanged.
[env.preview]

The inheritance is all-or-nothing per environment: the moment [env.preview.assets] exists at all, even partially, it replaces the top-level block outright rather than merging with it -- so a partial override still needs every field you rely on (directory, binding, not_found_handling, run_worker_first).

Note

Non-binding settings such as workers_dev and preview_urls DO inherit from the top level into named environments. The non-inheritance rule applies to bindings, [vars], and [secrets] -- with the single exception of [assets] noted above. Setting things explicitly per environment is still common for legibility.

Per-Environment Data Isolation

Giving each environment its own database_id and bucket_name is what keeps staging traffic from touching production data. The bindings keep the same name (DB, BUCKET) so the Worker code is environment-agnostic — only the underlying resource changes:

name = "sync-server"
main = "src/index.ts"
compatibility_date = "2025-04-01"

[[d1_databases]]
binding = "DB"
database_name = "sync-db"
database_id = "placeholder-prod-d1-id"

[[r2_buckets]]
binding = "BUCKET"
bucket_name = "sync-blobs"

[ai]
binding = "AI"

# Staging points the SAME bindings (DB, BUCKET, AI) at SEPARATE resources,
# so staging never reads or writes production data.
[env.staging]
name = "sync-server-staging"

[[env.staging.d1_databases]]
binding = "DB"
database_name = "sync-db-staging"
database_id = "placeholder-staging-d1-id"

[[env.staging.r2_buckets]]
binding = "BUCKET"
bucket_name = "sync-blobs-staging"

[env.staging.ai]
binding = "AI"

To create the staging resources before referencing them:

npx wrangler d1 create sync-db-staging
npx wrangler r2 bucket create sync-blobs-staging

Then paste the returned database_id into [[env.staging.d1_databases]].

Service Bindings: One Worker Calling Another

A service binding lets one Worker invoke another Worker directly over Cloudflare's internal edge — no public internet hop, no DNS lookup, and no CORS, because the call never leaves Cloudflare's network. You bind a logical name to a deployed Worker's name:

[env.preview]

# Service binding: this preview Worker → the "image-resizer-preview" Worker.
# The caller reaches it via env.IMAGE_RESIZER without a public request.
[[env.preview.services]]
binding = "IMAGE_RESIZER"
service = "image-resizer-preview"

[[env.preview.services]]
binding = "NOTIFY_WORKER"
service = "notify-worker-preview"

[env.production]

# Same logical bindings, wired to the production target Workers.
[[env.production.services]]
binding = "IMAGE_RESIZER"
service = "image-resizer-prod"

[[env.production.services]]
binding = "NOTIFY_WORKER"
service = "notify-worker-prod"

Note that the binding name (IMAGE_RESIZER) is stable across environments while the service target swaps between -preview and -prod. Your code calls it like a fetch, but the request is dispatched internally:

// env.IMAGE_RESIZER is the service binding; this never hits the public internet.
const res = await env.IMAGE_RESIZER.fetch("https://internal/resize", {
  method: "POST",
  body: imageBytes,
});

Custom-Domain Routes on Production Only

Attach the apex and www domains only to the production environment with custom_domain = true, so preview deploys stay on their generated *.workers.dev URLs and never serve the real domain:

[env.production]

# Custom-domain routes — production environment only.
[[env.production.routes]]
pattern = "example.com"
custom_domain = true

[[env.production.routes]]
pattern = "www.example.com"
custom_domain = true

custom_domain = true tells Cloudflare to create and manage the DNS record and TLS cert for that hostname automatically, rather than matching an existing zone route pattern.

A named environment INHERITS top-level routes — and deploying it steals the domain

routes is an inheritable key. If the custom domain sits at the top level and a named environment does not override it, that environment inherits the production hostname. Wrangler says so plainly:

The "env.preview" environment inherits the top-level routes configuration, which includes the custom domain(s): example.com. Deploying this environment will reassign these custom domains away from the top-level Worker. Add "routes": [] to "env.preview" to prevent inheritance.

The fix is a one-line override on every non-production environment:

[env.preview]
routes = []

Why this hides. A preview pipeline built on wrangler versions upload never applies triggers, so the misconfiguration sits dormant and every deploy looks fine. Switch that pipeline to wrangler deploy --env preview later — a perfectly reasonable refactor — and the preview Worker silently takes production's hostname on the next run.

Verify with wrangler deploy --dry-run --env <name> and confirm it reports routes: []. Do not infer it from a green deploy.

Revision History

CreatedUpdated