Building a free image CDN with Cloudflare R2 and workers
In today's web development landscape, efficient image delivery is crucial for optimal site performance. While traditional solutions often involve complex setups and significant costs, Cloudflare's R2 storage and Workers platform offer a simpler, more cost-effective approach to building an image CDN. This guide will show you how to create a powerful image CDN that's essentially free for most use cases.
Why Cloudflare R2 for your image CDN?
Unlike traditional cloud storage services that charge for data transfer (egress), Cloudflare R2 offers:
- Zero egress fees
- 10GB of free storage
- Native integration with Cloudflare's global network
- Seamless Workers integration for image optimization
- Simple API compatible with S3
This makes R2 an ideal choice for developers looking to build a scalable image delivery solution without worrying about 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 our 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
- Access Control: Use Worker middleware to implement authentication if needed
- Rate Limiting: Implement request limiting in your Worker
- URL Signing: Add 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
If you need to access your images from different domains, update your Worker to handle CORS:
// Add this to your Worker
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}
// Handle OPTIONS requests for CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders })
}
// Add corsHeaders to your image response headers
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 you exceed these limits, 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 at Cloudflare's edge locations
To force a cache refresh, you can:
- Add a version parameter:
?v=123
- Use Cloudflare's Dashboard to purge cache
- Use the Cloudflare API to programmatically purge cache
Note: 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
Remember to keep your API token secret and never commit it to version control!
Conclusion
Cloudflare R2 and Workers provide a powerful, cost-effective way to build an image CDN. This solution offers:
- Global distribution
- Automatic image optimization
- Zero egress fees
- Simple implementation
- High scalability
For most projects, this setup will remain completely free while providing enterprise-level features. As your needs grow, the pricing remains predictable and reasonable. If you have more extensive needs, consider Transloadit's Smart CDN, which works similar, but requires less set-up, while supporting many more file types, formats, and usecases because we have coupled it with our existing encoding platform.