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

  1. Sign up for a Cloudflare account if you haven't already
  2. Add your domain to Cloudflare (if not already added)
  3. Enable the Workers & R2 service in your account

Step 2: Create an R2 bucket

  1. Navigate to R2 in your Cloudflare dashboard
  2. Click Create bucket
  3. Name your bucket (e.g., my-image-cdn)
  4. Choose your preferred location
  5. Click Create bucket

Step 3: Create a worker

  1. Go to Workers & Pages in your dashboard
  2. Click Create Application
  3. Select Create Worker
  4. 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',
        },
      })
    }
  },
}
  1. Click Save and Deploy

Step 4: Configure worker bindings

  1. Go to your Worker's settings
  2. Click Variables
  3. Under R2 Bucket Bindings, click Add binding
  4. Name: MY_BUCKET
  5. Choose your R2 bucket
  6. Under KV Namespace Bindings, click Add binding
  7. Name: KV
  8. Create a new namespace called image-cdn-ratelimits
  9. Save changes

Step 5: Set up your domain

  1. Go to Workers Routes
  2. Add a new route:
    • Pattern: cdn.yourdomain.com/*
    • Worker: Select your image Worker
  3. 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

  1. Access Control: Use Worker middleware to implement authentication if needed
  2. Rate Limiting: Implement request limiting in your Worker
  3. 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 format
  • avif: Convert to AVIF format (better compression but slower)
  • json: Returns image metadata instead of the image
  • auto: 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:

  1. Browser Cache: Using Cache-Control: public, max-age=31536000 (1 year)
  2. 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:

  1. Go to your Worker's settings
  2. Click Settings > Variables
  3. Add these environment variables:
    • REQUIRE_AUTH: Set to "true" to enable authentication
    • API_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.