liildev

Building a media pipeline with R2 for storage, Workers for transformation, and signed URLs for access control — what works and what doesn't.

cloudflarer2mediainfrastructure

R2 is cheap, has zero egress fees, and integrates naturally with the rest of Cloudflare's stack. I've used it for media storage in ContentumAI and a few other projects. Here's an honest account of the setup.

The basic architecture

Files go in via a signed upload URL (generated server-side, used directly by the browser — no proxying large files through your API). Files come out via either public bucket URLs or signed download URLs depending on whether the content is private.

Browser → POST /api/upload → NestJS → R2 presigned URL
Browser → PUT to R2 directly (bypasses your server)
Browser → GET /api/media/:id → NestJS → signed R2 URL (302 redirect)

The redirect-to-signed-URL pattern is important if you need access control. Don't embed long-lived signed URLs in your frontend — they leak. Generate short-lived ones on demand, server-side.

Image optimization with Workers

R2 alone just stores bytes. For image optimization, you need Workers and the Cloudflare Images Transformations API (previously Image Resizing).

A Worker sits in front of your R2 bucket and intercepts requests. It reads size and format query params, applies cf.image options, and proxies to the bucket. The transformed result gets cached at Cloudflare's edge.

// Simplified Worker
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const width = parseInt(url.searchParams.get('w') ?? '0');
    const format = url.searchParams.get('f') ?? 'auto';

    return fetch(request, {
      cf: {
        image: { width: width || undefined, format },
      },
    });
  },
};

This gives you on-the-fly WebP/AVIF conversion and resizing with Cloudflare's CDN in front of everything.

What doesn't work well

CORS configuration is annoying. R2 bucket CORS rules are set at the bucket level via API or the dashboard. The UI is limited. If you have multiple frontend origins (local dev, staging, production), you need to list them all or use a wildcard — which may not be acceptable if the bucket is sensitive.

Signed URL expiry and CDN caching conflict. If you serve a signed URL through a CDN, the CDN might cache a 200 response to a URL that has since expired. The next user to request that URL gets the cached response even if their own signed URL would have been different. Be careful about what you cache.

Public buckets are all-or-nothing. There's no per-object public access control — it's the whole bucket or nothing. If you need some objects public and some private, use two buckets.

What works well

Zero egress fees mean you can stop worrying about bandwidth costs. The upload experience is fast because you're writing directly to R2, not through your API. The Workers integration for image transformation is genuinely good once you get past the setup. And R2 pricing at $0.015/GB/month is hard to beat.