Compute · Workers

Wire a Worker to storage with bindings

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.

What a binding actually is

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.

Why this matters more than it looks like it should. Because the binding name is just a string you choose, the same Worker code can point at different real resources in different environments (dev vs. staging vs. production) purely by changing config — no code diff between environments. That's the whole point of separating bindings from code.

Worked example: one Worker, two bindings

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.

1. Create the resources

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.

2. Declare both bindings in wrangler.toml

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.

3. Type env and use both bindings

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.

Environment variables vs. secrets

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.

Local dev secrets. 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.

Common pitfall: bindings differ between local dev and deployed

"It works in wrangler dev but not in production" (or vice versa) is almost always a bindings mismatch, not a code bug. Three separate gotchas compound here: Whenever a binding looks "missing," check which environment and which storage mode (local vs. remote) you're actually running against before you go looking for a bug in your handler code.

Pricing

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.

Use cases

Primary source

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.

You define [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?
Without scrolling up: what's the actual difference between a vars entry and a secret, given that both end up on env the same way in your code?
Reveal

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.

Anything above unclear — how a specific binding type's client API looks, or how far the local/remote dev split extends to D1 or Durable Objects? Ask your AI teacher before moving on; the bindings pattern here is the foundation for every storage-focused lesson that follows.
← Previous: Deploy your first Worker Next: Routes, custom domains, and middleware patterns in Workers →