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?

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

Is Cloudflare R2 free for WordPress sites?

R2 has a generous free tier: 10 GB storage, 10 million Class B (read) operations, and 1 million Class A (write) operations per month. Small WordPress sites with limited media libraries can run entirely within this free tier. Once you exceed it, storage costs $0.015/GB/month and bandwidth remains free forever. There are no WordPress-specific restrictions or hidden fees.

How much does a Cloudflare R2 + Worker setup cost per month?

For gauravtiwari.org with about 150 GB stored in R2, the total bill is $2-3/month. The Cloudflare Worker runs on the free Workers plan (100,000 requests/day). The only real cost is R2 storage at $0.015/GB/month. If you store 50 GB, expect roughly $0.75/month. If you store 500 GB, about $7.50/month. Bandwidth is always zero regardless of how much traffic you get.

Can I use Cloudflare R2 with WP Rocket or FlyingPress?

Yes. Caching plugins like WP Rocket and FlyingPress generate cached HTML files that reference your asset URLs. Since the mu-plugin rewrites URLs before the page is cached, the cached HTML already contains the R2 URLs. No conflicts. I run this alongside FlyingPress on my own site without any issues. Just make sure the R2 mu-plugin loads before any caching plugin by naming it alphabetically early (like 00-gt-r2-cdn.php) in the mu-plugins folder.

What happens if the Cloudflare Worker goes down?

Cloudflare Workers have a 99.99% uptime SLA across their global network. In over a year of running this setup, I have not experienced a single Worker outage. But if one did occur, asset requests would fail (404 or 502), and your pages would load without images and with broken styling. As a safety net, you could add a client-side JavaScript fallback that detects failed asset loads and retries from origin. I haven’t needed to build that yet.

Do I need to bulk-upload my existing images to R2?

No. That’s the whole point of the pull-through cache architecture. When a visitor requests an image that isn’t in R2 yet, the Worker fetches it from your origin server, stores it in R2, and serves it. The next visitor gets it straight from R2. Over a few days, R2 fills up with all your actively-used assets automatically. Dead images from old posts that nobody visits never get uploaded, which keeps storage costs proportional to actual usage.

Does this R2 setup work with WooCommerce product images?

Yes. WooCommerce product images use the standard WordPress media library and the same wp_get_attachment_url filter. The mu-plugin rewrites WooCommerce product thumbnails, gallery images, and variation images automatically. The on-demand resizer also handles WooCommerce’s custom image sizes. Avoid running two CDN rewriters simultaneously though. If you’re using WP Offload Media or another S3 plugin, disable it before switching to this R2 setup.

How does R2 handle cache busting when WordPress plugins update?

WordPress appends a ?ver= parameter to script and stylesheet URLs that changes on plugin updates. The Worker compares this version parameter against metadata stored with each file in R2. When the versions don’t match, the Worker fetches a fresh copy from origin, overwrites the old file in R2, and serves the updated version. This happens transparently on the first request after an update. No manual purging needed.

Can I use Cloudflare R2 without a Worker (just the bucket)?

Technically yes, using R2’s public bucket access with a custom domain. But you lose all the smart features: pull-through caching, version management, on-demand image resizing, and custom cache headers. You’d need to bulk-upload all assets manually and re-upload whenever anything changes. The Worker is what makes this setup self-maintaining. Without it, you’re just using R2 as dumb storage, which works but requires significantly more manual effort.

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.

Disclaimer: This site is reader‑supported. If you buy through some links, I may earn a small commission at no extra cost to you. I only recommend tools I trust and would use myself. Your support helps keep gauravtiwari.org free and focused on real-world advice. Thanks. — Gaurav Tiwari

Avatar of Gaurav Tiwari
Written by

Gaurav Tiwari

WordPress Developer & Content Strategist, CEO · Gatilab

I've spent 16 years building performance-driven WordPress solutions for 800+ clients—including IBM, Adobe, HubSpot, Monday.com, and Canva. My work has influenced over $8M in client revenue.

I write about WordPress development, content marketing, and conversion optimization. My plugins have 10,000+ active installs, and I've published 1,800+ articles on marketing and web development.

When I'm not coding or writing, I'm helping businesses turn their websites into revenue engines.

WordPress Core Contributor, 16+ years experience, 800+ client projects

Leave a Comment