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

    includes/class-r2-sigv4.php — the full signer
    php
    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.
    <?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

    hooks/upload.php — wp_handle_upload
    php
    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.
    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

    hooks/sizes.php — wp_generate_attachment_metadata
    php
    WordPress generates resized variants (thumbnail, medium, large) right after upload. We capture each and push it to R2.
    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

    hooks/urls.php — wp_get_attachment_url + upload_dir
    php
    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.
    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_attachment
    php
    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

    wp-content/mu-plugins/appycodes-r2.php — entry point
    php
    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.
    <?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.

    ■ Related services

    Three engagements that ship this work

    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.
    Ritesh — Founding Partner, Appycodes

    About the author

    RiteshFounding Partner, Appycodes

    LinkedIn

    Co-authored with Debarshi Dey, Lead WordPress Engineer

    Ritesh leads engineering at Appycodes. Debarshi leads the WordPress practice day-to-day and has shipped this MU-plugin variant on 12 production sites — including the Hornet Security knowledge base (850 GB of historical media) and Skindays (Magento-era image archive imported in a single maintenance window). The signer code above is the one that ships unmodified to every new WordPress engagement.

    Last reviewed: May 18, 2026

    Full stack web and mobile tech company

    Taking the first step is the hardest. We make everything after that simple.

    Let's talk today