Cloudflare Workers + R2 Image CDN: 2026 Tutorial

May 8, 2026

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.12

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

ItemPinned versionNotes
Node.js22.0.0 or laterWrangler 4 dropped Node 20 support after the 2026-04-30 EOL3
Wrangler CLI4.84.1Pinned to a release ≥14 days old; the latest is 4.90.0 but pin the lockfile to whatever you npm install
TypeScript5.5.xBundled by npm create cloudflare@latest
Cloudflare accountFree or aboveImage Transformations are available on every plan, including Free, with 5,000 unique transforms/month included on the account4
Cloudflare zoneproxied (orange cloud)Required so the dashboard can flip "Enable for zone" under Images > Transformations5

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:

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:

npm install --save-dev wrangler@4.84.1

Verify:

npx wrangler --version
# ⛅️ wrangler 4.84.1
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):

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

Drop a couple of test images in:

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

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:

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:

// 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:

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

// 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:

// 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:

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:

npx wrangler types

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

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:

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+):

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

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:

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:

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


Footnotes

  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

  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). 2

  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."). 2

  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). 2

  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). 2

  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). 2

  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). 2 3


FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.