Cloudflare Workers + R2 Image CDN: 2026 Tutorial
May 8, 2026
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
IMAGESbinding resizes, re-encodes, and content-negotiates AVIF/WebP/JPEG against theAcceptheader. - 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.tomlfor Wrangler 4 - How to apply on-the-fly transforms from a Worker fetch handler
- How to negotiate AVIF/WebP/JPEG with the
Acceptheader andVary - 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 Modifiedresponses 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 EOL3 |
| Wrangler CLI | 4.84.1 | Pinned to a release ≥14 days old; 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 account4 |
| Cloudflare zone | proxied (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:
fitmodes:scale-down,contain,cover,crop,pad.scale-downis the safest default — it never upscales smaller originals.formatmust be one ofimage/avif,image/webp,image/jpeg, orimage/png. Unlike the URL-based transform path, the binding has noautoshortcut — you do content negotiation in code. That's why we read the request'sAcceptheader inpickFormat()and emit aVary: Acceptheader on the response: withoutVary, browsers and intermediate caches would serve the AVIF bytes to a JPEG-only client.10metadata: "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:
caches.defaultis 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- 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:
- The allow-list in
parseWidth(Step 5) rejects anyw=outsideALLOWED_WIDTHS. - 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.5env.IMAGES is undefined— you forgot the[images]binding block inwrangler.toml, or you didn't re-runnpx wrangler typesafter adding it. Re-run, then restartwrangler 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 inspectingcf-cache-statusin the response headers, and increaseMAX_AGE_EDGEif hits are rare. - AVIF in WebP browsers (or vice versa) — you forgot
Vary: Accepton the response, so an intermediate cache served the wrong format to the wrong client. Add the header before anycache.put(). R2 GetObject 404on a key that exists — keys are case-sensitive and slashes count.Sunset.JPGis a different object fromsunset.jpg. The R2 dashboard's object list shows the exact key.8
Next steps
- Wire the Worker to a custom hostname like
images.example.comwith a Workers Route or a Pages Function — both feature in Mastering Edge Function Development. - Add a signed-upload flow 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.
Footnotes
-
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). ↩
-
Wrangler 4.90.0 release on npm (2026-05-07) — package
enginesfield declaresnode >= 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. ↩ -
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
-
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
-
Wrangler configuration reference — https://developers.cloudflare.com/workers/wrangler/configuration/ (current
r2_bucketsschema withbinding,bucket_name, optionalremote = true). ↩ -
Write Cloudflare Workers in TypeScript — https://developers.cloudflare.com/workers/languages/typescript/ ("
wrangler typesis recommended over@cloudflare/workers-typesbecause it generates types from your bindings and compatibility date"). ↩ -
R2 Workers API reference — https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ (
R2ObjectBody.body: ReadableStream,httpEtag,writeHttpMetadata(Headers),get/put/deletesemantics). ↩ ↩2 -
Miniflare local-dev docs — https://developers.cloudflare.com/workers/testing/miniflare/ (R2/KV/Durable Objects emulation under
.wrangler/state/). ↩ -
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();fitacceptsscale-down,contain,cover,crop,pad;formatacceptsimage/avif,image/webp,image/jpeg,image/png). ↩ ↩2 -
Workers platform limits — https://developers.cloudflare.com/workers/platform/limits/ (128 MB memory per invocation; use streams for large payloads). ↩
-
Cloudflare Images bindings — https://developers.cloudflare.com/images/transform-images/bindings/ + Cloudflare Images pricing notes (every
env.IMAGESbinding call counts as a billable transform; URL-based dedup does not apply). ↩ ↩2 -
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 HTTPCache-Control; usectx.waitUntil()to write to the cache without blocking the response). ↩ ↩2 ↩3