---
title: 'Cloudflare Workers + R2 Image CDN: 2026 Tutorial'
description: 'Hands-on 2026 tutorial: build a production image CDN with Cloudflare Workers + R2 — IMAGES binding, edge caching, signed URLs, AVIF/WebP negotiation.'
url: https://nerdleveltech.com/cloudflare-workers-r2-image-cdn-tutorial
language: en
date: '2026-05-08'
category: cloud-devops
tags:
  - cloudflare
  - cloudflare-workers
  - r2
  - image-cdn
  - image-resizing
  - edge-computing
  - typescript
  - cdn
  - performance
markdown_url: https://nerdleveltech.com/cloudflare-workers-r2-image-cdn-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/cloudflare-workers-r2-image-cdn-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Cloudflare Workers + R2 Image CDN: 2026 Tutorial

To serve resized images from R2 with a Cloudflare Worker, store the originals in a private R2 bucket, read them through the bucket binding, and pipe the bytes through `env.IMAGES.input(...).transform(...).output(...)` for on-the-fly resizing. Cache each variant at the edge with the Workers Cache API and the Worker becomes your image CDN — no Sharp, no Lambda, no extra origin.

## TL;DR

- Originals live in a private R2 bucket; the Worker is the only public route.
- The Workers `IMAGES` binding resizes, re-encodes, and content-negotiates AVIF/WebP/JPEG against the `Accept` header.
- The Cache API stores each variant for 30 days at the data-center edge, so the second request never hits R2 or the transform engine.
- An HMAC-SHA-256 signature on the URL stops attackers from generating millions of unique variants and burning your transform budget.
- Total cost on the Free plan for ~5,000 unique variants/month: **$0**.[^1][^2]

## What you'll learn

- How to configure an R2 bucket binding and Images binding in `wrangler.toml` for Wrangler 4
- How to apply on-the-fly transforms from a Worker fetch handler
- How to negotiate AVIF/WebP/JPEG with the `Accept` header and `Vary`
- How to cache variants at the edge with the Workers Cache API
- How to sign transform URLs with WebCrypto HMAC-SHA-256 to block parameter abuse
- How to return conditional `304 Not Modified` responses with ETag

## Prerequisites

| Item | Pinned version | Notes |
|---|---|---|
| Node.js | 22.0.0 or later | Wrangler 4 dropped Node 20 support after the 2026-04-30 EOL[^3] |
| Wrangler CLI | 4.88.0 | Pinned to a release ≥14 days old that already requires Node ≥22 (Wrangler bumped its minimum Node version to 22 at 4.87.0); the latest is 4.90.0 but pin the lockfile to whatever you `npm install` |
| TypeScript | 5.5.x | Bundled by `npm create cloudflare@latest` |
| Cloudflare account | Free or above | Image Transformations are available on every plan, including Free, with 5,000 unique transforms/month included on the account[^4] |
| Cloudflare zone | proxied (orange cloud) | Required so the dashboard can flip "Enable for zone" under Images > Transformations[^5] |

You'll also want `curl` and a couple of sample JPEGs (1024×768 or larger so we can see resizing actually work).

## Step 1 — Scaffold the Worker

Create the project with C3 (the official `create-cloudflare` CLI). Pick TypeScript, the Hello World template, no front-end framework, and skip Git initialization if you already have a parent repo:

```bash
npm create cloudflare@latest cf-image-cdn -- \
  --type=hello-world \
  --lang=ts \
  --no-deploy \
  --no-git
cd cf-image-cdn
```

Pin Wrangler explicitly so future patch releases don't break your build:

```bash
npm install --save-dev wrangler@4.88.0
```

Verify:

```bash
npx wrangler --version
# ⛅️ wrangler 4.88.0
node --version
# v22.x.x
```

If `node --version` shows v20 or older, upgrade — Wrangler 4 will refuse to start.

## Step 2 — Create the R2 bucket and upload originals

Create a bucket from the CLI (the name is global within your account, lower-case, hyphens OK):

```bash
npx wrangler r2 bucket create cf-image-cdn-originals
```

Drop a couple of test images in:

```bash
npx wrangler r2 object put cf-image-cdn-originals/sunset.jpg \
  --file=./samples/sunset.jpg
npx wrangler r2 object put cf-image-cdn-originals/portrait.jpg \
  --file=./samples/portrait.jpg
```

R2's Free tier covers 10 GB of storage, 1 million Class A operations (writes), and 10 million Class B operations (reads) per month, with zero egress charges across all tiers — so this whole tutorial runs at $0 unless you exceed those limits.[^1]

## Step 3 — Bind the bucket in wrangler.toml

Replace the contents of `wrangler.toml` (or `wrangler.jsonc` if C3 generated JSON — both work). The TOML below uses the current `r2_buckets` schema verified against today's Wrangler config docs:[^6]

```toml
name = "cf-image-cdn"
main = "src/index.ts"
compatibility_date = "2026-05-06"

# R2 bucket holding the original images
[[r2_buckets]]
binding = "ORIGINALS"
bucket_name = "cf-image-cdn-originals"

# Cloudflare Images binding (no key required; billed against your account)
[images]
binding = "IMAGES"

# HMAC signing secret for transform URLs.
# Set with: npx wrangler secret put SIGNING_KEY
# (use a 32-byte random value generated with `openssl rand -hex 32`)

[vars]
ALLOWED_WIDTHS = "320,640,960,1280,1920"
DEFAULT_QUALITY = "82"
MAX_AGE_BROWSER = "604800"     # 7 days
MAX_AGE_EDGE    = "2592000"    # 30 days
```

Generate the runtime types so TypeScript knows about `env.ORIGINALS`:

```bash
npx wrangler types
```

That writes `worker-configuration.d.ts` based on your bindings and compatibility date — the modern replacement for adding `@cloudflare/workers-types` by hand.[^7]

## Step 4 — Read images from R2

Wire a minimal fetch handler that streams the original bytes back. This proves the binding works before we add transforms:

```ts
// src/index.ts
export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    const key = url.pathname.replace(/^\/+/, "");
    if (!key) return new Response("Missing key", { status: 400 });

    const obj = await env.ORIGINALS.get(key);
    if (!obj) return new Response("Not found", { status: 404 });

    const headers = new Headers();
    obj.writeHttpMetadata(headers);
    headers.set("etag", obj.httpEtag);
    return new Response(obj.body, { headers });
  },
} satisfies ExportedHandler<Env>;
```

`R2ObjectBody.writeHttpMetadata()` copies `Content-Type`, `Content-Encoding`, and friends from the stored object onto your response headers, and `obj.httpEtag` already contains the surrounding quotes the HTTP spec wants.[^8] Run it locally:

```bash
npx wrangler dev
# Then in another shell:
curl -sI http://127.0.0.1:8787/sunset.jpg | head -5
```

You should see a `200`, the original `Content-Type`, and an `etag`. Wrangler's local R2 emulator (Miniflare) serves files from `.wrangler/state/` so you can iterate offline before publishing.[^9]

## Step 5 — Add image transforms via the IMAGES binding

This is the heart of the tutorial. The Workers `IMAGES` binding takes a stream of bytes, applies width/height/format transforms inside Cloudflare's image engine, and hands you back a transformed stream — no need for a public R2 bucket or a separate origin Worker:[^10]

```ts
// src/index.ts (replace the previous handler)
type Format = "image/avif" | "image/webp" | "image/jpeg";

function pickFormat(accept: string | null): Format {
  if (accept?.includes("image/avif")) return "image/avif";
  if (accept?.includes("image/webp")) return "image/webp";
  return "image/jpeg";
}

function parseWidth(url: URL, env: Env): number {
  const allowed = env.ALLOWED_WIDTHS.split(",").map(Number);
  const width = Number(url.searchParams.get("w") ?? "");
  if (!allowed.includes(width)) {
    throw new Response(`width must be one of ${env.ALLOWED_WIDTHS}`, { status: 400 });
  }
  return width;
}

export default {
  async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(req.url);
    const key = url.pathname.replace(/^\/+/, "");
    if (!key) return new Response("Missing key", { status: 400 });

    let width: number;
    try {
      width = parseWidth(url, env);
    } catch (e) {
      if (e instanceof Response) return e;
      throw e;
    }

    const obj = await env.ORIGINALS.get(key);
    if (!obj) return new Response("Not found", { status: 404 });

    const format = pickFormat(req.headers.get("accept"));
    const result = (
      await env.IMAGES
        .input(obj.body)
        .transform({ width, fit: "scale-down", metadata: "none" })
        .output({ format, quality: Number(env.DEFAULT_QUALITY) })
    ).response();

    const headers = new Headers();
    headers.set("content-type", format);
    headers.set("vary", "Accept");                    // per-format browser caches
    headers.set("etag", `W/"${obj.httpEtag.replace(/"/g, "")}-${width}-${format}"`);
    return new Response(result.body, { status: 200, headers });
  },
} satisfies ExportedHandler<Env>;
```

Three details to internalize:

- **`fit` modes:** `scale-down`, `contain`, `cover`, `crop`, `pad`. `scale-down` is the safest default — it never upscales smaller originals.
- **`format`** must be one of `image/avif`, `image/webp`, `image/jpeg`, or `image/png`. Unlike the URL-based transform path, the binding has no `auto` shortcut — you do content negotiation in code. That's why we read the request's `Accept` header in `pickFormat()` and emit a `Vary: Accept` header on the response: without `Vary`, browsers and intermediate caches would serve the AVIF bytes to a JPEG-only client.[^10]
- **`metadata: "none"`** strips EXIF and XMP from the output, which is usually what you want for a public CDN.

The 128 MB Worker memory limit applies — by piping `obj.body` (a `ReadableStream`) directly into `env.IMAGES.input(...)`, we never buffer the original or the output in memory.[^11]

## Step 6 — Cache image variants on the edge

Are Cloudflare Image Transformations free? **Yes — for the first 5,000 unique transforms per month per account.** Beyond that, $0.50 per 1,000.[^4] One important caveat for Workers: every call to the `IMAGES` binding counts as a billable transform — the per-month deduplication that applies to URL-based transforms is bypassed when you use the binding.[^12] The Cache API is what keeps that bill from spiraling.

The Cache API behaves like an ephemeral key-value store keyed on the request URL.[^13] Wrap the transform call in a cache check so we only invoke the binding on misses:

```ts
// Add inside fetch(), AFTER parsing the width and BEFORE reading R2.
// Build a cache key that includes the negotiated format so AVIF and WebP don't collide.
const fmt = pickFormat(req.headers.get("accept"));
const keyURL = new URL(url.toString());
keyURL.searchParams.set("_fmt", fmt);
const cacheKey = new Request(keyURL.toString(), { method: "GET" });
const cache = caches.default;

const cached = await cache.match(cacheKey);
if (cached) {
  // Short-circuit conditional requests with a 304.
  const inm = req.headers.get("if-none-match");
  if (inm && cached.headers.get("etag") === inm) {
    return new Response(null, { status: 304, headers: cached.headers });
  }
  return cached;
}

// ... read R2 + invoke env.IMAGES as in Step 5 ...

// Build the response with caching headers and tee the stream so we
// can both return it AND store it in the cache.
const response = new Response(result.body, {
  status: 200,
  headers: {
    "content-type": format,
    "etag": `W/"${obj.httpEtag.replace(/"/g, "")}-${width}-${format}"`,
    "vary": "Accept",
    "cache-control":
      `public, max-age=${env.MAX_AGE_BROWSER}, s-maxage=${env.MAX_AGE_EDGE}, immutable`,
  },
});

const [browserStream, cacheStream] = response.body!.tee();
ctx.waitUntil(
  cache.put(cacheKey, new Response(cacheStream, { headers: response.headers })),
);
return new Response(browserStream, { headers: response.headers });
```

Two things to internalize:

1. **`caches.default` is data-center scoped.** A request that lands in IAD won't see a variant cached in CDG. That's expected — at the cost of an extra transform per data center, you get the lowest possible read latency on cache hits.[^13]
2. **Use `ctx.waitUntil()` to write to the cache.** It lets the Worker return the response to the browser without blocking on the cache write.[^13]

## Step 7 — Sign URLs to block transform abuse

Without signing, anyone can hit `?w=320`, `?w=321`, `?w=322`, …, `?w=10000` and burn through your monthly transform budget in minutes. Two layers of defense:

1. The allow-list in `parseWidth` (Step 5) rejects any `w=` outside `ALLOWED_WIDTHS`.
2. An HMAC-SHA-256 signature on the URL ensures only the keys we mint are honored.

Generate a strong signing key once and store it as a secret:

```bash
node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'
# paste the output into the next command
npx wrangler secret put SIGNING_KEY
```

Update the type so `env.SIGNING_KEY` is known:

```bash
npx wrangler types
```

Add the signature check (inside `fetch`, before reading R2):

```ts
async function verifyHmac(env: Env, key: string, params: string, sig: string): Promise<boolean> {
  const enc = new TextEncoder();
  const cryptoKey = await crypto.subtle.importKey(
    "raw",
    enc.encode(env.SIGNING_KEY),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"],
  );
  const sigBytes = Uint8Array.from(
    sig.match(/.{1,2}/g)!.map((b) => parseInt(b, 16)),
  );
  return crypto.subtle.verify(
    "HMAC",
    cryptoKey,
    sigBytes,
    enc.encode(`${key}?${params}`),
  );
}
```

Then in the handler — **before the cache lookup or any R2 read**, so an attacker can't get a cached response with a forged signature:

```ts
const sig = url.searchParams.get("sig") ?? "";
const params = `w=${url.searchParams.get("w")}`;
if (!sig || !(await verifyHmac(env, key, params, sig))) {
  return new Response("bad signature", { status: 403 });
}
```

To mint a URL from your CMS or build pipeline (Node 22+):

```js
// scripts/sign-url.mjs
import { createHmac } from "node:crypto";
const SIGNING_KEY = process.env.SIGNING_KEY;
const key = "sunset.jpg";
const params = "w=640";
const sig = createHmac("sha256", SIGNING_KEY)
  .update(`${key}?${params}`)
  .digest("hex");
console.log(`https://images.example.com/${key}?${params}&sig=${sig}`);
```

The Worker only honors URLs your build pipeline generated, so the cache-key space is bounded by `(image × allowed widths × formats)` rather than infinite.

## Step 8 — Deploy and verify

```bash
npx wrangler deploy
```

Wrangler prints the worker's public URL (something like `cf-image-cdn.<your-subdomain>.workers.dev`) plus any custom routes you've configured. Verify the happy path with `curl -v`:

```bash
SIG=$(node scripts/sign-url.mjs | sed 's/.*sig=//')
curl -sI \
  -H 'accept: image/avif,image/webp,image/*'  \
  "https://cf-image-cdn.<sub>.workers.dev/sunset.jpg?w=640&sig=${SIG}" \
  | grep -E '^(HTTP|content-type|content-length|cf-cache-status|vary|etag)'
```

A first request prints `cf-cache-status: MISS`, returns `content-type: image/avif` (assuming a modern browser-equivalent `Accept` header), and reports a `content-length` substantially smaller than the original JPEG — typically 30–50% smaller for AVIF and 20–35% for WebP at the same perceptual quality. A second identical request prints `cf-cache-status: HIT` and skips the transform engine entirely.

For conditional requests:

```bash
ETAG=$(curl -sI ... | awk '/^etag/ {print $2}' | tr -d '\r')
curl -sI -H "If-None-Match: ${ETAG}" "https://cf-image-cdn.<sub>.workers.dev/..."
# HTTP/2 304
```

## Troubleshooting

- **`Transformations are not enabled for this zone`** — the dashboard toggle hasn't been flipped. Go to **Images → Transformations** in the Cloudflare dashboard, pick the zone, and click *Enable for zone*.[^5]
- **`env.IMAGES is undefined`** — you forgot the `[images]` binding block in `wrangler.toml`, or you didn't re-run `npx wrangler types` after adding it. Re-run, then restart `wrangler dev`.
- **Costs spike unexpectedly** — every call to `env.IMAGES.transform()` is billed; the URL-based dedup that applies to other Cloudflare Images paths is bypassed for the binding.[^12] Verify your Cache API hit rate by inspecting `cf-cache-status` in the response headers, and increase `MAX_AGE_EDGE` if hits are rare.
- **AVIF in WebP browsers (or vice versa)** — you forgot `Vary: Accept` on the response, so an intermediate cache served the wrong format to the wrong client. Add the header before any `cache.put()`.
- **`R2 GetObject 404` on a key that exists** — keys are case-sensitive and slashes count. `Sunset.JPG` is a different object from `sunset.jpg`. The R2 dashboard's object list shows the exact key.[^8]

## Next steps

- Wire the Worker to a custom hostname like `images.example.com` with a Workers Route or a Pages Function — both feature in [Mastering Edge Function Development](https://nerdleveltech.com/mastering-edge-function-development-a-complete-practical-guide).
- Add a [signed-upload flow](https://nerdleveltech.com/serverless-at-the-edge-deep-dive-into-aws-lambdaedge) so end users can post images directly to R2 without round-tripping through your origin.
- For non-image responses where you still want edge caching, the same Cache API patterns from this tutorial apply, as covered in [Edge Deployment in the Cloud-Native Era](https://nerdleveltech.com/edge-deployment-in-the-cloud-native-era-speed-scale-and-smarts).

---

[^1]: Cloudflare R2 Pricing — https://developers.cloudflare.com/r2/pricing/ (Free tier: 10 GB storage, 1M Class A ops, 10M Class B ops, $0 egress; Standard storage $0.015/GB-month).
[^2]: Cloudflare Workers Pricing — https://developers.cloudflare.com/workers/platform/pricing/ (Free plan: 100,000 requests/day, 10ms CPU per invocation; Paid plan starts at $5/month with 10M requests + 30M CPU-ms included).
[^3]: Wrangler 4.90.0 release on npm (2026-05-07) — package `engines` field declares `node >= 22.0.0`. Node.js 20.x reached EOL on 2026-04-30. Sources: https://www.npmjs.com/package/wrangler and https://github.com/cloudflare/workers-sdk/releases.
[^4]: Cloudflare Images Pricing — https://developers.cloudflare.com/images/pricing/ (5,000 unique transformations/month free per account; $0.50 per 1,000 transforms beyond; storage $5 per 100,000 images/month; delivery $1 per 100,000 — storage and delivery only apply when using the Cloudflare Images bucket, not external origins like R2).
[^5]: Transform via Workers — https://developers.cloudflare.com/images/transform-images/transform-via-workers/ ("To serve transformations on your zone, you must first enable the feature: Images → Transformations → pick zone → Enable for zone.").
[^6]: Wrangler configuration reference — https://developers.cloudflare.com/workers/wrangler/configuration/ (current `r2_buckets` schema with `binding`, `bucket_name`, optional `remote = true`).
[^7]: Write Cloudflare Workers in TypeScript — https://developers.cloudflare.com/workers/languages/typescript/ ("`wrangler types` is recommended over `@cloudflare/workers-types` because it generates types from your bindings and compatibility date").
[^8]: R2 Workers API reference — https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ (`R2ObjectBody.body: ReadableStream`, `httpEtag`, `writeHttpMetadata(Headers)`, `get/put/delete` semantics).
[^9]: Miniflare local-dev docs — https://developers.cloudflare.com/workers/testing/miniflare/ (R2/KV/Durable Objects emulation under `.wrangler/state/`).
[^10]: Cloudflare Images Workers binding — https://developers.cloudflare.com/images/transform-images/bindings/ (`env.IMAGES.input(stream).transform({width, height, fit, metadata}).output({format, quality}).response()`; `fit` accepts `scale-down`, `contain`, `cover`, `crop`, `pad`; `format` accepts `image/avif`, `image/webp`, `image/jpeg`, `image/png`).
[^11]: Workers platform limits — https://developers.cloudflare.com/workers/platform/limits/ (128 MB memory per invocation; use streams for large payloads).
[^12]: Cloudflare Images bindings — https://developers.cloudflare.com/images/transform-images/bindings/ + Cloudflare Images pricing notes (every `env.IMAGES` binding call counts as a billable transform; URL-based dedup does not apply).
[^13]: Workers Cache API — https://developers.cloudflare.com/workers/runtime-apis/cache/ (`caches.default.put/match/delete`, ETag/If-None-Match handling, data-center-scoped, respects HTTP `Cache-Control`; use `ctx.waitUntil()` to write to the cache without blocking the response).
