Building an image CDN with Cloudflare R2 and Workers
Keep your source image in a private R2 bucket and publish two resized variants through a Worker:
320-pixel WebP and 640-pixel JPEG. This walkthrough generates a recognizable test image, deploys
it on workers.dev, and checks both the decoded pixels and an actual cache hit. R2, Workers, and
Images have separate costs; a free storage allowance does not make this an unconditionally free
image CDN.
Why Cloudflare R2 for your image CDN?
R2 separates object storage from delivery. The Worker reads approved objects through a binding and returns transformed bytes without exposing a public R2 URL. The Images binding accepts those bytes directly, so the source bucket can stay private.
This is a publication endpoint for images you own and approve. Anyone can retrieve its published URLs. It does not authorize private downloads or process arbitrary uploads.
Setting up your image CDN
Prepare your account and tools
Use a Cloudflare account with R2 and Images already available, an existing workers.dev subdomain,
and an authenticated Wrangler profile named devtips. Copy that account’s ID from the Cloudflare dashboard
and use it throughout. You do not need a custom domain or a DNS change.
See Wrangler authentication profiles
if you need to prepare a profile first.
The commands use Bash on macOS or Linux, Node.js 26.8, and an already installed Corepack. Corepack is a separate prerequisite; check its installation instructions if it is missing. We pin Yarn 4.12.0, Wrangler 4.141.0, and Sharp 0.35.3 in the project. Wrangler’s system requirements also apply.
Save this as setup.mts in a directory outside an existing package project. It refuses enclosing
package-manager configuration and an existing image-cdn-demo directory before installing anything.
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
async function main(): Promise<void> {
let parent = resolve('.')
for (;;) {
for (const file of ['package.json', 'yarn.lock', '.yarnrc.yml', '.pnp.cjs']) {
if (existsSync(join(parent, file))) {
throw new Error('Choose a directory outside an existing package project.')
}
}
if (dirname(parent) === parent) break
parent = dirname(parent)
}
const probe = spawnSync('corepack', ['--version'], { stdio: 'inherit' })
if (probe.status !== 0) throw new Error('Install corepack before creating the project.')
await mkdir('image-cdn-demo')
await writeFile('image-cdn-demo/package.json', JSON.stringify({
name: 'image-cdn-demo', private: true, type: 'module', packageManager: 'yarn@4.12.0',
devDependencies: { wrangler: '4.141.0', sharp: '0.35.3' },
}, null, 2) + '\n', { flag: 'wx' })
await writeFile('image-cdn-demo/.yarnrc.yml', 'nodeLinker: node-modules\n', { flag: 'wx' })
const install = spawnSync('corepack', ['yarn', 'install', '--no-immutable'], {
cwd: 'image-cdn-demo', stdio: 'inherit',
})
if (install.status !== 0) throw new Error('Installation failed; keep the project and retry inside it.')
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : 'Setup failed.')
process.exitCode = 1
})
Run it from the directory containing setup.mts:
node setup.mts
If installation fails after creation, preserve the files and retry the installation:
(cd image-cdn-demo && corepack yarn install --no-immutable)
Do not rerun project creation over an existing directory. The remaining commands run in subshells so they leave your current directory unchanged.
Configure a new Worker and private bucket
Choose an unused lowercase resource name. Replace image-cdn-demo-8f6b2a in this page’s configuration
and commands with that name, and replace YOUR_ACCOUNT_ID with your account ID. Save this complete
new-project configuration as image-cdn-demo/wrangler.json; it replaces no existing configuration.
{
"name": "image-cdn-demo-8f6b2a",
"account_id": "YOUR_ACCOUNT_ID",
"main": "worker.ts",
"compatibility_date": "2026-10-04",
"workers_dev": true,
"preview_urls": false,
"cache": { "enabled": true },
"r2_buckets": [
{ "binding": "IMAGES_BUCKET", "bucket_name": "image-cdn-demo-8f6b2a" }
],
"images": { "binding": "IMAGES" }
}
Workers Cache
can serve responses without executing the Worker, including on workers.dev. This example uses
that cache with explicit response headers, rather than caches.default.
Create the remote bucket in the account pinned by your configuration:
(cd image-cdn-demo &&
corepack yarn wrangler r2 bucket create image-cdn-demo-8f6b2a --profile devtips)
When Wrangler asks whether to add the binding on your behalf, answer n. The configuration above
already defines IMAGES_BUCKET.
New R2 buckets are private by default. Leave the public development URL and public bucket domains disabled, as described in R2 public buckets. If creation reports that the name exists, choose another name; do not upload to an unfamiliar bucket.
Generate a source you can recognize
Save this as image-cdn-demo/fixture.mts. It makes an opaque 800 × 600 RGB PNG with four distinct
color regions, so you can tell a correct transformation from a valid image of the wrong source.
It refuses to replace an existing source file.
import { writeFile } from 'node:fs/promises'
import sharp from 'sharp'
const colors = [[210, 40, 40], [40, 160, 60], [30, 70, 210], [230, 200, 40]]
const pixels = Buffer.alloc(800 * 600 * 3)
for (let y = 0; y < 600; y++) {
for (let x = 0; x < 800; x++) {
const color = colors[(y < 300 ? 0 : 2) + (x < 400 ? 0 : 1)]
pixels.set(color, (y * 800 + x) * 3)
}
}
const png = await sharp(pixels, { raw: { width: 800, height: 600, channels: 3 } }).png().toBuffer()
await writeFile('photo-v1.png', png, { flag: 'wx' })
(cd image-cdn-demo && node fixture.mts)
For your own images, review publication permission and metadata before uploading. Keep sources single-frame, opaque 8-bit RGB PNGs, between 640 and 4,096 pixels wide, at most 12 million decoded pixels, and no larger than 8 MiB. The Worker checks bytes and dimensions; opacity, color encoding, metadata, and publication permission belong to your publishing process. Keep untrusted writers out of this bucket and never overwrite a published versioned object. The verifier below checks the generated four-color fixture; use your own expected dimensions and content when checking another image.
Select only the published variants
Save this complete Worker as image-cdn-demo/worker.ts. Its allowlist maps /photo-v1.png to
public/photo-v1.png. The only accepted pairs are w=320&f=webp and w=640&f=jpeg;
omitting both parameters selects the first pair. Unknown parameters, duplicates, and other pairs
are errors, rather than new billable transformations.
const published = new Map([['photo-v1.png', 'public/photo-v1.png']])
const variants = new Map<string, { width: number; format: 'image/webp' | 'image/jpeg' }>([
['320:webp', { width: 320, format: 'image/webp' }],
['640:jpeg', { width: 640, format: 'image/jpeg' }],
])
const MAX_BYTES = 8 * 1024 * 1024
function failure(status: number, message: string, extra: Record<string, string> = {}): Response {
return new Response(message, {
status,
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'text/plain; charset=utf-8', ...extra },
})
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'GET' && request.method !== 'HEAD') {
return failure(405, 'Use GET or HEAD.', { Allow: 'GET, HEAD' })
}
const url = new URL(request.url)
const key = published.get(url.pathname.slice(1))
if (!key) return failure(404, 'Image not found.')
if ([...url.searchParams].some(([name]) => name !== 'w' && name !== 'f') ||
url.searchParams.getAll('w').length > 1 || url.searchParams.getAll('f').length > 1) {
return failure(400, 'Unsupported image parameters.')
}
const width = url.searchParams.get('w') ?? '320'
const format = url.searchParams.get('f') ?? 'webp'
const variant = variants.get(`${width}:${format}`)
if (!variant) return failure(400, 'Unsupported image variant.')
// Workers Cache distinguishes query order; redirect aliases before doing image work.
const canonical = new URL(url.pathname, url.origin)
canonical.search = `?w=${width}&f=${format}`
if (url.href !== canonical.href) {
return failure(307, 'Use the canonical image URL.', { Location: canonical.href })
}
try {
const object = await env.IMAGES_BUCKET.get(key)
if (!object) return failure(404, 'Image not found.')
if (object.size === 0 || object.size > MAX_BYTES) {
await object.body.cancel()
return failure(422, 'Image is outside the supported limits.')
}
const bytes = await object.arrayBuffer()
const signature = [137, 80, 78, 71, 13, 10, 26, 10]
if (!signature.every((value, index) => new Uint8Array(bytes)[index] === value)) {
return failure(422, 'Publish a supported PNG source.')
}
const info = await env.IMAGES.info(new Blob([bytes]).stream()).catch(() => null)
if (!info || !('width' in info) || !('height' in info) ||
info.width < 640 || info.width > 4096 || info.width * info.height > 12_000_000) {
return failure(422, 'Image is outside the supported limits.')
}
const output = await env.IMAGES.input(new Blob([bytes]).stream())
.transform({ width: variant.width })
.output({ format: variant.format })
const response = output.response({ headers: {
'Cache-Control': 'public, max-age=3600, stale-if-error=0',
'Access-Control-Allow-Origin': '*',
'X-Content-Type-Options': 'nosniff',
'X-Image-Invocation': crypto.randomUUID(),
} })
return request.method === 'HEAD' ? new Response(null, response) : response
} catch {
console.error('Image delivery failed.')
return failure(502, 'Image is temporarily unavailable.')
}
},
}
The successful response allows cross-origin reads without credentials because these images are
public. The example needs no bearer token, KV namespace, or optional authentication flag.
X-Image-Invocation changes whenever the Worker computes a new image response; a cached response
retains it. Use it alongside Cloudflare’s cache status when checking reuse.
Build, upload, and deploy
Generate the binding types for Env, then check that Wrangler can bundle the saved Worker:
(cd image-cdn-demo &&
corepack yarn wrangler types &&
corepack yarn wrangler deploy --dry-run --outdir build)
Upload the generated file to the remote bucket and deploy only after upload succeeds:
(cd image-cdn-demo &&
corepack yarn wrangler r2 object put image-cdn-demo-8f6b2a/public/photo-v1.png \
--file photo-v1.png --content-type image/png --remote --profile devtips &&
corepack yarn wrangler deploy --profile devtips)
Wrangler prints the deployed https://…workers.dev URL. Keep that exact origin for the next step.
If deployment fails after upload, fix the reported cause and retry only the deploy command.
If an upload’s result is uncertain, inspect your owned object before retrying; do not overwrite a
published source to retry setup.
Local R2 is separate: r2 object put --local seeds only Wrangler’s local storage, and wrangler dev
uses local bindings. It does not upload or deploy this example. See the
R2 object commands
for the explicit --local and --remote flags. Use the deployed path below to verify provider
transformation and cache behavior.
Uploading and using images
Save the following as image-cdn-demo/verify.mts. It downloads both variants and repeats the WebP
request. Replace YOUR_WORKER_ORIGIN in the command beneath it with the exact deployed origin.
The verifier requires a cache hit with the same invocation ID and bytes, and independently decodes
each file with Sharp. HTTP 200 and a plausible filename alone cannot establish the result.
import assert from 'node:assert/strict'
import { mkdir, writeFile } from 'node:fs/promises'
import sharp from 'sharp'
const origin = new URL(process.argv[2])
assert(origin.protocol === 'https:')
assert(origin.pathname === '/' && !origin.search && !origin.hash)
const directory = process.argv[3] ?? 'downloads'
await mkdir(directory)
const colors = [[210, 40, 40], [40, 160, 60], [30, 70, 210], [230, 200, 40]]
async function download(query: string, file: string, width: number, format: string) {
const response = await fetch(new URL(`/photo-v1.png?${query}`, origin))
assert.equal(response.status, 200)
assert.equal(response.headers.get('content-type'), `image/${format}`)
const bytes = Buffer.from(await response.arrayBuffer())
assert.equal((await sharp(bytes).metadata()).format, format)
const decoded = await sharp(bytes).removeAlpha().raw().toBuffer({ resolveWithObject: true })
assert.equal(decoded.info.width, width)
assert.equal(decoded.info.height, width * 3 / 4)
assert.equal(decoded.info.channels, 3)
for (const [index, [x, y]] of [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]].entries()) {
const offset = (Math.floor(y * decoded.info.height) * width + Math.floor(x * width)) * 3
for (let channel = 0; channel < 3; channel++) {
assert(Math.abs(decoded.data[offset + channel] - colors[index][channel]) <= 6)
}
}
await writeFile(`${directory}/${file}`, bytes, { flag: 'wx' })
return { bytes, invocation: response.headers.get('x-image-invocation'),
cache: response.headers.get('cf-cache-status') }
}
const cold = await download('w=320&f=webp', 'photo-320.webp', 320, 'webp')
const warm = await download('w=320&f=webp', 'photo-320-repeat.webp', 320, 'webp')
assert.equal(warm.cache, 'HIT', 'Repeat request did not demonstrate a cache hit.')
assert(cold.invocation)
assert.equal(warm.invocation, cold.invocation, 'Worker computed the image again.')
assert(cold.bytes.equals(warm.bytes))
await download('w=640&f=jpeg', 'photo-640.jpeg', 640, 'jpeg')
console.log('Verified both variants and a cache hit with the same invocation.')
(cd image-cdn-demo && node verify.mts https://YOUR_WORKER_ORIGIN)
Success leaves a 320 × 240 WebP and a 640 × 480 JPEG under image-cdn-demo/downloads, with the
same red, green, blue, and yellow regions as the source. A third file contains the repeated WebP.
The script refuses an existing output directory. After a failed request, preserve that directory
and retry with a new directory name:
(cd image-cdn-demo && node verify.mts https://YOUR_WORKER_ORIGIN downloads-retry-1)
The retry writes only to downloads-retry-1; it does not replace earlier downloads.
Check canonical requests and caching
The default URL /photo-v1.png and reversed query order ?f=webp&w=320 return an uncached 307
redirect to ?w=320&f=webp. Follow that redirect to use the same cached variant. Canonicalization
is needed because Workers Cache keys include query order.
Cache hits can expire or be evicted. This check demonstrates reuse from one client location;
it does not establish a global hit rate or a latency improvement. If the verifier reports a miss,
inspect CF-Cache-Status using the Workers Cache debugging guide.
Two successful responses with different invocation IDs mean the Worker computed twice.
Use a new public filename and backing object key for a new source version, such as photo-v2.png
and public/photo-v2.png, then update the allowlist and deploy. Do not overwrite photo-v1.png.
The public version, width, and format distinguish cached bodies. A deployment uses a new Worker
cache version by default, but cannot clear an old response already cached by a browser.
Understand failures and the publication boundary
Invalid parameters return 400, unpublished or missing objects return 404, unsupported methods
return 405, and unsupported sources return 422. Binding or transformation failures return a
sanitized 502. Every failure is no-store; missing sources never fall back to an untransformed
original. Fresh cached successes remain available until expiry even if their backing object is
removed. stale-if-error=0 prevents an expired success from hiding a later Worker error.
Removing an allowlist entry and redeploying changes the Worker’s cache version. It does not recall copies already downloaded or cached in browsers. Publish only content you intend to make public.
Cost considerations
Check the current R2 pricing, Workers pricing, and Images pricing before deployment. Storage, object reads, Worker requests, and unique image transformations have separate billing rules. The fixed variant list bounds the choices for one source; new source versions add transformations. A cache miss can read and decode the source again even when that transformation was already billed.
Remove the demo resources
Delete only the Worker and bucket you created for this example:
(cd image-cdn-demo &&
corepack yarn wrangler delete --name image-cdn-demo-8f6b2a --force --profile devtips &&
corepack yarn wrangler r2 object delete image-cdn-demo-8f6b2a/public/photo-v1.png --remote --profile devtips &&
corepack yarn wrangler r2 bucket delete image-cdn-demo-8f6b2a --profile devtips)
Confirm the Worker and bucket are absent in your account. Keep your local source and downloads until you finish checking the results. If cleanup stops partway, resume with the remaining deletion commands for those same owned names.
You now have a concrete publication path: a private source bucket, two deliberately chosen public variants, and a check that distinguishes a cache hit from another transformation. For a managed image delivery alternative, see Transloadit’s Smart CDN.
