Cloudflare R2 Media Sync
Sync WordPress media files with cloudflare r2 storage
by graphitesprite · github.com/graphitesprite/cloudflare-r2-media-sync
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.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