Building an image CDN with Cloudflare R2 and Workers
R2 can hold your source images, a Worker can select a variant, and the Cloudflare Images binding can resize and encode it. These are separate services with separate usage limits. A small project may fit their free allowances, but an image CDN is not unconditionally free.
This example publishes a small, explicitly approved set of images. It is not a private-file gateway or an arbitrary upload processor.
Why Cloudflare R2 for your image CDN?
R2 separates object storage from delivery. Keep the bucket private and let the Worker read approved objects through a binding. The Worker returns transformed bytes rather than exposing an R2 URL.
Resizing requires an image transformation service: placing cf.image on a Response does not
transform its body. We use the actual
Images binding.
Setting up your image CDN
Step 1: Create a Cloudflare account
Enable R2, Workers and Images in your account and review their current billing settings. Install
Node.js and create a Worker project with the
Cloudflare getting-started guide.
Use an ES module Worker; the implementation below is src/index.js.
Step 2: Create an R2 bucket
Create a bucket named image-cdn-demo in the R2 dashboard. Leave both the public development URL
and public bucket custom domains disabled. Upload only images you are allowed to publish.
Our allowlist maps the public filename photo.jpg to public/photo-v1.jpg. Before upload, check
that this is a single-frame JPEG or PNG, no larger than 8 MiB and 12 million decoded pixels. Remove
private metadata during your publishing process. Never overwrite versioned objects.
Step 3: Create a worker
The complete Worker supports three widths and two formats. Rejecting unknown or duplicate parameters
keeps the transformation set finite. Format is explicit in the URL, so the cache does not depend on
the browser's Accept header.
const published = new Map([['photo.jpg', 'public/photo-v1.jpg']])
const widths = new Set(['320', '640', '1280'])
const formats = new Map([['webp', 'image/webp'], ['jpeg', 'image/jpeg']])
const MAX_BYTES = 8 * 1024 * 1024
function failure(status, message, extra = {}) {
return new Response(message, {
status,
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'text/plain; charset=utf-8', ...extra },
})
}
export default {
async fetch(request, env, ctx) {
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.')
const pairs = [...url.searchParams]
if (pairs.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') ?? '640'
const format = url.searchParams.get('f') ?? 'webp'
if (!widths.has(width) || !formats.has(format)) {
return failure(400, 'Unsupported image variant.')
}
// Normalize defaults/order and include the immutable source version in the internal cache key.
const cacheUrl = new URL('/_image-cache/' + key, url.origin)
cacheUrl.searchParams.set('w', width)
cacheUrl.searchParams.set('f', format)
const cacheKey = new Request(cacheUrl, { method: 'GET' })
const cache = caches.default
try {
let response = await cache.match(cacheKey)
if (!response) {
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 output = await env.IMAGES.input(object.body)
.transform({ width: Number(width) })
.output({ format: formats.get(format) })
const transformed = output.response()
response = new Response(transformed.body, transformed)
response.headers.set('Cache-Control', 'public, max-age=3600')
response.headers.set('X-Content-Type-Options', 'nosniff')
// Public images only: this endpoint does not authorize private content.
response.headers.set('Access-Control-Allow-Origin', '*')
ctx.waitUntil(cache.put(cacheKey, response.clone()).catch(() => {
console.error('Image cache write failed.')
}))
}
return request.method === 'HEAD'
? new Response(null, response)
: response
} catch {
console.error('Image delivery failed.')
return failure(502, 'Image is temporarily unavailable.')
}
},
}
Step 4: Configure worker bindings
Add these bindings to your generated Wrangler configuration, preserving the project's other fields:
{
"name": "image-cdn-demo",
"main": "src/index.js",
"compatibility_date": "2026-07-30",
"r2_buckets": [
{ "binding": "IMAGES_BUCKET", "bucket_name": "image-cdn-demo" }
],
"images": { "binding": "IMAGES" }
}
This implementation uses the Workers Cache API explicitly. Do not add a second automatic response cache without checking its cache key, invalidation and authorization behavior.
Step 5: Set up your domain
Start locally with npx wrangler dev. Local R2 is separate from the deployed bucket; seed it with
a local object before testing. Local Images emulation supports only a subset of production options,
so verify your deployed variants before directing traffic to them.
Deploy with npx wrangler deploy. In the Worker settings, add a
Workers Custom Domain
for a domain in your Cloudflare account. A DNS record alone does not attach the Worker.
Uploading and using images
Seed the local development bucket with your reviewed source file:
npx wrangler r2 object put image-cdn-demo/public/photo-v1.jpg --file ./photo.jpg --local
curl --fail-with-body 'http://localhost:8787/photo.jpg?w=320&f=webp' --output photo-320.webp
curl --fail-with-body --head 'http://localhost:8787/photo.jpg?f=jpeg&w=640'
For deployment, upload the same approved file through the R2 dashboard or deliberately use Wrangler's
--remote flag. Local uploads are not automatically copied to production.
Cost considerations
Check the current R2 pricing, Workers pricing and Images pricing before deployment. Storage, object reads, Worker requests and image transformations have different allowances and billing rules. Exceeding a free allowance may incur charges or reject requests, depending on the service and plan. Do not infer a free transformation allowance from R2's egress policy.
Budget for cache misses and new source versions. An edge cache hit is not a guarantee that every future request avoids storage or transformation work.
Security best practices
The allowlist is a publication boundary, not an authentication mechanism. Anyone who knows an approved URL can retrieve its image. Do not map it to personal uploads or files with access controls.
For private delivery, design authorization and private caching together before adapting this code. Keep credentials in Worker bindings or secrets, never query strings. Apply account-level abuse controls and usage alerts. A KV read/increment/write counter is not an atomic rate limiter.
Handling CORS requests
The successful image response allows any origin because these images are public. This permits
browser fetch() and canvas use without credentials. Ordinary cross-origin img display does
not itself require CORS. The endpoint does not support credentialed requests or custom request
headers requiring a preflight.
Error handling
Invalid parameters return 400, unpublished names return 404 and unsupported methods return 405.
Missing or unusable source files do not fall back to the original image. A failed transformation
returns a sanitized 502 response with no-store, not provider details or cached error pages.
Supported image transformations
Use w=320, w=640 or w=1280, with f=webp or f=jpeg. Omitting parameters selects
640-pixel WebP. Width-only resizing preserves aspect ratio; the approved source determines the
height. JPEG does not preserve transparency. Publish opaque sources when both variants must look
identical. Additional formats or crop policies require explicit allowlist and test changes.
Image limitations
The Worker checks object byte size. Pixel count, single-frame format and publication permission are publishing-time requirements, not enforced by trusting a filename or content-type header. Do not make this bucket writable by untrusted uploaders. Large or unsupported objects must be rejected before publication; the provider also applies its own decoder limits.
Caching behavior
A successful response is public for one hour. The internal key includes the source object version, width and format. Query order and omitted defaults resolve to the same internal key, while JPEG and WebP never share a cached body.
The Cache API is local to each Cloudflare data center; it is not a globally replicated object store. Use a new public filename for updates that must immediately bypass browser caches. Changing the allowlist's backing object alone cannot invalidate an already cached browser response.
Setting up environment variables
This example needs no bearer token, KV namespace or optional authentication flag. Bindings provide the private R2 read access and image service connection. Keep development and production buckets separate, and do not use remote bindings accidentally in automated tests.
Conclusion
R2, Workers and Images provide the pieces for an image delivery service. Keep publication decisions, variant selection and caching explicit, verify the current service contracts, and monitor all three usage budgets. For a managed alternative, see Transloadit's Smart CDN.
