Cloudflare R2 Media Sync
Sync WordPress media files with cloudflare r2 storage
Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/graphitesprite/cloudflare-r2-media-sync/archive/refs/heads/main.zipCloudflare 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
- In Cloudflare dashboard, go to Workers & Pages → your Worker → Settings → Variables and Secrets
- Add a new encrypted secret (not a regular variable) named
HMAC_SECRET_NEWSITEand paste the secret from step 1 - 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.
- Deploy the Worker (save and deploy in the dashboard)
Step 3: Configure Cloudflare WAF Rule
- Go to Security → WAF → Firewall Rules (or Security Policies depending on your plan)
- Ensure you have a rule that blocks
/uploadrequests 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
- Condition: URI Path starts with
- 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
- In Cloudflare dashboard, go to Workers & Pages → your Worker → Settings → Domains & Routes
- Click Add Custom Domain
- Enter the media subdomain (e.g.,
media.newsite.com) and select your domain - Cloudflare automatically provisions an SSL certificate (may take a few minutes)
- 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.comwith your actual domain - Replace
your-generated-secret-from-step-1with the HMAC secret from step 1 - Ensure
CLOUDFLARE_WORKER_URLmatches the custom domain from step 4 and includes the/upload/newsite.com/path CLOUDFLARE_R2_PUBLIC_URLmust 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
- Download the plugin files (
cf-r2-media-sync-plugin.php,cf-r2-media-sync-worker.js) - Create a folder
cf-r2-media-syncin/wp-content/plugins/ - Copy the plugin file into that folder
- In WordPress admin, go to Plugins → Installed Plugins
- Find Cloudflare R2 Sync and click Activate
- Go to Settings → R2 Sync to verify settings and enter allowed usernames
Step 7: Set Allowed Users
- In WordPress admin, go to Settings → R2 Sync
- In the Allowed Users field, enter comma-separated usernames (e.g.,
admin, editor1, editor2) - Only these users' uploads will be synced to R2
- Click Save Settings
Security note: Only designate trusted users — synced files become public via the R2 domain.
Step 8: Test a New Upload
- Log in as one of the allowed users
- Go to Media → Add New and upload a small test image
- Watch the admin notices area — you should see a success message "✓ R2 Sync: ... Successfully synced to R2"
- Click the View file link to verify the file is accessible at the R2 URL
- 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
- Download rclone from https://rclone.org/downloads/
- Run
rclone configto create a new remote:rclone config - Choose New Remote and name it (e.g.,
r2) - Select S3-compatible storage
- 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
- Visit your site's homepage and open a page with images
- Inspect the image URLs in browser DevTools (right-click image → Inspect)
- Confirm images load from
https://media.newsite.com/...and no 404s appear - 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_SECRETto 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:
- Generate a new secret
- Update
wp-config.php - Update Worker environment variable
- 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:
- Verify R2 Prefix is correct:
wp option get cloudflare_media_r2_prefix - Verify R2 Public URL is correct:
wp option get cloudflare_media_r2_public_url - Test direct R2 URL access:
curl -I https://media.yoursite.com/YYYY/MM/filename.jpg - 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:
- Verify both values in Settings:
wp option get cloudflare_media_r2_public_url wp option get cloudflare_media_r2_prefix - Confirm the R2 Public URL matches your custom domain: should be
https://media.yoursite.com(no trailing slash) - 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:
- Verify PHP settings:
php -i | grep -E "upload_max_filesize|post_max_size" - Check nginx/Apache limits if using a reverse proxy
Fix:
- Increase
upload_max_filesizeandpost_max_sizeinphp.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:
- Edit the image in Media Library and verify the File URL is the R2 domain (not local)
- If still local, check cloudflare_media_synced_files:
wp option get cloudflare_media_synced_files --format=json | head -20If 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:
- 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)
- Verify server time is accurate (5-minute window for replay protection):
date - 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:
- Create a separate Worker route for each site (step 2)
- Use a unique HMAC secret per site
- Use a unique R2 Prefix per site (e.g.,
site1.com/,site2.com/) - Optionally use the same or different public domains per site (e.g.,
media.site1.comvs.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:
- Check the Troubleshooting section above
- Review Worker logs in Cloudflare dashboard
- Enable WP_DEBUG in wp-config.php to see plugin debug logs
- Check WordPress error log (
/wp-content/debug.log)
Last Updated: February 2026 Plugin Version: 1.3.0