Debugging R2 CORS Configuration: When Workers Don't Work as Expected
CORS (Cross-Origin Resource Sharing) issues can be particularly frustrating when working with Cloudflare R2 and custom domains. Recently, I encountered a scenario where a developer was trying to enable CORS for R2 bucket access through their own domain, only to discover that their Worker wasn't executing at all. This debugging journey reveals several critical insights about Cloudflare's service hierarchy and domain ownership.
The Problem
The developer needed to serve CSV files from an R2 bucket through their custom domain (data.company.com) with CORS support for localhost development. They initially tried configuring CORS through the R2 Bucket UI, but this approach wasn't working for their architecture.
The key insight emerged early: R2 Bucket CORS configuration is designed for direct access to R2 endpoints, not for custom domains proxied through Cloudflare Zones.
When files are served through a custom domain like https://data.company.com/file.csv, the browser only sees the custom domain response headers. The R2 CORS configuration becomes invisible in this setup.
Investigation: Worker Not Executing
After understanding that CORS needed to be handled at the Zone level through a Worker, the developer implemented a CORS-enabled Worker. However, they quickly discovered that the Worker wasn't executing at all - a classic case of "Worker written ≠ Worker running."
Step 1: Verifying Worker Execution
The first debugging step was to create a minimal test Worker:
export default {
async fetch(request, env) {
return new Response('WORKER HIT', { status: 200 })
}
}When accessing the CSV file URL still returned the original content instead of "WORKER HIT", it confirmed that the Worker wasn't being triggered.
Step 2: Checking Worker Routes
The investigation revealed a common configuration issue: Worker Routes weren't properly configured. The correct route pattern should be:
data.company.com/*
Not:
company.com/*(too broad)data.company.com(missing wildcard)/api/*(too specific)
However, even with correct routes, the Worker still wasn't executing.
Root Cause: Domain Binding Conflicts
The real breakthrough came when examining the DNS records. The investigation revealed that data.company.com was showing up as a Type: R2 record, not a standard DNS record. This indicated that the subdomain was bound as an R2 Custom Domain, creating a fundamental conflict.
Understanding Cloudflare's Service Hierarchy
Cloudflare has a strict hierarchy for domain ownership within a Zone:
R2 Custom Domain
> Pages Custom Domain
> Worker Custom Domain
> Standard DNS Records
When a domain is bound as an R2 Custom Domain for public bucket access, it creates an exclusive lock. Workers cannot override or intercept requests to that domain because R2 has higher priority in Cloudflare's routing system.
The DNS Evidence
The DNS panel showed:
Type: R2
Name: data.company.com
Content: public-datasets
This wasn't a regular CNAME or A record - it was an R2-specific domain binding that gave R2 exclusive control over that subdomain.
Solution: Reconfiguring Domain Architecture
Step 1: Unbind R2 Custom Domain
The only way to resolve this conflict was to remove the R2 Custom Domain binding:
- Navigate to R2 → Buckets → public-datasets
- Find the Custom Domains section
- Remove
data.company.comfrom the R2 binding
This step cannot be performed from the DNS panel - it must be done from within the R2 service configuration.
Step 2: Implement Worker-Based Architecture
After unbinding the R2 domain, the correct architecture becomes:
Browser
↓
https://data.company.com/file.csv
↓
Cloudflare Worker (handles CORS)
↓
R2 Bucket (via env binding)
Step 3: Worker Implementation with CORS
Here's the production-ready Worker code:
const corsHeaders = {
'Access-Control-Allow-Origin': 'http://localhost:8080',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
}
export default {
async fetch(request, env) {
// Handle preflight requests
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: corsHeaders,
})
}
// Extract file path from URL
const url = new URL(request.url)
const key = url.pathname.slice(1) // Remove leading slash
// Fetch from R2
const object = await env.MY_BUCKET.get(key)
if (!object) {
return new Response('Not found', { status: 404 })
}
// Return with CORS headers
return new Response(object.body, {
headers: {
...corsHeaders,
'Content-Type': 'text/csv',
'Cache-Control': 'public, max-age=3600',
},
})
},
}Step 4: Configure Worker Custom Domain
After unbinding R2, the Worker Custom Domain configuration succeeds:
- Navigate to Workers & Pages → [Worker Name] → Settings → Domains & Routes
- Add Custom Domain:
data.company.com - The domain binding should complete without conflicts
Performance and Cost Considerations
DNS Propagation
Unlike traditional DNS changes, this reconfiguration doesn't require waiting for global DNS propagation. Cloudflare's internal service bindings typically take effect within 30-60 seconds.
Performance Impact
The Worker-based approach maintains performance characteristics:
- Requests still hit Cloudflare edge locations
- Caching remains effective
- Response times are comparable to direct R2 access
Cost Implications
The primary cost consideration is Worker requests vs. R2 bandwidth:
- Worker requests: Charged per invocation
- R2 bandwidth: Free egress within Cloudflare
- Trade-off: Slight increase in compute cost for significantly better control
For most use cases, the cost increase is minimal compared to the operational benefits of proper CORS handling and request control.
Lessons Learned
1. Understand Service Hierarchy
Cloudflare services have strict hierarchical relationships. When debugging routing issues, always check if multiple services are competing for the same domain.
2. CORS Responsibility Layers
CORS headers must be injected at the final response layer that the browser sees. In custom domain scenarios, this is never the origin service (R2) but always the edge service (Worker/Zone).
3. Domain Ownership is Exclusive
Within a Cloudflare Zone, each subdomain can only be "owned" by one service at a time. R2 Custom Domains, Pages, and Workers cannot share the same domain.
4. Always Verify Worker Execution
Before debugging Worker code, always verify the Worker is actually receiving requests. A simple string response test can save hours of CORS configuration troubleshooting.
5. DNS Panel Shows All Bindings
The DNS panel reveals all types of domain bindings, not just traditional records. Look for unusual "Type" values that indicate service-specific bindings.
Prevention Tips
- Choose architecture early: Decide between R2 public domains or Worker-based access before configuring custom domains
- Use consistent debugging patterns: Always test Worker execution before debugging application logic
- Monitor service conflicts: Regularly audit domain bindings across different Cloudflare services
- Document domain ownership: Keep track of which services control which subdomains in complex setups
This debugging experience highlights how modern cloud platforms abstract complexity while introducing new failure modes. Understanding the service hierarchy and domain ownership model is crucial for effective troubleshooting in serverless architectures.
