Recipe: production R2 delivery, CORS, and private uploads
Separate custom domains, CORS, and presigned URLs to deliver images, PDFs, and attachments safely in production.
Edited and verified by Orange Book Editorial Team ·
Conclusion first
Serve public reads through a custom domain. For direct browser uploads to a private bucket, issue a short-lived presigned URL on the server and allow only your exact application origin with CORS. Use r2.dev for non-production tests only. Presigned URLs use the R2 S3 API domain, not your custom domain.
Do not confuse these three controls
| Control | What it solves | What it does not solve |
|---|---|---|
| Custom domain | Public reads, caching, and Cloudflare controls such as WAF, Access, and Bot products | Private-object authorization |
| CORS | Allows selected browser origins to read a response or upload | Authentication; non-browser clients are not protected by it |
| Presigned URL | Authorizes one S3 API operation for a limited time | Permanent publication or secrecy from anyone who obtains the URL |
Public assets
Private upload
Private download
Production workflow
Start with an access matrix
For each object class, name its readers, writers, public status, allowed Content-Type, maximum size, and retention. Avoid mixing permanently public assets with sensitive temporary files in one bucket unless prefixes and authorization boundaries are explicit.
Bind a custom domain for public reads
Bind a domain managed in the same Cloudflare account from the R2 bucket settings. After the custom domain is verified, disable r2.dev if the test entry point is no longer required. Otherwise readers may bypass protections that exist only on the custom domain.
curl -I https://media.example.com/public/sample.webpCheck status, Content-Type, Cache-Control, ETag, and CF-Cache-Status; “it opens in my browser” is not enough.
Configure the smallest browser CORS policy
An origin must be an exact scheme://host[:port] without a path. List development and production origins separately. Do not use * merely to simplify private uploads.
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["Content-Type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]If an old CORS response is cached behind the custom domain, purge the affected cached objects after changing the policy, then repeat the preflight.
Sign URLs on the server only
Before signing, validate the session, tenant, object key, operation, Content-Type, and business quota. Keep the URL short-lived and return it over HTTPS. R2 accepts an expiry of up to seven days, but that is not a sensible default. Presigned URLs support GET, HEAD, PUT, and DELETE; S3 HTML form POST uploads are not supported.
Verify both allowed and rejected browser origins
read -r -s -p 'Paste a short-lived presigned URL: ' R2_SIGNED_URL
curl -i -X OPTIONS "$R2_SIGNED_URL" \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: PUT' \
-H 'Access-Control-Request-Headers: content-type'
unset R2_SIGNED_URLUse a newly generated, unexpired server-side URL. The allowed origin should receive matching CORS headers. Change Origin to an unapproved domain and confirm it does not receive the same allowance. Finally upload a small file from the real app, and verify that a wrong Content-Type, expired URL, and unauthorized key all fail.
A presigned URL is a bearer token
Anyone who obtains the URL can perform the signed operation until it expires. Do not put it in logs, analytics, or support tickets. Do not sign in the browser, and never ship account-level R2 credentials to a client.
Release and rollback checklist
- Validate with a test bucket and test domain before production.
- Keep the old read path until hit rate, errors, and download integrity are stable.
- If uploads fail, stop issuing new URLs before deleting any old objects.
- Restore the previous application entry point during rollback; reverse CORS, DNS, or cache changes one at a time and re-verify.
For existing data, continue to the R2 migration playbook. For cost modelling, read R2 vs S3, OSS, and B2.
Primary sources
Did this page help you complete your goal?
Beta feedback is generated in this browser and is never uploaded automatically.
Launch checklist for AI-generated code
Move an AI-generated Cloudflare project from “looks runnable” to verified APIs, permissions, tests, cost, and rollback.
Recipe: Cache Rules that do not break sign-in or APIs
Bypass identity, account, and write paths first, cache only public content, and verify the policy with headers and separate clients.