WP Manifestindependent plugin directory
manifest / performance / cloudflare-r2-media-sync

Cloudflare R2 Media Sync

Sync WordPress media files with cloudflare r2 storage

by graphitesprite · github.com/graphitesprite/cloudflare-r2-media-sync

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/graphitesprite/cloudflare-r2-media-sync/archive/refs/heads/main.zip

Cloudflare R2 Media Sync Plugin

A WordPress plugin that securely syncs media uploads to Cloudflare R2 via a Cloudflare Worker, with HMAC authentication and configurable access controls.

Features

  • Secure uploads: HMAC-SHA256 signed requests with 5-minute replay window
  • Multi-site support: Route uploads from multiple WordPress sites to a single R2 bucket with per-site prefixes
  • Flexible media types: Supports images, audio, video, PDFs, documents (APK, Office files, etc.)
  • URL rewriting: Automatically rewrites media URLs to point to the R2 public domain
  • User restrictions: Limit syncing to specific WordPress user accounts
  • File size limits: 200MB soft limit per file (configurable in Worker and plugin)
  • Persistent tracking: Records synced files to prevent broken R2 links on failed syncs
  • Fallback detection: Auto-discovers previously-synced files in R2 if local records are missing

Architecture Overview

WordPress Site
    ↓
Plugin (HMAC sign upload) 
    ↓
Cloudflare Worker (verify HMAC + validate content-type/size)
    ↓
Cloudflare R2 Bucket
    ↓
Public R2 Domain (served via media.yoursite.com)
  • Plugin role: validates file types/sizes locally, signs requests with HMAC, marks successful uploads as synced, rewrites media URLs
  • Worker role: verifies HMAC signature, enforces size/content-type limits, stores in R2, returns 200 on success or appropriate error codes
  • R2 bucket: stores all media organized by site prefix (e.g., mysite.com/YYYY/MM/filename)

Requirements

  • WordPress 5.0+ with media management enabled
  • Cloudflare account with Workers and R2 enabled
  • At least one custom domain for the media Worker (e.g., media.yoursite.com)
  • WP-CLI (optional but recommended for URL migrations)
  • rclone (for initial media migration to R2)

Installation

Step 1: Generate HMAC Secret

On your server, generate a secure random secret:

openssl rand -hex 32

Save this output temporarily in a password manager — you'll need it in steps 2 and 5.

Step 2: Configure Cloudflare Worker

  1. In Cloudflare dashboard, go to Workers & Pages → your Worker → Settings → Variables and Secrets
  2. Add a new encrypted secret (not a regular variable) named HMAC_SECRET_NEWSITE and paste the secret from step 1
  3. Edit your Worker code and add a routing entry for the new site:
if (url.pathname === '/upload/newsite.com/') {
    return handleUpload(request, env, env.HMAC_SECRET_NEWSITE, 'newsite.com/');
}

Replace newsite.com with your actual site domain. Note the trailing slashes in both the pathname and the prefix.

  1. Deploy the Worker (save and deploy in the dashboard)

Step 3: Configure Cloudflare WAF Rule

  1. Go to Security → WAF → Firewall Rules (or Security Policies depending on your plan)
  2. Ensure you have a rule that blocks /upload requests from non-server IPs:
    • Condition: URI Path starts with /upload
    • Action: Block (or challenge)
    • Scope: All countries / all IPs except your server's static IP
  3. Alternatively, use Workers Routes to restrict the /upload/ route to your server IP in the Worker itself (more secure)

This prevents unauthorized uploads from the public internet.

Step 4: Add Custom Domain to Worker

  1. In Cloudflare dashboard, go to Workers & Pages → your Worker → Settings → Domains & Routes
  2. Click Add Custom Domain
  3. Enter the media subdomain (e.g., media.newsite.com) and select your domain
  4. Cloudflare automatically provisions an SSL certificate (may take a few minutes)
  5. Verify the route appears in the dashboard before proceeding

Step 5: Configure wp-config.php

On your WordPress server, add these constants to wp-config.php above the /* That's all, stop editing! */ line:

define('CLOUDFLARE_HMAC_SECRET',   'your-generated-secret-from-step-1');
define('CLOUDFLARE_WORKER_URL',    'https://media.newsite.com/upload/newsite.com/');
define('CLOUDFLARE_R2_PREFIX',     'newsite.com/');
define('CLOUDFLARE_R2_PUBLIC_URL', 'https://media.newsite.com');

Important:

  • Replace newsite.com with your actual domain
  • Replace your-generated-secret-from-step-1 with the HMAC secret from step 1
  • Ensure CLOUDFLARE_WORKER_URL matches the custom domain from step 4 and includes the /upload/newsite.com/ path
  • CLOUDFLARE_R2_PUBLIC_URL must be the public URL where files will be served (same domain as step 4)
  • Note the trailing slashes in paths

Step 6: Install and Activate the Plugin

  1. Download the plugin files (cf-r2-media-sync-plugin.php, cf-r2-media-sync-worker.js)
  2. Create a folder cf-r2-media-sync in /wp-content/plugins/
  3. Copy the plugin file into that folder
  4. In WordPress admin, go to Plugins → Installed Plugins
  5. Find Cloudflare R2 Sync and click Activate
  6. Go to Settings → R2 Sync to verify settings and enter allowed usernames

Step 7: Set Allowed Users

  1. In WordPress admin, go to Settings → R2 Sync
  2. In the Allowed Users field, enter comma-separated usernames (e.g., admin, editor1, editor2)
  3. Only these users' uploads will be synced to R2
  4. Click Save Settings

Security note: Only designate trusted users — synced files become public via the R2 domain.

Step 8: Test a New Upload

  1. Log in as one of the allowed users
  2. Go to Media → Add New and upload a small test image
  3. Watch the admin notices area — you should see a success message "✓ R2 Sync: ... Successfully synced to R2"
  4. Click the View file link to verify the file is accessible at the R2 URL
  5. Edit the media item and confirm the File URL now points to https://media.newsite.com/... instead of /wp-content/uploads/

Step 9: Migrate Existing Media to R2 (Optional)

If you have existing media files, migrate them using rclone.

9a. Install and Configure rclone

  1. Download rclone from https://rclone.org/downloads/
  2. Run rclone config to create a new remote:
    rclone config
  3. Choose New Remote and name it (e.g., r2)
  4. Select S3-compatible storage
  5. Provide Cloudflare R2 credentials:
    • Access Key ID: from R2 settings (Account → R2 → API Tokens → Create API Token)
    • Secret Access Key: same location
    • Endpoint: https://your-account-id.r2.cloudflarestorage.com
    • Region: (leave blank, Cloudflare handles it)
    • ACL: private (optional, R2 ignores this)

9b. Verify rclone Connection

List your bucket to confirm the connection works:

rclone ls yourremotename:YOURBUCKETNAME

(Should return nothing or existing files with no error)

9c. Dry Run

Preview what files will be copied:

rclone copy /path/to/wp-content/uploads/ yourremotename:YOURBUCKETNAME/newsite.com/ --dry-run --verbose

Review the output to ensure:

  • File count looks reasonable
  • Path structure preserves YYYY/MM/ folders
  • No unexpected subdirectories are being copied

9d. Perform Migration

rclone copy /path/to/wp-content/uploads/ yourremotename:YOURBUCKETNAME/newsite.com/ --progress --transfers 10

This copies all files to R2 under the newsite.com/ prefix, preserving folder structure.

9e. Verify Files in R2

Test a few files are accessible:

curl -I https://media.newsite.com/2025/01/example.jpg

Expected response: HTTP/1.1 200 OK with appropriate Content-Type header

Stop here if any 404s occur. Do not proceed to step 10 until files are confirmed accessible.

Step 10: Rewrite Existing Database URLs

Once files are confirmed in R2, update all database references from the local WordPress URL to the R2 URL.

10a. Dry Run with WP-CLI

wp search-replace 'https://newsite.com/wp-content/uploads' 'https://media.newsite.com/newsite.com' --all-tables --dry-run --report-changed-only

Review the output to confirm the replacements look correct (you should see posts, postmeta, etc. with updated URLs).

10b. Apply Changes

wp search-replace 'https://newsite.com/wp-content/uploads' 'https://media.newsite.com/newsite.com' --all-tables

10c. Verify Front-End

  1. Visit your site's homepage and open a page with images
  2. Inspect the image URLs in browser DevTools (right-click image → Inspect)
  3. Confirm images load from https://media.newsite.com/... and no 404s appear
  4. Check the WordPress admin Media Library — thumbnails and previews should display

Configuration

All plugin settings are managed in Settings → R2 Sync. You can also define them in wp-config.php for additional security:

Setting wp-config Constant Default Notes
HMAC Secret CLOUDFLARE_HMAC_SECRET (from options) Define in wp-config to prevent accidental exposure; if defined here, the settings page cannot override
Worker URL CLOUDFLARE_WORKER_URL (from options) Must start with https:// and include /upload/ path; validates on save
R2 Prefix CLOUDFLARE_R2_PREFIX (from options) Folder path in bucket, e.g., mysite.com/
R2 Public URL CLOUDFLARE_R2_PUBLIC_URL (from options) Public-facing domain for served files, e.g., https://media.mysite.com
Allowed Users (none; set in Settings UI) Empty Comma-separated usernames; only these users' uploads are synced

Security Considerations

HMAC Secret Management

  • Do not commit CLOUDFLARE_HMAC_SECRET to version control
  • Store secrets in wp-config.php (production server only) or a secrets manager
  • Rotate secrets periodically (requires updating wp-config and Worker secrets simultaneously)
  • If a secret is compromised, immediately:
    1. Generate a new secret
    2. Update wp-config.php
    3. Update Worker environment variable
    4. Deploy Worker

File Upload Restrictions

  • Only designated admin/editor users can upload and sync files
  • The plugin enforces a 200MB soft limit per file (configurable in Worker and plugin)
  • Supported file types: images (JPEG, PNG, GIF, WebP), audio/video (any common format), documents (PDF, Office, ZIP, text, APK)
  • For very large files (>200MB), consider implementing direct/presigned S3-compatible uploads (future enhancement)

Worker Access Control

  • The Worker /upload/ endpoint should be blocked via WAF rules or Worker routing to non-server IPs
  • The GET endpoint (serving files) is public — confirm you do not upload private/sensitive files
  • CORS is restricted to the public R2 domain (not *)

Malware & Virus Scanning

  • Consider adding ClamAV or an external scanning service for non-image uploads (APK, executables, etc.)
  • This is not built into the plugin; you would need a custom integration

Troubleshooting

Files Upload But Don't Appear in R2

Symptoms: Admin notice shows "Successfully synced" but files are 404 on R2 URL

Check:

  1. Verify R2 Prefix is correct: wp option get cloudflare_media_r2_prefix
  2. Verify R2 Public URL is correct: wp option get cloudflare_media_r2_public_url
  3. Test direct R2 URL access: curl -I https://media.yoursite.com/YYYY/MM/filename.jpg
  4. Check Worker logs in Cloudflare dashboard for errors

Fix:

  • Correct the R2 Prefix and/or R2 Public URL in Settings and re-save
  • Test a new upload

Media URL Shows Local Path Instead of R2 Domain

Symptoms: Edit Media page shows File URL as https://yoursite.com/wp-content/uploads/... instead of https://media.yoursite.com/...

Cause: Usually misconfigured R2 Public URL or R2 Prefix

Check:

  1. Verify both values in Settings:
    wp option get cloudflare_media_r2_public_url
    wp option get cloudflare_media_r2_prefix
  2. Confirm the R2 Public URL matches your custom domain: should be https://media.yoursite.com (no trailing slash)
  3. Confirm the R2 Prefix ends with /: e.g., yoursite.com/

Fix:

  • Update R2 Public URL to the correct media domain
  • Update R2 Prefix to the correct site prefix
  • Save settings
  • The next access to a media URL should rewrite correctly (with transient caching, may take up to 1 minute for existing files)

Upload Blocked with "File Too Large"

Symptoms: Upload rejected before reaching the plugin with size validation error

Cause: PHP/webserver limits or Worker size limits

Check:

  1. Verify PHP settings:
    php -i | grep -E "upload_max_filesize|post_max_size"
  2. Check nginx/Apache limits if using a reverse proxy

Fix:

  • Increase upload_max_filesize and post_max_size in php.ini (e.g., to 250M)
  • Restart PHP-FPM / webserver
  • Test with a file close to the limit

Upload Blocked with "Server Cannot Process That Type"

Symptoms: APK, document, or other file type rejected at upload

Cause: WordPress has blocked the file type by default, or the plugin does not recognize it

Fix: Add the MIME type to WordPress uploads via a filter in functions.php:

add_filter('upload_mimes', function($mimes) {
    $mimes['apk'] = 'application/vnd.android.package-archive';
    return $mimes;
});

Then try uploading again.

Images Show Placeholder "Empty Alt Attribute" in Editor

Symptoms: Image inserted into page/post shows placeholder instead of preview

Cause: Usually happens when the image URL is not being rewritten consistently

Check:

  1. Edit the image in Media Library and verify the File URL is the R2 domain (not local)
  2. If still local, check cloudflare_media_synced_files:
    wp option get cloudflare_media_synced_files --format=json | head -20

    If the filename is not listed, the plugin will attempt a fallback HEAD check to R2 (may take a moment)

Fix:

  • Verify R2 Public URL and R2 Prefix are correct
  • Clear the media library page cache (if using caching plugin) and reload
  • If the issue persists, check Cloudflare Worker logs for errors during sync

"Request to Worker Failed" or HTTP Error Notices

Symptoms: Admin notice shows "Request to Worker failed" or "Worker returned HTTP 401"

Cause: Could be HMAC signature mismatch, expired timestamp, or Worker misconfiguration

Check:

  1. Verify the HMAC secret matches between wp-config and Worker:
    grep CLOUDFLARE_HMAC_SECRET /path/to/wp-config.php

    (Compare to the Worker secret value)

  2. Verify server time is accurate (5-minute window for replay protection):
    date
  3. Check Worker logs in Cloudflare dashboard for detailed error messages

Fix:

  • If time is off, sync NTP: sudo timedatectl set-ntp true
  • If secrets don't match, update wp-config and redeploy Worker
  • Check Worker logs for the specific error (401 = bad signature, 413 = too large, 415 = unsupported type)

Performance & Optimization

Media Library Preview Caching

  • The plugin checks R2 existence once per file and caches results for 1 hour (if present) or 1 minute (if absent)
  • On first access to a previously-synced-but-unrecorded file, a HEAD request to R2 is made — this adds ~200ms latency
  • To avoid this, ensure all media is recorded in cloudflare_media_synced_files

Worker Performance

  • The Worker processes each upload synchronously; large files may timeout
  • For files >200MB, implement direct S3-compatible uploads (recommended future enhancement)
  • Average upload latency: 100–500ms depending on file size and network

R2 Pricing

  • R2 is free for reads/downloads; standard class applies
  • No data transfer fees between Cloudflare and your server (same account)
  • Cost is primarily storage (~$0.015/GB/month)

Advanced Usage

Multiple Sites on One R2 Bucket

You can host media from multiple WordPress sites in a single R2 bucket:

  1. Create a separate Worker route for each site (step 2)
  2. Use a unique HMAC secret per site
  3. Use a unique R2 Prefix per site (e.g., site1.com/, site2.com/)
  4. Optionally use the same or different public domains per site (e.g., media.site1.com vs. media.site2.com)

Repeat steps 1–10 for each site.

Presigned/Direct Uploads (Future)

For >200MB files, consider implementing presigned R2 URLs so clients upload directly to R2 without routing through the Worker. This requires:

  • A separate endpoint in the Worker to issue presigned PUT URLs
  • JavaScript client-side upload library (e.g., aws-sdk or Uppy)
  • Separate WordPress functionality to exchange presigned URLs for attachment post IDs

License

MIT or your chosen license.

Support

For issues or questions:

  1. Check the Troubleshooting section above
  2. Review Worker logs in Cloudflare dashboard
  3. Enable WP_DEBUG in wp-config.php to see plugin debug logs
  4. Check WordPress error log (/wp-content/debug.log)

Last Updated: February 2026 Plugin Version: 1.3.0