appycodes.

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.

May 18, 202620 min readBy Ritesh + Debarshi
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.

Let's build

Taking the first step is the hardest. Everything after, we make simple.

Contact