billing hacks
Cloudflare Deep DiveCloudflare as Proxy

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_REDIRECTS

Cause: 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 this

Fix: 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:

next.config.js
// 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 updates

Cloudflare 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 SettingBehavior
Browser Cache TTLCan override your max-age
Edge Cache TTLCan override your s-maxage
Cache LevelCan ignore query strings
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 Rule

Cache Invalidation Nightmare

When you deploy to Vercel:

  1. Vercel purges its cache ✅
  2. 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 hour

Solution 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's

Use 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:

next.config.js
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:

SourceWhat It Counts
Cloudflare AnalyticsAll requests (including cached)
Vercel AnalyticsOnly requests that reach Vercel
Your App AnalyticsOnly requests that run your code

If 90% of requests are cached at Cloudflare, Vercel sees 10% of traffic.

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 webhook

Quick Debugging Checklist

curl -I https://example.com/page
HeaderValuesMeaning
cf-cache-statusHIT/MISS/BYPASSCloudflare cache status
x-vercel-cacheHIT/MISS/STALEVercel 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 cached

For moving completely off Vercel to Cloudflare, see the Migration Checklist.

On this page