Building a free image CDN with Cloudflare R2 and workers

In today's web development landscape, efficient image delivery is essential for optimal site performance. While many traditional solutions involve complex configurations and high costs, Cloudflare's R2 storage and Cloudflare Workers provide a streamlined, cost-effective approach to building an image CDN. This guide walks you through setting up a robust image CDN that remains essentially free for most use cases.
Why Cloudflare R2 for your image CDN?
Unlike many traditional cloud storage services that impose egress fees, Cloudflare R2 offers:
- Zero egress fees
- 10GB of free storage
- Native integration with Cloudflare's global network
- Seamless integration with Cloudflare Workers for on-the-fly image optimization
- An S3-compatible API
These features make R2 an excellent choice for developers looking to build a scalable image delivery solution without unpredictable bandwidth costs.
Setting up your image CDN
Step 1: Create a Cloudflare account
- Sign up for a Cloudflare account if you haven't already
- Add your domain to Cloudflare (if not already added)
- Enable the Workers & R2 service in your account
Step 2: Create an R2 bucket
- Navigate to R2 in your Cloudflare dashboard
- Click Create bucket
- Name your bucket (e.g.,
my-image-cdn
) - Choose your preferred location
- Click Create bucket
Step 3: Create a worker
- Go to Workers & Pages in your dashboard
- Click Create Application
- Select Create Worker
- Replace the default code with the following image-handling Worker:
export default {
async fetch(request, env) {
try {
// Basic auth check
if (env.REQUIRE_AUTH) {
const authorized = await authenticate(request, env)
if (!authorized) {
return new Response('Unauthorized', { status: 401 })
}
}
// Rate limiting
const clientIP = request.headers.get('cf-connecting-ip')
const rateLimitKey = `ratelimit:${clientIP}`
// Get and increment counter
const currentRequests = parseInt((await env.KV.get(rateLimitKey)) || '0')
if (currentRequests > 100) {
return new Response('Rate limit exceeded', { status: 429 })
}
// Set counter with 60 second expiration
await env.KV.put(rateLimitKey, (currentRequests + 1).toString(), {
expirationTtl: 60,
})
const url = new URL(request.url)
const path = url.pathname.slice(1)
// Parse image transformation parameters
const width = url.searchParams.get('w')
const height = url.searchParams.get('h')
const quality = url.searchParams.get('q') || '85'
const format = url.searchParams.get('f') || 'auto'
const object = await env.MY_BUCKET.get(path)
if (!object) {
return new Response('Image not found', { status: 404 })
}
const headers = new Headers()
headers.set('Cache-Control', 'public, max-age=31536000')
headers.set('Content-Type', object.httpMetadata.contentType || 'image/jpeg')
return new Response(object.body, {
headers,
cf: {
image: {
fit: 'scale-down',
width: width ? parseInt(width) : undefined,
height: height ? parseInt(height) : undefined,
quality: parseInt(quality),
format,
},
},
})
} catch (err) {
if (!(err instanceof Error)) {
throw new Error(`Was thrown a non-error: ${err}`)
}
console.error('Error serving image:', err)
return new Response('Error processing image', {
status: 500,
headers: {
'Content-Type': 'text/plain',
},
})
}
},
}
- Click Save and Deploy
Step 4: Configure worker bindings
- Go to your Worker's settings
- Click Variables
- Under R2 Bucket Bindings, click Add binding
- Name:
MY_BUCKET
- Choose your R2 bucket
- Under KV Namespace Bindings, click Add binding
- Name:
KV
- Create a new namespace called
image-cdn-ratelimits
- Save changes
Step 5: Set up your domain
- Go to Workers Routes
- Add a new route:
- Pattern:
cdn.yourdomain.com/*
- Worker: Select your image Worker
- Pattern:
- Add a DNS record:
- Type: CNAME
- Name: cdn
- Target: your-worker.your-subdomain.workers.dev
- Proxy status: Proxied
Uploading and using images
You can upload images to your R2 bucket using:
- The Cloudflare dashboard
- Wrangler CLI
- Any S3-compatible tool
Example using Wrangler:
wrangler r2 object put my-image-cdn/example.jpg --file ./local-image.jpg
Access your images via:
https://cdn.yourdomain.com/example.jpg
Cost considerations
The free tier includes:
- 10GB R2 storage
- 10 million R2 operations per month
- 100,000 Worker requests per day
- Unlimited bandwidth
Most small to medium-sized projects will fit comfortably within these limits. If you exceed them, the pricing remains competitive:
- R2: $0.015 per GB/month after 10GB
- Workers: $0.50 per million requests after free tier
Security best practices
Implement security best practices to protect your image CDN:
- Access Control: Use Worker middleware to implement authentication if needed
- Rate Limiting: Enforce request limits within your Worker
- URL Signing: Generate signed URLs for time-limited access
Here's an example of adding basic authentication:
async function authenticate(request, env) {
const authHeader = request.headers.get('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return false
}
const token = authHeader.split(' ')[1]
return token === env.API_TOKEN
}
Handling cors requests
To enable cross-origin resource sharing, include the following in your Worker:
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders })
}
// Ensure CORS headers are added to your response
headers.set('Access-Control-Allow-Origin', '*')
Error handling
Update your Worker to handle common errors gracefully:
export default {
async fetch(request, env) {
try {
// Basic auth check
if (env.REQUIRE_AUTH) {
const authorized = await authenticate(request, env)
if (!authorized) {
return new Response('Unauthorized', { status: 401 })
}
}
// Rate limiting
const clientIP = request.headers.get('cf-connecting-ip')
const rateLimitKey = `ratelimit:${clientIP}`
// Get and increment counter
const currentRequests = parseInt((await env.KV.get(rateLimitKey)) || '0')
if (currentRequests > 100) {
return new Response('Rate limit exceeded', { status: 429 })
}
// Set counter with 60 second expiration
await env.KV.put(rateLimitKey, (currentRequests + 1).toString(), {
expirationTtl: 60,
})
const url = new URL(request.url)
const path = url.pathname.slice(1)
// Parse image transformation parameters
const width = url.searchParams.get('w')
const height = url.searchParams.get('h')
const quality = url.searchParams.get('q') || '85'
const format = url.searchParams.get('f') || 'auto'
const object = await env.MY_BUCKET.get(path)
if (!object) {
return new Response('Image not found', { status: 404 })
}
const headers = new Headers()
headers.set('Cache-Control', 'public, max-age=31536000')
headers.set('Content-Type', object.httpMetadata.contentType || 'image/jpeg')
return new Response(object.body, {
headers,
cf: {
image: {
fit: 'scale-down',
width: width ? parseInt(width) : undefined,
height: height ? parseInt(height) : undefined,
quality: parseInt(quality),
format,
},
},
})
} catch (err) {
if (!(err instanceof Error)) {
throw new Error(`Was thrown a non-error: ${err}`)
}
console.error('Error serving image:', err)
return new Response('Error processing image', {
status: 500,
headers: {
'Content-Type': 'text/plain',
},
})
}
},
}
Supported image transformations
When using the f
parameter, the following formats are supported:
webp
: Convert to WebP formatavif
: Convert to AVIF format (better compression but slower)json
: Returns image metadata instead of the imageauto
: Let Cloudflare choose the best format based on the client's capabilities
Example URLs:
# Convert to WebP
https://cdn.yourdomain.com/image.jpg?f=webp
# Resize to 800px width and convert to avif
https://cdn.yourdomain.com/image.jpg?w=800&f=avif
# Get image metadata
https://cdn.yourdomain.com/image.jpg?f=json
Image limitations
Be aware of Cloudflare's image processing limits:
- Maximum file size: 100MB
- Maximum dimensions: 12,000×12,000 pixels
- Supported input formats:
- JPEG/JPG
- PNG
- GIF (first frame only)
- WebP
- AVIF
If these limits are exceeded, the Worker will return the original image without transformations.
Caching behavior
Images are cached at two levels:
- Browser Cache: Using
Cache-Control: public, max-age=31536000
(1 year) - Cloudflare's Edge Cache: Automatically caches assets at Cloudflare's edge locations
To force a cache refresh, you can:
- Append a version parameter (e.g.,
?v=123
) - Purge the cache via Cloudflare's Dashboard
- Use the Cloudflare API to programmatically purge cache
Each unique combination of transformation parameters (w, h, f, q) creates a separate cache entry.
Setting up environment variables
If you want to use authentication:
- Go to your Worker's settings
- Click Settings > Variables
- Add these environment variables:
REQUIRE_AUTH
: Set to "true" to enable authenticationAPI_TOKEN
: Your chosen authentication token
Keep your API token secure and never commit it to version control!
Conclusion
Cloudflare R2 and Workers offer a powerful, cost-effective solution for building an image CDN, delivering global distribution, automatic image optimization, and straightforward implementation. For those needing extra features, consider exploring Transloadit's Smart CDN.