Implementation guide

Cloudflare R2 as the WordPress media library, SDK-free, SigV4 in pure PHP

A 200-line MU-plugin that replaces wp-content/uploads with Cloudflare R2, with no AWS SDK and no Composer dependency. The full SigV4 signer, the four WordPress hooks that matter, and the configuration that makes it transparent to editors. Shipped on 12 production sites.

By Ritesh + DebarshiMay 18, 202620 min read
Cloudflare R2 as the WordPress media library, SDK-free SigV4 in pure PHP

Why R2, why no SDK

Most managed WordPress hosts give you 10-50 GB of local disk and treat anything past that as a premium tier. R2 gives you S3-compatible object storage with no egress fees at $0.015/GB-month, meaningfully cheaper than S3 if you serve images outside AWS, which is every WordPress site.

There are mature plugins for offloading media to S3 or R2. We have used WP Offload Media on several sites; it works, it is supported, it has a UI. The reason we ended up writing this MU-plugin is simpler: most managed hosts that we deploy on (Kinsta, Cloudways, WPEngine) restrict either Composer or the AWS SDK at runtime, and the official plugins drag in 200-400 files of dependencies whether you use them or not. A 200-line single-file MU-plugin with a custom SigV4 signer is easier to reason about, easier to audit, and easier to deploy under those constraints. This MU-plugin ships by default on every site we build through our custom WordPress development engagement.

The SigV4 problem

S3-compatible APIs (R2 included) require AWS SigV4 request signing. The signing algorithm is precisely specified by AWS and not complicated, but it has six steps that have to be done in exactly the right order with exactly the right casing. The reference is Signature Version 4 signing process from the IAM docs. The full signer below is around 80 lines of PHP.

The signer, in full

Pure PHP, requires only hash_hmac and curl. No autoloader needed. Designed to be dropped into a WordPress MU-plugin or any classic-PHP project.

includes/class-r2-sigv4.php, the full signerphp
<?php
class R2_SigV4 {
    private string $accessKey;
    private string $secretKey;
    private string $accountId;
    private string $region = "auto";

    public function __construct(string $accessKey, string $secretKey, string $accountId) {
        $this->accessKey = $accessKey;
        $this->secretKey = $secretKey;
        $this->accountId = $accountId;
    }

    /**
     * Sends a SigV4-signed HTTP request to R2.
     *
     * @param string $method    GET|PUT|DELETE|HEAD
     * @param string $bucket    bucket name
     * @param string $key       object key (no leading slash)
     * @param string $body      raw body (empty for GET/DELETE)
     * @param array  $headers   extra request headers; Content-Type etc.
     * @return array { status:int, headers:array, body:string }
     */
    public function request(
        string $method,
        string $bucket,
        string $key,
        string $body = "",
        array $headers = []
    ): array {
        $host = "{$this->accountId}.r2.cloudflarestorage.com";
        $path = "/" . rawurlencode($bucket) . "/" . str_replace("%2F", "/", rawurlencode($key));
        $amzDate = gmdate("Ymd\THis\Z");
        $dateStamp = gmdate("Ymd");
        $payloadHash = hash("sha256", $body);

        $canonicalHeaders =
            "host:{$host}\n" .
            "x-amz-content-sha256:{$payloadHash}\n" .
            "x-amz-date:{$amzDate}\n";
        $signedHeaders = "host;x-amz-content-sha256;x-amz-date";

        $canonicalRequest =
            "{$method}\n" .
            "{$path}\n" .
            "" . "\n" .          // canonical query string (none here)
            "{$canonicalHeaders}\n" .
            "{$signedHeaders}\n" .
            "{$payloadHash}";

        $scope = "{$dateStamp}/{$this->region}/s3/aws4_request";
        $stringToSign =
            "AWS4-HMAC-SHA256\n" .
            "{$amzDate}\n" .
            "{$scope}\n" .
            hash("sha256", $canonicalRequest);

        $kDate    = hash_hmac("sha256", $dateStamp,    "AWS4{$this->secretKey}", true);
        $kRegion  = hash_hmac("sha256", $this->region, $kDate, true);
        $kService = hash_hmac("sha256", "s3",            $kRegion, true);
        $kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
        $signature = hash_hmac("sha256", $stringToSign, $kSigning);

        $authorization =
            "AWS4-HMAC-SHA256 " .
            "Credential={$this->accessKey}/{$scope}, " .
            "SignedHeaders={$signedHeaders}, " .
            "Signature={$signature}";

        $reqHeaders = array_merge([
            "Host: {$host}",
            "X-Amz-Date: {$amzDate}",
            "X-Amz-Content-Sha256: {$payloadHash}",
            "Authorization: {$authorization}",
        ], $headers);

        $ch = curl_init("https://{$host}{$path}");
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST  => $method,
            CURLOPT_HTTPHEADER     => $reqHeaders,
            CURLOPT_POSTFIELDS     => $body,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADER         => true,
            CURLOPT_TIMEOUT        => 30,
        ]);
        $raw = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
        curl_close($ch);

        return [
            "status"  => (int) $status,
            "headers" => substr($raw, 0, $headerSize),
            "body"    => substr($raw, $headerSize),
        ];
    }
}

Three things to verify when you drop this in: the host (R2 endpoints use the account-id subdomain, not s3.); the region literal "auto" (R2 does not use AWS regions, but the algorithm requires a region string); and the payload hash on empty bodies must still be the SHA-256 of the empty string, do not skip it.

The WordPress integration

Four hooks turn that signer into a transparent S3-backed media library. Each one is small. The combined plugin is ~110 lines of WordPress glue on top of the ~80-line signer.

Hook 1, intercept uploads

Runs every time WordPress receives an uploaded file. We push it to R2 then return a path the rest of WP will treat as canonical.

hooks/upload.php, wp_handle_uploadphp
add_filter("wp_handle_upload", function ($upload) {
    if (!isset($upload["file"]) || !file_exists($upload["file"])) {
        return $upload;
    }

    $r2 = appycodes_r2_client();
    $key = appycodes_r2_key_for($upload["file"]);

    $put = $r2->request(
        "PUT",
        APPYCODES_R2_BUCKET,
        $key,
        file_get_contents($upload["file"]),
        ["Content-Type: " . $upload["type"]]
    );

    if ($put["status"] !== 200) {
        error_log("R2 upload failed: " . $put["body"]);
        return $upload;
    }

    // Keep the file locally for image-size generation; we'll push
    // the resized variants on wp_generate_attachment_metadata below,
    // then optionally clean up.
    $upload["url"] = APPYCODES_R2_PUBLIC_BASE . "/" . $key;
    return $upload;
}, 10, 1);

Hook 2, push generated thumbnail sizes

WordPress generates resized variants (thumbnail, medium, large) right after upload. We capture each and push it to R2.

hooks/sizes.php, wp_generate_attachment_metadataphp
add_filter("wp_generate_attachment_metadata", function ($metadata, $attachment_id) {
    if (empty($metadata["file"])) return $metadata;

    $uploads = wp_get_upload_dir();
    $baseDir = trailingslashit($uploads["basedir"]);
    $relDir = dirname($metadata["file"]);
    $r2 = appycodes_r2_client();

    foreach ((array) ($metadata["sizes"] ?? []) as $size => $info) {
        $absPath = $baseDir . $relDir . "/" . $info["file"];
        if (!file_exists($absPath)) continue;
        $key = ltrim($relDir . "/" . $info["file"], "/");
        $r2->request(
            "PUT",
            APPYCODES_R2_BUCKET,
            $key,
            file_get_contents($absPath),
            ["Content-Type: " . $info["mime-type"] ?? "image/jpeg"]
        );
    }

    return $metadata;
}, 10, 2);

Hook 3, serve URLs from R2

Replaces the URLs WordPress generates for every <img>, every gallery, every API response. The site keeps using normal WP functions; it just gets back R2 URLs.

hooks/urls.php, wp_get_attachment_url + upload_dirphp
add_filter("upload_dir", function ($dirs) {
    $dirs["baseurl"] = APPYCODES_R2_PUBLIC_BASE;
    $dirs["url"]     = APPYCODES_R2_PUBLIC_BASE . $dirs["subdir"];
    return $dirs;
});

add_filter("wp_get_attachment_url", function ($url, $attachment_id) {
    $file = get_post_meta($attachment_id, "_wp_attached_file", true);
    if (!$file) return $url;
    return APPYCODES_R2_PUBLIC_BASE . "/" . ltrim($file, "/");
}, 10, 2);

add_filter("wp_get_attachment_image_src", function ($image, $attachment_id, $size) {
    if (is_array($image)) {
        $file = get_post_meta($attachment_id, "_wp_attached_file", true);
        if ($file) {
            $dir = dirname($file);
            $basename = basename($image[0]);
            $image[0] = APPYCODES_R2_PUBLIC_BASE . "/" . trim("$dir/$basename", "/");
        }
    }
    return $image;
}, 10, 3);

Hook 4, delete from R2 when WP deletes

hooks/delete.php, wp_delete_attachmentphp
add_action("delete_attachment", function ($attachment_id) {
    $file = get_post_meta($attachment_id, "_wp_attached_file", true);
    if (!$file) return;

    $r2 = appycodes_r2_client();
    $r2->request("DELETE", APPYCODES_R2_BUCKET, $file);

    $metadata = wp_get_attachment_metadata($attachment_id);
    foreach ((array) ($metadata["sizes"] ?? []) as $info) {
        $key = ltrim(dirname($file) . "/" . $info["file"], "/");
        $r2->request("DELETE", APPYCODES_R2_BUCKET, $key);
    }
});

Public access, custom domain over R2

R2's public endpoint (pub-<hash>.r2.dev) works out of the box, but is rate-limited and not for production. Connect a custom domain via Cloudflare DNS to get unlimited public access at the standard $0.015/GB-month rate.

Connect custom domain (one-time setup)text
1. Cloudflare dashboard -> R2 -> Buckets -> your bucket -> Settings
2. Custom Domains -> "Connect Domain"
3. Enter "media.example.com"
4. Cloudflare creates a CNAME record automatically (if the domain is
   already on Cloudflare DNS).
5. Wait 30-60s for SSL provisioning.
6. Verify with: curl -I https://media.example.com/test-image.jpg

Then set in wp-config.php:
  define("APPYCODES_R2_PUBLIC_BASE", "https://media.example.com");

The complete MU-plugin bootstrap

Drop this single file in wp-content/mu-plugins/ (with the class file alongside). MU-plugins are auto-loaded, no activation step. The wp-config constants are read at request time so credentials never appear in the database.

wp-content/mu-plugins/appycodes-r2.php, entry pointphp
<?php
/**
 * Plugin Name: Appycodes R2 Media
 * Description: Offload WordPress media to Cloudflare R2, no SDK required.
 * Version: 1.0.0
 */

require_once __DIR__ . "/r2/class-r2-sigv4.php";

if (!defined("APPYCODES_R2_ACCOUNT_ID")) return;
if (!defined("APPYCODES_R2_ACCESS_KEY")) return;
if (!defined("APPYCODES_R2_SECRET_KEY")) return;
if (!defined("APPYCODES_R2_BUCKET")) return;
if (!defined("APPYCODES_R2_PUBLIC_BASE")) return;

function appycodes_r2_client(): R2_SigV4 {
    static $client = null;
    if ($client === null) {
        $client = new R2_SigV4(
            APPYCODES_R2_ACCESS_KEY,
            APPYCODES_R2_SECRET_KEY,
            APPYCODES_R2_ACCOUNT_ID
        );
    }
    return $client;
}

function appycodes_r2_key_for(string $abs_path): string {
    $uploads = wp_get_upload_dir();
    $baseDir = trailingslashit($uploads["basedir"]);
    return ltrim(str_replace($baseDir, "", $abs_path), "/");
}

require_once __DIR__ . "/r2/hooks/upload.php";
require_once __DIR__ . "/r2/hooks/sizes.php";
require_once __DIR__ . "/r2/hooks/urls.php";
require_once __DIR__ . "/r2/hooks/delete.php";

What it costs

On our 12 sites running this MU-plugin in production: monthly R2 cost is between $0.85 and $14, with the median at $2.40. The site with the highest cost has 850 GB of media (a media publisher with 12 years of article images); the cheapest is a B2B SaaS marketing surface with 60 GB. Egress is free under R2's zero-egress model regardless of how much we serve.

For comparison, the same 850 GB on S3 with comparable egress would land around $76/month at typical traffic levels, the egress is what kills S3 for image-serving workloads. The architectural decisions behind that cost picture are similar in shape to the ones covered in our companion WordPress performance study, image weight is consistently the largest moveable cost on a typical WP site.

Limitations and edge cases

The MU-plugin is intentionally small, it does not try to do everything WP Offload Media does. Things it does not handle, and what we do instead:

  • Migrating an existing media library. We use rclone to bulk-copy from wp-content/uploads to R2, then run a one-shot SQL UPDATE to rewrite old URLs in post content. This is a 1-hour task per site, run during a maintenance window, usually wrapped into the next monthly cycle of our maintenance retainer.
  • Image CDN transforms. Cloudflare Images and Image Resizing live alongside R2 if you want them; the plugin doesn't integrate them directly. We've done that integration on three of the 12 sites; happy to share the snippets if useful.
  • Private buckets. The signer above can sign GET requests too; we use that for a separate use case (per-customer downloads), not for the public media library, usually as part of an API & integration engagement wired into the existing WP build.
  • Backup / replication.We use the Cloudflare-side cron to push a daily snapshot of the bucket to a Backblaze B2 cold-storage bucket. R2 doesn't replicate cross-region by default.

Companion WordPress and SEO pieces the same engagements draw from:

The custom WordPress engagement that ships R2 as the default media backend, the retainer that runs the migration as one task, and the API engagement where private signed-URL flows fit:

Frequently asked questions

Why use Cloudflare R2 instead of Amazon S3 for WordPress media?
Zero egress fees. R2 charges $0.015/GB-month for storage with no per-request egress cost, while S3 charges $0.09/GB egress on top of storage. For an 850GB image-heavy WordPress site, the difference is roughly $76/mo on S3 vs $14/mo on R2 at typical traffic levels.
Do I need the AWS SDK to upload to R2 from WordPress?
No. R2 speaks the S3 API but the SigV4 signing algorithm is precisely specified by AWS and can be implemented in ~80 lines of PHP using only `hash_hmac` and `curl`. A 200-line single-file MU-plugin is easier to audit and easier to deploy on managed hosts than the official 200-400-file SDK bundle.
How do I migrate an existing WordPress media library to R2?
Use `rclone` to bulk-copy `wp-content/uploads` to the R2 bucket, then run a one-shot SQL UPDATE to rewrite old URLs in `wp_posts.post_content`. A 1-hour maintenance window per site is enough for media libraries up to a few hundred GB; larger libraries are a longer rclone job but the WP-side cutover is still a few minutes.

The engagements this writing comes from

UK · Europe · Worldwide

selected work

Where these numbers came from

Production systems with the delivery figures attached.

Creoate product interface01
B2B commerce

The engineering partnership behind a cross-border wholesale marketplace.

Next.js storefront, Python ingestion pipelines, DynamoDB data layer and AWS infrastructure.

8+ yearsone team, still shipping
Ontick product interface02
Event technology

A commission-free ticketing platform built for ownership and scale.

Multi-organiser commerce, Stripe instalments and two native apps in one connected platform.

£2M+processed since launch
Easyship product interface03
Global logistics

Embedded product engineering for a global shipping platform.

Rate, tax and duty calculators, server-rendered courier pages and a custom MongoDB CMS.

550+couriers on the calculator
TEFL.ie product interface04
Education & training

A course-commerce and learning platform wired into one system.

WordPress and WooCommerce, a Moodle LMS, Stripe deposits and Zoho CRM, tied together with Zapier automation.

8 yrsengineer & run, since 2017
All White Laser product interface05
Medical aesthetics

A bespoke platform that finances aesthetic machines on Direct Debit.

A lead-to-billing system on GoCardless Direct Debit, provider certification, and a React Native app for machine owners.

9 yrsbuild & run, since 2017
Decofetch product interface06
Luxury commerce

A custom furniture marketplace engineered from storefront to infrastructure.

Server-rendered Next.js commerce over a Laravel API, bespoke operations tooling and re-architected AWS infrastructure.

0→livecustom, front to back
BA Engine Room product interface07
AI operations

An AI-native operating system that runs a consultancy lead to invoice.

Discovery briefs, e-signed contracts, Stripe deposits, delivery milestones and time tracking in one operational system.

0→1built from the ground up
PlusHeat product interface08
Home services

A conversion platform for a growing UK boiler-cover provider.

Custom plan configuration, postcode-qualified lead journeys, CRM synchronisation and campaign landing pages.

5 yrsweb partner since 2021
Léonia product interface09
Beauty commerce

A custom Shopify store for a French beauty brand.

Custom theme, customer accounts, loyalty rewards, referrals and gift-with-purchase offers.

5 yrspartners since 2021
Shutters 365 product interface10
Home improvement

Made-to-measure shutters with live pricing.

A seven-step product builder with live previews, sample orders and supplier tools.

7-stepconfigurator, live pricing
Bloc Ads Manager product interface11
Advertising

A self-service advertising platform for venues.

Campaign creation, audience targeting, in-app ads and reporting linked to venue check-ins.

check-insclosed-loop attribution
Bloc product interface12
Social events

An events app with the tools to run it.

Mobile app, backend, advertising tools, a digital marketplace and website.

4+ yrsone team, five codebases
Zonely product interface13
Social mobile

Two mobile apps for real-time companionship.

Customer and buddy apps with per-minute billing, wallets, moderation and admin tools.

2 appsconsumer + buddy, iOS & Android
Player Profile Hub product interface14
Grassroots football

Player profiles and discovery for youth football.

Verified profiles, video highlights, coach discovery and safeguarding on web and mobile.

0→1built from the ground up
DeepSpatial product interface15
Geospatial AI

Websites and a talent platform for DeepSpatial.

Corporate and investor pages, the Xploor talent platform and ongoing releases on AWS Amplify.

2 yrsone team, ongoing
Yippee Malta product interface16
Travel

Tour bookings with a custom mobile-first checkout.

A multilingual website connected to the booking API, with deposits, coupons and affiliate tracking.

90+core web vitals, mobile & desktop
Professional Energy product interface17
Energy brokerage

One platform for tenders, contracts and accounts.

Supplier tenders, contract management, brokerage accounting and client records.

100+suppliers in one tender

Tell us what you are trying to build.

A thirty-minute call with the engineer who would run it.

Book a call