After this lesson you'll be able to declare KV and R2 bindings in your Wrangler config, read and write both from a single Worker via env, and tell the difference between a plain environment variable and a secret.
Every lesson so far has treated a Worker as a self-contained function. Real Workers almost never are — they read and write data somewhere. Bindings are how a Worker gets access to that "somewhere" — a KV namespace, an R2 bucket, a D1 database, a Durable Object, a Queue — without you ever handling an API key, endpoint URL, or SDK client yourself. You declare the binding in config, and Cloudflare hands your Worker a ready-to-use object at runtime.
A binding is a named connection between your Worker's code and a resource, configured outside the code, in wrangler.toml (or wrangler.jsonc). Cloudflare's own framing is useful here: a binding is a permission and an API in one piece. You never construct a client, never pass credentials, never hardcode a resource ID in your source — the runtime resolves the binding name to the actual resource and injects it into your handler. This is deliberate: it means secrets for that resource never need to live in your code at all.
Bindings show up in your Worker as properties on the env object:
export default {
async fetch(request, env, ctx) {
// env.MY_KV, env.MY_BUCKET, env.MY_DB, etc. — all defined by config, not code
},
};
env is the second argument to the fetch handler. Every binding you declare in config appears on it, typed as whatever that resource's client looks like — a KVNamespace, an R2Bucket, a D1Database, a Durable Object namespace, a Queue producer, and so on. Cloudflare currently supports 25+ binding types across storage, compute, media, AI, and security — the mechanism is the same for all of them, only the shape of the object you get back differs.
Say you're building an upload endpoint: metadata (filename, uploader, timestamp) goes in KV for fast lookup, the file bytes go in R2. One Worker, two bindings.
npx wrangler kv namespace create UPLOADS_META
npx wrangler r2 bucket create uploads-bucket
The KV command prints an id — copy it, you need it in config next. The R2 command doesn't need an ID; buckets are referenced by name.
name = "uploads-worker"
main = "src/index.ts"
compatibility_date = "2026-06-01"
[[kv_namespaces]]
binding = "UPLOADS_META"
id = "<the id printed above>"
[[r2_buckets]]
binding = "UPLOADS_BUCKET"
bucket_name = "uploads-bucket"
The binding field is the name that appears on env — it can differ from the underlying resource's real name. id (KV) and bucket_name (R2) point at the actual resource.
interface Env {
UPLOADS_META: KVNamespace;
UPLOADS_BUCKET: R2Bucket;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const key = url.pathname.slice(1);
if (request.method === "PUT") {
await env.UPLOADS_BUCKET.put(key, request.body);
await env.UPLOADS_META.put(
key,
JSON.stringify({ uploadedAt: Date.now() })
);
return new Response(`Stored ${key}`);
}
const meta = await env.UPLOADS_META.get(key, "json");
if (!meta) return new Response("Not found", { status: 404 });
const object = await env.UPLOADS_BUCKET.get(key);
if (!object) return new Response("Blob missing", { status: 404 });
return new Response(object.body, {
headers: { "x-uploaded-at": String((meta as { uploadedAt: number }).uploadedAt) },
});
},
};
npx wrangler dev
# in another terminal:
curl -X PUT http://localhost:8787/report.pdf --data-binary @report.pdf
curl http://localhost:8787/report.pdf -o out.pdf -D -
That's the whole pattern for every storage binding: declare it in config, type it on Env, call methods on env.<BINDING_NAME>. D1, Durable Objects, and Queues follow the identical shape with different config blocks (d1_databases, durable_objects, queues.producers) and different client APIs — covered in their own lessons.
Plain config values that aren't storage resources go under vars:
[vars]
API_BASE_URL = "https://api.example.com"
FEATURE_FLAG_NEW_UI = "true"
These are visible in plaintext in your Wrangler config, in the dashboard, and via wrangler.toml if it's committed to git. Fine for non-sensitive config; wrong for anything sensitive. For secrets — API keys, tokens, signing keys — use:
npx wrangler secret put STRIPE_API_KEY
# prompts for the value, then encrypts and stores it —
# never appears in wrangler.toml, the dashboard, or wrangler output afterward
Both vars and secrets end up on the same env object at runtime — env.STRIPE_API_KEY works identically to env.API_BASE_URL in your code. The difference is entirely in how the value is stored and who can see it afterward, not in how you access it.
wrangler secret put writes to Cloudflare's deployed environment, not your local machine. For wrangler dev, put secrets in a .dev.vars file in your project root (dotenv syntax: STRIPE_API_KEY=sk_test_...) and keep it out of git. Wrangler loads it automatically in local dev and merges it onto env.
wrangler dev but not in production" (or vice versa) is almost always a bindings mismatch, not a code bug. Three separate gotchas compound here:
wrangler dev reads and writes a local, on-disk simulation of KV/R2/D1 — completely separate data from what's actually in your Cloudflare account. Writing a key locally does not put it in the real namespace. Use wrangler dev --remote if you need to hit the real resource while developing.[env.staging] or [env.production] in your config, bindings are not inherited from the top level — each named environment needs its own kv_namespaces/r2_buckets/etc. block, even if it points at the same resource. Forgetting this means your production deploy silently has no binding at all, and env.MY_KV is undefined at runtime.wrangler secret put targets one environment at a time. Deploying a new named environment for the first time means re-running every secret put for it — secrets don't copy over automatically.Bindings themselves are free — you pay for the underlying resource's usage, not for the connection. As of this writing:
Exact per-operation and per-GB rates change over time and differ by access tier — treat any specific dollar figure here as approximate and verify against the live pricing page before estimating a real bill: see Workers Platform pricing.
wrangler secret put rather than in vars.[env.staging] / [env.production] block, so a bug in staging can't touch production data, using the exact same Worker code.Workers Runtime APIs — Bindings is Cloudflare's canonical reference for what bindings are and the full list of supported types; it's the page to re-check as new binding types ship.
[env.production] in wrangler.toml with a new vars block, but you don't repeat your top-level [[kv_namespaces]] binding under it. What happens when you deploy to production?vars entry and a secret, given that both end up on env the same way in your code?vars values are stored and displayed in plaintext — visible in wrangler.toml, the dashboard, and Wrangler's own output. Secrets (set via wrangler secret put) are encrypted at rest and never shown again after you set them, in either the dashboard or the CLI. Access in code is identical (env.NAME) — the difference is entirely about where the sensitive value is allowed to be visible.