How I Serve WordPress Assets from Cloudflare R2 (For Almost Free.)

Every static asset on gauravtiwari.org gets served from a Cloudflare R2 bucket. Images, stylesheets, JavaScript files, fonts. All of it comes from r2.gauravtiwari.org, not my origin server.

My R2 bill last month? $2.41. That’s storage only and that is because my wp-content folder is 10GB. Bandwidth costs nothing because R2 doesn’t charge for egress.

I built three components to make this work:

  1. a WordPress mu-plugin that rewrites all asset URLs,
  2. a Cloudflare Worker that acts as a pull-through cache, and
  3. a second mu-plugin that handles on-demand image resizing.

I didn’t bulk-upload anything, write migration scripts, or sync a single file. The system fills itself automatically as visitors hit the site.

Serve WordPress Assets from Cloudflare R2 in Media Library
WordPress Media Library now serving via Cloudflare R2

This is the exact setup I’ve been running for over last couple of months. I’m going to walk you through every piece, with code, so you can build the same thing.

Why Cloudflare R2 Over Traditional CDNs

Most CDNs charge you for bandwidth. That’s their business model. You store files, they serve files, and every gigabyte that leaves their network costs money.

Amazon S3 charges $0.09 per GB for egress. Sounds cheap until you do the math. A site serving 500 GB of assets per month (not unusual for image-heavy WordPress sites) pays $45/month just in transfer fees. That doesn’t include storage, API requests, or anything else. I’ve seen S3 bills surprise people badly.

DigitalOcean Spaces is a bit better at $0.01/GB after the first 1 TB included. BunnyCDN runs about $0.01/GB too, with a $1/month minimum. These are reasonable. But Cloudflare R2 is… free on bandwidth.

R2’s pricing structure is simple. You pay $0.015 per GB per month for storage. You pay $0 for bandwidth. Zero. Class A operations (writes) cost $4.50 per million. Class B operations (reads) cost $0.36 per million. And there’s a free tier that covers 10 GB storage and 10 million reads per month.

Cost comparison: Cloudflare R2 vs S3 vs DigitalOcean Spaces vs BunnyCDN

My actual numbers: I store about 150 GB in R2. That’s roughly $2.25 for storage, plus a few cents for API operations. My total bill hovers around $2-3/month. For a site that gets hundreds of thousands of pageviews and serves tens of thousands of images.

But cost isn’t the only reason. R2 lives inside the Cloudflare ecosystem. That means I can put a Cloudflare Worker in front of my bucket, attach a custom domain through Cloudflare DNS, and write cache logic that’s impossible with standalone CDNs. The Worker runs at the edge, in 300+ data centers worldwide, with sub-millisecond cold starts.

If you’re already using Cloudflare for DNS (and you should be), R2 fits into your stack without adding another vendor, another dashboard, or another billing relationship.

The Architecture: Three Components

The system has three pieces that work together. I’ll give you the full picture before we get into each one.

Architecture diagram: Browser to Cloudflare Worker to R2 bucket with origin fallback

Component 1: URL Rewriter (WordPress mu-plugin). This runs on your WordPress site and intercepts every asset URL. Images, scripts, stylesheets, fonts. It rewrites them from yourdomain.com/wp-content/... to r2.yourdomain.com/wp-content/.... The origin never sees the traffic.

Component 2: Pull-Through Cache (Cloudflare Worker). When a request hits r2.yourdomain.com, the Worker checks R2 first. If the file exists, it serves it with aggressive cache headers. If not, it fetches from your origin, stores the file in R2, then serves it. This is the “lazy migration” trick. R2 fills itself.

Pull-through cache flow: Worker checks R2, fetches from origin on miss, stores and serves

Component 3: On-Demand Image Resizer (Worker + mu-plugin endpoint). WordPress generates thumbnail sizes at upload time. But if you add a new image size later, or your R2 bucket doesn’t have a specific crop… the Worker calls back to a resize endpoint on your WordPress site, generates the variant on the fly, stores it in R2, and serves it. One request, then it’s cached permanently.

I built it this way because I don’t want to manage a sync process or bulk-upload 50,000 images. The pull-through cache means R2 is always eventually consistent with the origin, with zero manual effort.

Setting Up the URL Rewriter (gt-r2-cdn.php)

This is a must-use plugin (mu-plugin), meaning it sits in wp-content/mu-plugins/ and loads automatically. No activation needed. No deactivation possible. That’s intentional. You don’t want someone accidentally disabling your CDN rewrite and suddenly serving all assets from origin.

The mu-plugin hooks into several WordPress filters to catch every type of asset URL.

gt-r2-cdn.php
<?php

/**

 * Plugin Name: GT R2 CDN Rewriter

 * Description: Rewrites static asset URLs to serve from Cloudflare R2.

 */



if (is_admin()) {

    return; // Only rewrite on frontend

}



define('GT_R2_CDN_URL', 'https://r2.yourdomain.com');

define('GT_R2_ORIGIN_URL', 'https://yourdomain.com');



function gt_r2_rewrite_url($url) {

    if (!is_string($url) || empty($url)) {

        return $url;

    }



    // Don't rewrite admin, cron, or REST API requests

    if (is_admin() || wp_doing_cron() || defined('REST_REQUEST')) {

        return $url;

    }



    // Only rewrite allowed extensions

    $allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg',

                'css', 'js', 'woff', 'woff2', 'ttf', 'eot', 'ico',

                'mp4', 'webm', 'pdf'];



    $extension = strtolower(pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION));

    if (!in_array($extension, $allowed)) {

        return $url;

    }



    // Skip manifest files and cache paths

    $skip_patterns = ['/cache/', '.manifest', 'service-worker', 'sw.js'];

    foreach ($skip_patterns as $pattern) {

        if (strpos($url, $pattern) !== false) {

            return $url;

        }

    }



    return str_replace(GT_R2_ORIGIN_URL, GT_R2_CDN_URL, $url);

}

Now, the hooks. Each one catches a different category of asset:

hooks.php
// Media library URLs

add_filter('wp_get_attachment_url', 'gt_r2_rewrite_url', 99);



// Enqueued scripts and stylesheets

add_filter('script_loader_src', 'gt_r2_rewrite_url', 99);

add_filter('style_loader_src', 'gt_r2_rewrite_url', 99);



// Responsive image srcset

add_filter('wp_calculate_image_srcset', function($sources) {

    foreach ($sources as &$source) {

        $source['url'] = gt_r2_rewrite_url($source['url']);

    }

    return $sources;

}, 99);



// Content and block output

add_filter('the_content', 'gt_r2_rewrite_content', 99);

add_filter('render_block', 'gt_r2_rewrite_content', 99);



function gt_r2_rewrite_content($content) {

    if (empty($content)) {

        return $content;

    }

    return str_replace(GT_R2_ORIGIN_URL, GT_R2_CDN_URL, $content);

}

There’s one more edge case. Some themes and plugins hardcode URLs in preload tags or inline styles outside the content filters. For those, I use output buffering as a catch-all:

output-buffer.php
add_action('template_redirect', function() {
    ob_start(function($html) {
        return gt_r2_rewrite_content($html);
    });
});
Pro Tip

Name your mu-plugin file with a leading number like 00-gt-r2-cdn.php so it loads before any caching plugin that might also use output buffering.

The priority 99 on every filter matters. You want the rewrite to run last, after other plugins have finished modifying URLs. If a caching plugin or SEO plugin touches the URL first, your rewrite picks up the final version.

Testing the Rewriter

Open your site in a browser, right-click any image, and check the URL. It should start with r2.yourdomain.com instead of your origin domain. Open DevTools, go to the Network tab, and filter by images, JS, and CSS. Every static asset should be loading from the R2 subdomain.

If you see mixed origins, check which filter is missing. The most common culprit? Hardcoded URLs in theme template files. The output buffer catch-all handles most of these, but occasionally a theme will echo URLs before template_redirect fires. In that case, you’ll need to add specific filters for that theme’s hooks.

Building the Cloudflare Worker

The Worker sits between the browser and R2, handling cache logic, origin pulls, and version management.

Build  Compute  Workers & Pages  Gaurav@gauravtiwari.org's Account  Cloudflare2026

Prerequisites: A Cloudflare account (free plan works), an R2 bucket created from the Cloudflare dashboard, and Wrangler CLI installed (npm install -g wrangler).

Start with the wrangler.toml configuration:

wrangler.toml
name = "r2-cdn-worker"
main = "src/index.js"
compatibility_date = "2024-01-01"

[[r2_buckets]]
binding = "ASSETS_BUCKET"
bucket_name = "your-bucket-name"

[vars]
ORIGIN = "https://yourdomain.com"

Now the Worker itself. I’ll walk through each piece of the logic:

src/index.js
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const path = url.pathname;

    // 1. CORS handling
    if (request.method === 'OPTIONS') {
      return new Response(null, {
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
          'Access-Control-Max-Age': '86400',
        },
      });
    }

    // 2. Bypass cache paths (manifests, service workers)
    if (path.includes('/cache/') || path.endsWith('.manifest')) {
      return fetch(new URL(path, env.ORIGIN));
    }

    // 3. R2 lookup
    const r2Key = path.startsWith('/') ? path.slice(1) : path;
    let object = await env.ASSETS_BUCKET.get(r2Key);

    // 4. Version refresh check
    const requestedVer = url.searchParams.get('ver');
    if (object && requestedVer) {
      const storedVer = object.customMetadata?.ver;
      if (storedVer && storedVer !== requestedVer) {
        object = null; // Force re-fetch
      }
    }

    // 5. HIT -- serve from R2
    if (object) {
      return buildResponse(object, 'HIT');
    }

    // 6. MISS -- pull from origin, store, serve
    const originUrl = new URL(path, env.ORIGIN);
    originUrl.search = url.search;

    const originResponse = await fetch(originUrl.toString());

    if (!originResponse.ok) {
      // 7. Check if this is a sized image variant
      const sizeMatch = path.match(
        /-(\d+)x(\d+)\.(jpg|jpeg|png|webp|avif|gif)$/i
      );
      if (sizeMatch) {
        return handleResize(env, path, sizeMatch);
      }
      return new Response('Not Found', { status: 404 });
    }

    // Store in R2 with metadata
    const body = await originResponse.arrayBuffer();
    const metadata = { ver: requestedVer || 'initial' };

    await env.ASSETS_BUCKET.put(r2Key, body, {
      httpMetadata: {
        contentType: originResponse.headers.get('content-type'),
      },
      customMetadata: metadata,
    });

    // Fetch the stored object to serve
    object = await env.ASSETS_BUCKET.get(r2Key);
    const cacheHeader = requestedVer ? 'MISS-REFRESH' : 'MISS';
    return buildResponse(object, cacheHeader);
  },
};
Version management flow: Worker compares ?ver= parameter against stored metadata

The buildResponse function sets aggressive cache headers:

build-response.js
function buildResponse(object, cacheStatus) {
  const headers = new Headers();
  headers.set('Content-Type',
    object.httpMetadata?.contentType || 'application/octet-stream');
  headers.set('Cache-Control',
    'public, max-age=31536000, immutable');
  headers.set('X-Cache', cacheStatus);
  headers.set('Access-Control-Allow-Origin', '*');

  if (object.httpMetadata?.contentEncoding) {
    headers.set('Content-Encoding',
      object.httpMetadata.contentEncoding);
  }

  return new Response(object.body, { headers });
}

Let me explain the X-Cache header values because they’re useful for debugging:

  • HIT means the file was already in R2 and served directly. This is the fast path.
  • MISS means the file wasn’t in R2, so the Worker fetched it from origin, stored it, and served it. Next request will be a HIT.
  • MISS-REFRESH means the file existed in R2 but the ?ver= parameter didn’t match. The Worker fetched a fresh copy. This handles WordPress cache busting when plugins or themes update.
  • BYPASS means the request matched a skip pattern (cache paths, manifests) and went straight to origin.

The version check (step 4) is something I don’t see in most R2 tutorials, but it’s critical for WordPress. When you update a plugin, WordPress changes the ?ver= parameter on its scripts and styles. Without this check, R2 would keep serving the old version forever because max-age=31536000 tells browsers to cache for a year. The Worker compares the requested version against the stored metadata and forces a refresh when they don’t match.

Deploying with Wrangler

Deploy is one command:

deploy.sh
npx wrangler deploy

For the custom domain, go to your Cloudflare dashboard, navigate to Workers & Pages, find your Worker, and add a Custom Domain. Point it to r2.yourdomain.com. Cloudflare handles the SSL certificate automatically. The DNS record gets created for you.

One important note: don’t use a Workers Route with a wildcard pattern. Use the Custom Domain feature instead. Routes can conflict with other Cloudflare features. Custom Domains are cleaner.

On-Demand Image Resizing

WordPress generates all thumbnail sizes when you upload an image. If you have 6 registered image sizes (thumbnail, medium, medium_large, large, 1536×1536, 2048×2048), every upload creates 6 extra files. That’s fine for new uploads. But what about existing images that don’t have a newly registered size? Or sized variants that were never uploaded to R2?

The on-demand resizer fixes this. When the Worker can’t find a sized image variant in R2 or on the origin, it calls back to a resize endpoint on your WordPress site. That endpoint generates the image on the fly, streams it back, and the Worker stores it in R2 permanently.

On-demand image resize flow: Worker detects missing variant, calls WordPress resize endpoint, stores result in R2

Here’s the WordPress-side mu-plugin:

gt-resize.php
<?php
/**
 * Plugin Name: GT On-Demand Image Resize
 * Description: Generates image variants on request from the R2 Worker.
 */

add_action('init', function() {
    if (!isset($_GET['gt_resize'])) {
        return;
    }

    // Verify shared secret
    $secret = $_SERVER['HTTP_X_RESIZE_SECRET'] ?? '';
    if ($secret !== defined('GT_RESIZE_SECRET') ? GT_RESIZE_SECRET : '') {
        http_response_code(403);
        exit('Unauthorized');
    }

    $path = sanitize_text_field($_GET['path'] ?? '');
    $width = absint($_GET['w'] ?? 0);
    $height = absint($_GET['h'] ?? 0);

    // Security: cap dimensions
    if ($width > 2560 || $height > 2560 || $width === 0) {
        http_response_code(400);
        exit('Invalid dimensions');
    }

    // Resolve to absolute file path
    $upload_dir = wp_upload_dir();
    $base_path = $upload_dir['basedir'];
    $source = realpath($base_path . '/' . ltrim($path, '/'));

    if (!$source || strpos($source, $base_path) !== 0) {
        http_response_code(404);
        exit('Source not found');
    }

    // Allowed extensions
    $ext = strtolower(pathinfo($source, PATHINFO_EXTENSION));
    $allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif'];
    if (!in_array($ext, $allowed)) {
        http_response_code(400);
        exit('Unsupported format');
    }

    // Resize using WordPress image editor
    $editor = wp_get_image_editor($source);
    if (is_wp_error($editor)) {
        http_response_code(500);
        exit('Resize failed');
    }

    $editor->resize($width, $height, true);

    // Set quality based on format
    $quality_map = [
        'webp' => 82,
        'avif' => 72,
        'jpg'  => 82,
        'jpeg' => 82,
        'png'  => 9,
    ];
    $editor->set_quality($quality_map[$ext] ?? 82);

    // Stream the result
    $mime_types = [
        'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
        'png' => 'image/png', 'gif' => 'image/gif',
        'webp' => 'image/webp', 'avif' => 'image/avif',
    ];

    header('Content-Type: ' . ($mime_types[$ext] ?? 'application/octet-stream'));
    $editor->stream($mime_types[$ext]);
    exit;
});

Define the shared secret in your wp-config.php:

wp-config.php
define('GT_RESIZE_SECRET', 'your-random-secret-here');

And in the Worker, the resize handler sends the request with the secret:

resize-handler.js
async function handleResize(env, path, sizeMatch) {
  const [, width, height, ext] = sizeMatch;
  const basePath = path.replace(
    `-${width}x${height}.${ext}`, `.${ext}`
  );

  const resizeUrl = new URL('/', env.ORIGIN);
  resizeUrl.searchParams.set('gt_resize', '1');
  resizeUrl.searchParams.set('path', basePath);
  resizeUrl.searchParams.set('w', width);
  resizeUrl.searchParams.set('h', height);

  const response = await fetch(resizeUrl.toString(), {
    headers: { 'X-Resize-Secret': env.RESIZE_SECRET },
  });

  if (!response.ok) {
    return new Response('Resize failed', { status: 404 });
  }

  const body = await response.arrayBuffer();
  const r2Key = path.startsWith('/') ? path.slice(1) : path;

  await env.ASSETS_BUCKET.put(r2Key, body, {
    httpMetadata: {
      contentType: response.headers.get('content-type'),
    },
  });

  const object = await env.ASSETS_BUCKET.get(r2Key);
  return buildResponse(object, 'MISS-RESIZE');
}

Quality Settings I Use

I’ve tested a lot of compression levels. These are my sweet spots:

  • WebP: quality 82. Below 80, you start losing detail in gradients and text overlays. Above 85, the file size increase isn’t worth the barely-visible quality gain.
  • AVIF: quality 72. AVIF compresses much better than WebP, so a lower quality number still produces great results. At 72, my AVIF files are typically 30-40% smaller than equivalent WebP files.
  • JPEG: quality 82. Same logic as WebP. The 80-85 range is where JPEG quality and file size find their balance.
  • PNG: compression level 9. PNG is lossless, so this controls compression effort, not quality. Level 9 takes slightly longer but produces the smallest files.

I didn’t arrive at these numbers randomly. I ran visual comparison tests across 200+ images from my media library, checking for artifacts on different image types: photographs, screenshots, graphics with text, and transparent PNGs. These settings produce files that are visually identical to the originals on screens up to 4K resolution.

The Migration Strategy: Lazy Is Better

The traditional approach to moving WordPress assets to object storage involves a bulk upload. You’d install a plugin like WP Offload Media, configure it to sync your uploads folder to S3 or R2, wait for it to upload thousands of files, then hope nothing breaks. I’ve done this. It’s stressful. And it often takes hours for sites with large media libraries.

My approach is different. I don’t upload anything.

The pull-through cache handles migration automatically. When a visitor requests an image, the Worker checks R2 first. If it’s not there, the Worker pulls it from the origin, stores it in R2, and serves it. The next visitor gets it straight from R2. No Worker-to-origin trip needed.

Over the first few days, R2 fills up with all the actively-used assets. The homepage images get cached on day one. Popular blog post images follow within hours. Older content that still gets organic traffic fills in over a few weeks.

And here’s the part I love… dead assets never get uploaded. That image from a 2018 blog post that gets zero traffic? It stays on your origin and never wastes R2 storage. You’re only storing what people actually request, which keeps costs proportional to real usage.

The downside? The first visitor to each uncached asset sees a slightly slower response (maybe 200-300ms extra for the origin fetch). But that happens exactly once per asset. After that, it’s edge-cached permanently. And honestly, for most sites, R2 fills up so fast that you’d never notice.

No downtime, no broken images, and no migration scripts to maintain.

Real-World Numbers

I’ve been running this setup on gauravtiwari.org since late 2025. Here’s what the numbers look like.

$ 2.41
Monthly R2 Bill
98.5 %
Cache Hit Rate
15 ms
Edge TTFB (ms)
94 %
Bandwidth Saved
Performance metrics: Before and after R2 implementation showing TTFB, cache hit rate, and bandwidth savings
MetricBefore R2After R2
R2 storage usedN/A~150 GB
Monthly R2 billN/A$2-3/month
Asset TTFB (origin)180-400msN/A
Asset TTFB (R2 edge)N/A15-45ms
Cache hit rate (after 30 days)N/A~98.5%
LCP improvementBaseline-0.3 to -0.8s
Origin bandwidth saved0%~94%

The TTFB difference is the most noticeable. Going from 180-400ms (depending on server load and geographic distance) to 15-45ms for every image, stylesheet, and font file makes a real difference in how fast pages feel.

My server setup is already pretty fast. Hetzner dedicated box, CloudPanel, LiteSpeed, Redis. But no origin server, no matter how fast, can beat an edge network with 300+ locations. The file is physically closer to the user, so it arrives faster.

The LCP improvement varies by page. Image-heavy pages saw the biggest gains, sometimes 0.8 seconds faster. Text-heavy pages with fewer assets saw smaller improvements, around 0.3 seconds. But every tenth of a second matters for Core Web Vitals. And once you start optimizing WordPress performance at this level, those fractional improvements compound.

My R2 cache hit rate stabilized at about 98.5% after the first month. That means only 1.5% of requests need an origin fetch. And most of those are first-time hits on old content that someone found through search. The Worker handles them transparently.

Important
Always test your R2 setup on a staging site first. A misconfigured URL rewriter can break your entire frontend by pointing assets to non-existent R2 paths.

Common Issues and Fixes

I ran into all of these while building this system. Save yourself the debugging time.

  • Double-encoding in URLs. If your WordPress URL contains special characters (spaces encoded as %20, for example), the str_replace in the mu-plugin can break things. Make sure you’re comparing normalized URLs. I ran into this with a few media files that had spaces in the filename. The fix was simple: don’t rename existing files, just make sure parse_url() handles the encoding correctly before you do the replacement.
  • Manifest files must stay same-origin. If your site uses a web app manifest (manifest.json or site.webmanifest), don’t rewrite that URL. Browsers require it to be same-origin for PWA features. The skip pattern in the mu-plugin handles this, but double-check.
  • Cache busting with ?ver= parameter. This is why the version check in the Worker exists. Without it, updating a plugin would leave the old version of its CSS/JS in R2 forever (or at least until the immutable cache expired in the browser, which is… a year). The Worker compares the ?ver= value against stored metadata and re-fetches when they differ.
  • Mixed content. If your origin is HTTPS (it should be), make sure your R2 custom domain also uses HTTPS. Cloudflare handles this automatically with Custom Domains, but if you’re using a Workers Route with a subdomain, verify the SSL certificate is active.
  • Font CORS. Browsers enforce CORS for web fonts loaded cross-origin. The Worker’s Access-Control-Allow-Origin: * header handles this. If you’re still seeing CORS errors on fonts, check that your origin isn’t also sending conflicting CORS headers. Two different Access-Control-Allow-Origin headers in the same response will cause browsers to reject the request.
  • Output buffering conflicts. Some plugins (especially caching plugins and page builders) use output buffering too. If the R2 rewrite isn’t working on certain parts of the page, it might be an OB conflict. The fix is usually adjusting the priority of your template_redirect hook. I use priority 1 (very early) to start buffering before other plugins.
  • Admin panel exclusion. The mu-plugin returns early on is_admin(). Don’t skip this check. The WordPress admin needs same-origin URLs for media uploads, editor previews, and AJAX requests. Rewriting admin URLs to R2 breaks things in subtle, annoying ways.

Is This Setup Right for You?

Before going down this road, make sure your base WordPress performance is solid. My Perfmatters review covers the script management side, and the image compression guide handles pre-upload optimization. R2 handles delivery, but garbage in means garbage out.

This is a custom solution. It’s not a one-click plugin. You need to be comfortable with Cloudflare Workers, mu-plugins, and basic debugging.

Good Fit

  • 1,000+ images in media library
  • 50,000+ monthly pageviews
  • Already using Cloudflare for DNS
  • Want lowest possible asset delivery cost
  • Comfortable with Workers and mu-plugins

Overkill If

  • Under 10,000 monthly visits
  • Fewer than a few hundred images
  • Don’t want to touch code or Workers

For simpler needs? A CDN pull zone from BunnyCDN or Cloudflare’s built-in CDN (just flip the orange cloud on) works fine. BunnyCDN costs about $1/month for most WordPress sites. It won’t give you the on-demand resize or pull-through caching, but for straightforward asset delivery, it gets the job done.

The difference is control. With this R2 setup, I control every header, every cache rule, every fallback behavior. If I want to add AVIF auto-conversion tomorrow, I add a few lines to the Worker. If I want to purge a single asset, I delete it from the R2 bucket and the Worker re-fetches it. That kind of flexibility matters when you’re running a site at scale and performance is part of your brand.

Key Takeaway
Cloudflare R2 eliminates CDN bandwidth costs entirely. Combined with a pull-through cache Worker, your R2 bucket fills itself automatically as visitors browse your site. No migration scripts, no bulk uploads, no sync tools. Total cost for 150GB of assets: roughly $2.50/month.

Frequently Asked Questions

How much does Cloudflare R2 actually cost for a WordPress site?

My bill runs about $2-3/month for a 10GB media library serving hundreds of thousands of pageviews. R2 charges $0.015 per GB/month for storage and $0 for bandwidth egress. The free tier covers 10 GB storage and 10 million reads per month. Compare that to S3 at $0.09/GB for egress alone, and the math becomes obvious fast.

Do I need to bulk-upload my existing WordPress media to R2?

Not with the pull-through cache architecture I describe. The Cloudflare Worker pulls files from your origin server on first request, stores them in R2, then serves every subsequent request from R2 directly. Your bucket fills itself automatically as visitors hit the site. No migration scripts, no manual uploads.

Will this setup break my WordPress Media Library?

No. The mu-plugin rewrites asset URLs at the output layer, so WordPress still thinks files live on your server. You upload images through the standard Media Library interface, they sit on your origin until first request, then get cached in R2 permanently. Nothing changes in your admin workflow.

What’s the difference between Cloudflare R2 and using a traditional CDN like BunnyCDN?

The key difference is egress pricing. BunnyCDN charges around $0.01/GB for bandwidth, which is already cheap. R2 charges nothing for egress. For high-traffic sites serving lots of images, that adds up fast. R2 also lets you put a Cloudflare Worker in front of the bucket for custom cache logic, on-demand image resizing, and other edge processing that standalone CDNs can’t match.

Does this work for any WordPress hosting, or only specific hosts?

It works with any WordPress host. The mu-plugin runs on your WordPress installation and rewrites URLs regardless of where your site is hosted. The Cloudflare Worker handles requests at the edge. As long as your domain uses Cloudflare for DNS (which it should), you can plug this in on shared hosting, VPS, or managed WordPress hosting.

I’ve been running this exact setup on gauravtiwari.org for a few months now. It handles every image, every stylesheet, every font file. I haven’t had a single R2-caused outage, sync issue, or surprise bill. The total cost is lower than a single cup of coffee per month.

If you’re serious about WordPress performance, asset delivery compounds across every page. Faster images improve LCP, which improves Core Web Vitals, which gives you a small ranking boost on every URL. Multiply that across hundreds of pages and the impact is real.

The code in this guide is production-ready. I’m running it right now. Grab it, adapt it to your domain, deploy it, and watch your R2 bucket fill itself while your origin server takes a well-deserved break.

Leave a Comment