Cloudflare as Proxy
Using Cloudflare in front of Vercel. Common pitfalls, caching conflicts, and how to configure both correctly.
Dual CDN Complexity
Using two CDNs creates caching conflicts and debugging complexity. Configure carefully or disable Cloudflare caching for dynamic content.
Many teams use Cloudflare's DNS and proxy in front of Vercel for DDoS protection, WAF, or existing infrastructure. This creates a dual-CDN situation with plenty of footguns.
The Setup
User → Cloudflare Edge → Vercel Edge → Vercel Functions
(CDN #1) (CDN #2)Two CDNs means two caches, two sets of headers, and twice the debugging complexity.
DNS Configuration
Orange Cloud vs Grey Cloud
┌─────────────────────────────────────────────┐
│ Cloudflare DNS Settings │
├─────────────────────────────────────────────┤
│ example.com CNAME cname.vercel-dns.com │
│ │
│ 🟠 Proxied → Traffic goes through CF │
│ 🔘 DNS Only → Direct to Vercel │
└─────────────────────────────────────────────┘Proxied (Orange Cloud): Cloudflare processes all requests. Enables caching, WAF, DDoS protection. Creates dual-CDN issues.
DNS Only (Grey Cloud): Cloudflare just resolves DNS. All traffic goes direct to Vercel. No conflicts, but no CF features.
SSL/TLS Issues
The Most Common Problem
Error: ERR_TOO_MANY_REDIRECTSCause: SSL mode mismatch between Cloudflare and Vercel.
Cloudflare SSL Mode:
├── Off → ❌ Never use
├── Flexible → ❌ Causes redirect loops with Vercel
├── Full → ⚠️ Works but not recommended
└── Full (Strict) → ✅ Use thisFix: In Cloudflare Dashboard → SSL/TLS → Set to "Full (Strict)"
Vercel always serves HTTPS. If Cloudflare uses Flexible (HTTP to origin), Vercel redirects to HTTPS, Cloudflare downgrades to HTTP, infinite loop.
Forcing HTTPS
Don't do it twice:
// DON'T add HTTPS redirect if using Cloudflare proxy
// Cloudflare already handles HTTPS redirect
module.exports = {
// Don't add custom HTTPS redirect headers
};In Cloudflare:
- SSL/TLS → Edge Certificates → Always Use HTTPS → ON
Caching Conflicts
The Double-Cache Problem
Request → Cloudflare Cache (HIT) → Never reaches Vercel
Vercel cache never updatesCloudflare and Vercel both cache. They don't talk to each other.
Cache-Control Header Wars
Vercel sets cache headers. Cloudflare respects them... sometimes.
// Your API route
export async function GET() {
return new Response(data, {
headers: {
'Cache-Control': 'public, max-age=60, s-maxage=3600',
// s-maxage: CDN cache time
// max-age: browser cache time
}
});
}But Cloudflare has its own rules:
| Cloudflare Setting | Behavior |
|---|---|
| Browser Cache TTL | Can override your max-age |
| Edge Cache TTL | Can override your s-maxage |
| Cache Level | Can ignore query strings |
Recommended Cloudflare Cache Settings
Cloudflare Dashboard → Caching → Configuration
Browser Cache TTL: "Respect Existing Headers"
(Don't override your app's headers)
Edge Cache TTL: Leave default or set per Page RuleCache Invalidation Nightmare
When you deploy to Vercel:
- Vercel purges its cache ✅
- Cloudflare cache remains ❌
Solution 1: Lower Cloudflare TTLs
Page Rules:
example.com/api/* → Cache Level: Bypass
example.com/_next/static/* → Edge Cache TTL: 1 month
example.com/* → Edge Cache TTL: 1 hourSolution 2: Purge on Deploy
# In your deployment script or Vercel webhook
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'Solution 3: Cache Tags (Enterprise)
// Response header
headers: {
'Cache-Tag': 'product-123, category-electronics'
}
// Purge by tag
// POST /zones/:zone/purge_cache
// { "tags": ["product-123"] }Header Conflicts
Forwarded Headers
Cloudflare adds headers. Vercel reads them. Sometimes wrong.
// Getting real client IP
export async function GET(request: Request) {
// ❌ Wrong: This is Cloudflare's IP
const ip = request.headers.get('x-forwarded-for');
// ✅ Correct: Cloudflare's real IP header
const ip = request.headers.get('cf-connecting-ip');
// ✅ Also works: True-Client-IP (if enabled)
const ip = request.headers.get('true-client-ip');
}Geo Headers
Both add geo data with different header names:
// Cloudflare geo headers
const country = request.headers.get('cf-ipcountry');
const city = request.headers.get('cf-ipcity'); // Enterprise
// Vercel geo headers (won't be accurate behind CF proxy)
const country = request.headers.get('x-vercel-ip-country');
// ⚠️ This shows Cloudflare's edge location, not user'sUse Cloudflare's geo headers when proxied.
Performance Implications
Double Edge = Double Latency?
Not necessarily, but possible:
User (Sydney)
↓ 20ms
Cloudflare Edge (Sydney)
↓ 150ms (to nearest Vercel edge)
Vercel Edge (Singapore)
↓ varies
Vercel Function (US)Cloudflare has more PoPs (300+) than Vercel. But adding a hop adds latency.
Measure actual performance:
// Add Server-Timing headers
export async function GET() {
const start = Date.now();
const data = await fetchData();
return new Response(JSON.stringify(data), {
headers: {
'Server-Timing': `fetch;dur=${Date.now() - start}`,
}
});
}When to Bypass Cloudflare Proxy
For Vercel-specific features:
Page Rules or Transform Rules:
/_vercel/* → Bypass (Vercel analytics, functions)
/api/revalidate/* → Bypass (ISR revalidation webhooks)WebSocket Issues
Cloudflare proxies WebSocket differently:
module.exports = {
// If using WebSocket (like with Socket.io)
// Cloudflare needs this enabled in dashboard
};In Cloudflare:
- Network → WebSockets → ON
Cloudflare Free plan has WebSocket limits. Check your plan's concurrent connection limits.
Analytics Discrepancies
You'll see different numbers:
| Source | What It Counts |
|---|---|
| Cloudflare Analytics | All requests (including cached) |
| Vercel Analytics | Only requests that reach Vercel |
| Your App Analytics | Only requests that run your code |
If 90% of requests are cached at Cloudflare, Vercel sees 10% of traffic.
Recommended Configuration
Minimal Proxy (DNS + DDoS only)
Cloudflare Settings:
├── SSL/TLS: Full (Strict)
├── Caching
│ ├── Browser Cache TTL: Respect Existing Headers
│ └── Edge Cache TTL: Respect Existing Headers
├── Page Rules
│ └── example.com/* → Cache Level: Bypass
└── Security
└── DDoS: On (this still works even with cache bypass)Full Proxy (Caching at Cloudflare edge)
Cloudflare Settings:
├── SSL/TLS: Full (Strict)
├── Caching
│ ├── Browser Cache TTL: Respect Existing Headers
│ └── Edge Cache TTL: 4 hours (or per Page Rule)
├── Page Rules
│ ├── *example.com/_next/static/* → Cache Level: Cache Everything, Edge TTL: 1 year
│ ├── *example.com/api/* → Cache Level: Bypass
│ └── *example.com/* → Cache Level: Standard
└── Workers (optional)
└── Purge cache on Vercel deploy webhookQuick Debugging Checklist
curl -I https://example.com/page| Header | Values | Meaning |
|---|---|---|
cf-cache-status | HIT/MISS/BYPASS | Cloudflare cache status |
x-vercel-cache | HIT/MISS/STALE | Vercel cache status |
If both headers show HIT, the response was cached at both layers. If cf-cache-status: HIT, the request never reached Vercel.
Common Headers to Check
cf-ray: 1234567890-SYD # Cloudflare request ID + PoP
cf-cache-status: HIT # Cloudflare cache status
x-vercel-cache: MISS # Vercel cache status (if reached)
x-vercel-id: syd1::xxxxx # Vercel region
age: 3600 # Seconds since cachedFor moving completely off Vercel to Cloudflare, see the Migration Checklist.