Skip to content

CDN/Edge Caching Implementation Guide

Date: 2025-10-25 Project: PiqueTickets Frontend Goal: Move caching from Railway origin server to edge/CDN for 400-500MB memory reduction


Table of Contents


Overview

Current Architecture

User → Railway (Next.js) → Django API
    All caching happens here
    (400-600MB memory usage)

Target Architecture (Railway + Cloudflare)

User → Cloudflare CDN → Railway (Next.js) → Django API
         ↑                    ↑
    Caching here          Minimal caching
    (edge servers)        (50-100MB memory)

Benefits

  • 400-500MB memory reduction on Railway origin
  • Faster page loads (edge servers closer to users)
  • Lower Railway costs (smaller container needed)
  • Better DDoS protection (Cloudflare handles traffic spikes)
  • Global distribution (edge caching worldwide)

Cost: Free (Cloudflare Free tier is sufficient) Time: 1-2 hours Complexity: Low Memory Reduction: 400-500MB

Step 1: Sign Up for Cloudflare

  1. Go to cloudflare.com
  2. Create free account
  3. Click "Add a Site"
  4. Enter piquetickets.com
  5. Select Free Plan

Step 2: Update Domain Nameservers

Cloudflare will provide nameservers like:

abe.ns.cloudflare.com
reza.ns.cloudflare.com

Update at your domain registrar (GoDaddy, Namecheap, etc.):

Example for Namecheap: 1. Log in to Namecheap 2. Go to Domain List → Manage 3. Navigate to Nameservers 4. Select "Custom DNS" 5. Enter Cloudflare nameservers 6. Save (propagation takes 0-24 hours)

Verify DNS propagation:

dig piquetickets.com NS

# Should show Cloudflare nameservers

Step 3: Configure DNS Records in Cloudflare

In Cloudflare Dashboard → DNS → Records:

Type    Name                Value                           Proxy Status
----------------------------------------------------------------------
A       piquetickets.com    your-railway-ip-address        Proxied (orange cloud)
CNAME   www                 piquetickets.com               Proxied (orange cloud)
A       api                 your-api-railway-ip            Proxied (orange cloud)
CNAME   api-staging         staging-railway.up.railway.app Proxied (orange cloud)

Get Railway IP Address:

# In Railway dashboard for your frontend service
# Settings → Networking → Public Networking → Copy the domain

# Then resolve it:
nslookup piquetickets.up.railway.app

# Or use Railway's custom domain directly if already set up

Important: Make sure "Proxy status" is Proxied (orange cloud icon) for CDN caching to work.

Step 4: Configure Cloudflare SSL/TLS

Cloudflare Dashboard → SSL/TLS:

  1. SSL/TLS encryption mode: Full (strict)
  2. Encrypts traffic between user ↔ Cloudflare ↔ Railway

  3. Edge Certificates: Automatic (already enabled)

  4. Always Use HTTPS: ON

  5. Settings → SSL/TLS → Edge Certificates → Always Use HTTPS: ON

Step 5: Update Next.js Configuration for CDN

File: apps/frontend/next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // ... existing config

  // IMPORTANT: Remove or reduce page-level caching
  // We'll rely on CDN/edge caching instead
  experimental: {
    optimizeCss: true,
    optimizePackageImports: ['react-icons'],
    webpackMemoryOptimizations: true,
    preloadEntriesOnStart: false,

    // Reduce origin cache size dramatically
    isrMemoryCacheSize: 10 * 1024 * 1024, // 10MB (down from 50MB)
    isrFlushToDisk: true,
  },

  // ... existing image config

  // Add CDN-friendly headers
  async headers() {
    const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'https://api.piquetickets.com';
    const apiDomain = new URL(apiUrl).origin;

    return [
      // Security headers for all routes (existing)
      {
        source: '/(.*)',
        headers: [
          // ... your existing security headers
          {
            key: 'X-DNS-Prefetch-Control',
            value: 'on'
          },
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=63072000; includeSubDomains; preload'
          },
          // ... rest of existing headers
        ]
      },

      // CDN caching for homepage
      {
        source: '/',
        headers: [
          {
            key: 'Cache-Control',
            // Browser: 0s, CDN: 5min, stale-while-revalidate: 1hr
            value: 'public, s-maxage=300, stale-while-revalidate=3600',
          },
          {
            key: 'CDN-Cache-Control',
            // Cloudflare-specific: cache for 1 hour
            value: 'public, max-age=3600',
          },
        ],
      },

      // CDN caching for show pages
      {
        source: '/shows/:path*',
        headers: [
          {
            key: 'Cache-Control',
            // Browser: 0s, CDN: 10min, stale: 1hr
            value: 'public, s-maxage=600, stale-while-revalidate=3600',
          },
          {
            key: 'CDN-Cache-Control',
            value: 'public, max-age=3600',
          },
        ],
      },

      // CDN caching for producer pages
      {
        source: '/producers/:path*',
        headers: [
          {
            key: 'Cache-Control',
            // Producers change less frequently
            value: 'public, s-maxage=1800, stale-while-revalidate=7200',
          },
          {
            key: 'CDN-Cache-Control',
            value: 'public, max-age=7200', // 2 hours
          },
        ],
      },

      // CDN caching for venue pages
      {
        source: '/venue/:path*',
        headers: [
          {
            key: 'Cache-Control',
            // Venues change rarely
            value: 'public, s-maxage=3600, stale-while-revalidate=86400',
          },
          {
            key: 'CDN-Cache-Control',
            value: 'public, max-age=86400', // 24 hours
          },
        ],
      },

      // CDN caching for static pages
      {
        source: '/(about|privacy-policy|terms-of-service|refund-policies)/:path*',
        headers: [
          {
            key: 'Cache-Control',
            // Static content - cache aggressively
            value: 'public, s-maxage=86400, stale-while-revalidate=604800',
          },
          {
            key: 'CDN-Cache-Control',
            value: 'public, max-age=604800', // 1 week
          },
        ],
      },

      // NO caching for API routes
      {
        source: '/api/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'private, no-cache, no-store, must-revalidate',
          },
        ],
      },
    ];
  },

  // ... rest of config
};

export default nextConfig;

Step 6: Update Page-Level Revalidation

Remove or reduce page-level revalidate exports:

File: apps/frontend/src/app/page.tsx

// BEFORE:
export const revalidate = 30;

// AFTER:
// Remove this line entirely - rely on CDN caching
// OR keep very short for fallback:
export const revalidate = 300; // 5 minutes (only for origin cache)

File: apps/frontend/src/app/shows/[slug]/page.tsx

// BEFORE:
export const revalidate = 30;

// AFTER:
export const revalidate = 600; // 10 minutes origin cache
// CDN will cache for 1 hour (per headers above)

Apply same pattern to: - apps/frontend/src/app/shows/page.tsx - apps/frontend/src/app/producers/page.tsx - apps/frontend/src/app/producers/[slug]/page.tsx - apps/frontend/src/app/venue/[id]/page.tsx - apps/frontend/src/app/series/[slug]/page.tsx

Step 7: Configure Cloudflare Page Rules

Cloudflare Dashboard → Rules → Page Rules:

Rule 1: Cache Everything for HTML

URL Pattern: piquetickets.com/*

Settings:
- Cache Level: Standard
- Edge Cache TTL: 1 hour
- Browser Cache TTL: Respect Existing Headers
- Origin Cache Control: ON

Rule 2: Bypass Cache for API

URL Pattern: piquetickets.com/api/*

Settings:
- Cache Level: Bypass

Rule 3: Cache Static Assets Aggressively

URL Pattern: piquetickets.com/_next/static/*

Settings:
- Cache Level: Cache Everything
- Edge Cache TTL: 1 year
- Browser Cache TTL: 1 year

Note: Free plan allows 3 page rules. If you need more, upgrade to Pro ($20/month).

Step 8: Configure Cloudflare Cache Rules (New Method)

Alternative to Page Rules (better, more rules on free tier):

Cloudflare Dashboard → Caching → Cache Rules → Create Rule

Rule 1: Cache HTML Pages

Rule name: Cache HTML Pages

When incoming requests match:
  (http.request.uri.path matches "^/shows/.*") or
  (http.request.uri.path matches "^/producers/.*") or
  (http.request.uri.path matches "^/venue/.*") or
  (http.request.uri.path eq "/")

Then:
  Cache eligibility: Eligible for cache
  Edge TTL: 1 hour
  Browser TTL: 5 minutes
  Respect Origin TTL: Yes

Rule 2: Bypass API Routes

Rule name: Bypass API Cache

When incoming requests match:
  http.request.uri.path matches "^/api/.*"

Then:
  Cache eligibility: Bypass cache

Rule 3: Cache Static Assets

Rule name: Cache Static Assets

When incoming requests match:
  http.request.uri.path matches "^/_next/static/.*" or
  http.request.uri.path matches "^/static/.*" or
  http.request.uri.path matches ".*\\.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot|webp|avif)$"

Then:
  Cache eligibility: Eligible for cache
  Edge TTL: 1 year
  Browser TTL: 1 year

Step 9: Test CDN Caching

Test cache headers:

# Test homepage
curl -I https://piquetickets.com

# Look for:
# cf-cache-status: HIT (cached)
# cf-cache-status: MISS (not cached, first request)
# cf-cache-status: DYNAMIC (bypassed)
# cf-ray: [ray-id] (confirms going through Cloudflare)

# Test show page
curl -I https://piquetickets.com/shows/some-show-slug

# Test API (should be DYNAMIC/bypassed)
curl -I https://piquetickets.com/api/cache/health

Test cache hit rates:

# Make request twice
curl -I https://piquetickets.com/shows/some-show
# First: cf-cache-status: MISS

curl -I https://piquetickets.com/shows/some-show
# Second: cf-cache-status: HIT ✓

Step 10: Purge Cache When Needed

Option A: Cloudflare Dashboard 1. Cloudflare Dashboard → Caching → Configuration 2. Click "Purge Everything" or "Purge by URL"

Option B: Cloudflare API (Recommended)

Create cache purge script:

File: apps/frontend/scripts/purge-cdn-cache.sh

#!/bin/bash

# Cloudflare credentials (add to Railway env vars)
CLOUDFLARE_ZONE_ID="${CLOUDFLARE_ZONE_ID}"
CLOUDFLARE_API_KEY="${CLOUDFLARE_API_KEY}"

# Purge specific URLs
curl -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CLOUDFLARE_API_KEY}" \
  -H "Content-Type: application/json" \
  --data '{
    "files": [
      "https://piquetickets.com/",
      "https://piquetickets.com/shows"
    ]
  }'

# Or purge everything
# --data '{"purge_everything": true}'

# Or purge by tag (requires Enterprise plan)
# --data '{"tags": ["shows"]}'

Option C: Integrate with Next.js Cache Purge API

Update your cache purge API to also purge Cloudflare:

File: apps/frontend/src/app/api/cache/purge/route.ts

import { revalidateTag, revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

async function purgeCloudflareCache(urls?: string[]) {
  const zoneId = process.env.CLOUDFLARE_ZONE_ID
  const apiKey = process.env.CLOUDFLARE_API_KEY

  if (!zoneId || !apiKey) {
    console.warn('Cloudflare credentials not configured')
    return
  }

  try {
    const response = await fetch(
      `https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${apiKey}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(
          urls
            ? { files: urls }
            : { purge_everything: true }
        ),
      }
    )

    const data = await response.json()
    console.log('Cloudflare purge result:', data)
  } catch (error) {
    console.error('Cloudflare purge failed:', error)
  }
}

export async function POST(request: NextRequest) {
  // Verify authorization
  const authHeader = request.headers.get('authorization')
  if (authHeader !== `Bearer ${process.env.CACHE_PURGE_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { tags, paths, purgeCloudflare = false } = await request.json()

  try {
    // Purge Next.js cache
    if (tags && Array.isArray(tags)) {
      tags.forEach(tag => revalidateTag(tag))
    }

    if (paths && Array.isArray(paths)) {
      paths.forEach(path => revalidatePath(path))
    }

    // Optionally purge Cloudflare CDN
    if (purgeCloudflare) {
      const urls = paths?.map((path: string) =>
        `https://piquetickets.com${path}`
      )
      await purgeCloudflareCache(urls)
    }

    return NextResponse.json({
      success: true,
      purged: { tags, paths, cloudflare: purgeCloudflare },
      timestamp: new Date().toISOString()
    })
  } catch (error) {
    return NextResponse.json({
      error: 'Cache purge failed',
      details: error instanceof Error ? error.message : 'Unknown error'
    }, { status: 500 })
  }
}

Add environment variables to Railway:

CLOUDFLARE_ZONE_ID=your-zone-id
CLOUDFLARE_API_KEY=your-api-key

Get Cloudflare credentials: 1. Zone ID: Cloudflare Dashboard → Overview (right sidebar) 2. API Key: Cloudflare Dashboard → My Profile → API Tokens → Create Token - Use "Edit zone DNS" template - Zone Resources: Include → Specific zone → piquetickets.com - Copy the token

Step 11: Monitor CDN Performance

Cloudflare Analytics: - Dashboard → Analytics & Logs - View: - Requests (total traffic) - Bandwidth (saved from origin) - Cache hit rate (should be >80%) - Response time

Check memory reduction on Railway:

# Before CDN: ~400-600MB
# After CDN: ~100-200MB (target)

# Monitor via Railway dashboard:
# Service → Metrics → Memory Usage

Step 12: Configure Cache for Images

Cloudflare automatically caches images, but optimize further:

File: apps/frontend/next.config.ts

images: {
  remotePatterns: [
    {
      protocol: "https",
      hostname: "**",
    },
  ],
  formats: ['image/webp', 'image/avif'],
  minimumCacheTTL: 3600, // 1 hour (CDN caches longer)
  deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
  imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],

  // Let Cloudflare handle image optimization
  unoptimized: false, // Keep Next.js optimization for first pass
},

Cloudflare Image Optimization (Optional, $5/month): - Dashboard → Speed → Optimization - Enable: - Polish: Lossy - Mirage: ON (lazy loading) - WebP: ON


Option 2: Migrate to Vercel (Alternative)

Cost: Free tier available, scales with usage Time: 2-4 hours Complexity: Medium (requires migration) Memory Reduction: 100% (serverless, no persistent memory)

Why Vercel?

  • Built-in edge caching (global CDN included)
  • Serverless Next.js (no memory accumulation issues)
  • Zero config for Next.js apps
  • Built by Next.js creators (best compatibility)
  • Edge runtime available for ultra-fast responses

Downsides

  • More expensive at scale ($20/month Pro plan for team features)
  • Vendor lock-in
  • Need to migrate from Railway

Migration Steps

Step 1: Create Vercel Account

  1. Go to vercel.com
  2. Sign up with GitHub
  3. Install Vercel CLI:
    npm install -g vercel
    

Step 2: Connect Repository

# In your project directory
cd apps/frontend

# Login to Vercel
vercel login

# Deploy to Vercel
vercel

# Follow prompts:
# - Set up and deploy? Yes
# - Which scope? Your account/team
# - Link to existing project? No
# - Project name? piquetickets-frontend
# - Directory? ./
# - Override settings? No

Step 3: Configure Environment Variables

Vercel Dashboard → Project → Settings → Environment Variables

Add all your env vars:

NEXT_PUBLIC_API_URL=https://api.piquetickets.com
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=...
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=...
NEXT_PUBLIC_CDN_DOMAIN=...
CACHE_PURGE_SECRET=...

Step 4: Configure Custom Domain

Vercel Dashboard → Project → Settings → Domains

  1. Add domain: piquetickets.com
  2. Vercel will provide DNS records:
    Type    Name    Value
    A       @       76.76.21.21
    CNAME   www     cname.vercel-dns.com
    
  3. Update at your domain registrar
  4. Wait for DNS propagation (0-24 hours)

Step 5: Update Next.js Config for Vercel Edge

File: apps/frontend/src/app/shows/[slug]/page.tsx

// Add edge runtime for fastest responses
export const runtime = 'edge' // Run on Vercel Edge Network

// Keep revalidation
export const revalidate = 60

Apply to high-traffic pages: - Homepage (app/page.tsx) - Show pages (app/shows/[slug]/page.tsx) - Shows listing (app/shows/page.tsx)

Step 6: Configure Vercel Edge Config (Optional)

For advanced caching control:

File: apps/frontend/vercel.json

{
  "headers": [
    {
      "source": "/shows/(.*)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, s-maxage=600, stale-while-revalidate=3600"
        }
      ]
    },
    {
      "source": "/_next/static/(.*)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        }
      ]
    }
  ],
  "rewrites": [
    {
      "source": "/api/:path*",
      "destination": "https://api.piquetickets.com/:path*"
    }
  ]
}

Step 7: Deploy Production

# Deploy to production
vercel --prod

# Or connect GitHub for auto-deployments:
# Vercel Dashboard → Git → Connect Git Repository
# Every push to main = automatic deployment

Step 8: Monitor Performance

Vercel Dashboard → Analytics: - View edge cache hit rate - Response times by region - Traffic breakdown - Core Web Vitals


Option 3: Railway + AWS CloudFront

Cost: Pay-as-you-go (typically $10-50/month) Time: 4-6 hours Complexity: High Memory Reduction: 400-500MB

Brief Overview

  1. Create AWS CloudFront distribution
  2. Set origin to Railway URL
  3. Configure cache behaviors
  4. Update DNS to point to CloudFront
  5. Configure SSL certificate

Not recommended unless you: - Already use AWS - Need advanced features Cloudflare doesn't offer - Have DevOps expertise

For most cases, Cloudflare is easier and free.


Performance Testing

Before CDN (Baseline)

# Test from multiple locations
# Use https://www.webpagetest.org/

# Test from command line
time curl -w "@curl-format.txt" -o /dev/null -s https://piquetickets.com

# Create curl-format.txt:
cat > curl-format.txt <<EOF
    time_namelookup:  %{time_namelookup}s\n
       time_connect:  %{time_connect}s\n
    time_appconnect:  %{time_appconnect}s\n
   time_pretransfer:  %{time_pretransfer}s\n
      time_redirect:  %{time_redirect}s\n
 time_starttransfer:  %{time_starttransfer}s\n
                    ----------\n
         time_total:  %{time_total}s\n
EOF

After CDN

# Same test - should be 2-5x faster
time curl -w "@curl-format.txt" -o /dev/null -s https://piquetickets.com

# Check cache status
curl -I https://piquetickets.com | grep -i cache

Load Testing

# Use Apache Bench
ab -n 1000 -c 50 https://piquetickets.com/

# Before CDN: ~500-800ms avg
# After CDN: ~100-200ms avg (goal)

Rollback Plan

If CDN Causes Issues

Cloudflare: 1. Cloudflare Dashboard → DNS 2. Toggle all records from "Proxied" (orange) to "DNS only" (gray) 3. Traffic bypasses Cloudflare, goes direct to Railway 4. Diagnose issue 5. Re-enable proxy when fixed

Vercel: 1. Update DNS back to Railway 2. Delete Vercel deployment 3. Re-deploy on Railway

Common Issues

Issue: Stale content showing

# Purge Cloudflare cache
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  --data '{"purge_everything":true}'

Issue: API requests failing - Check bypass rule for /api/* - Verify CORS headers - Check Cloudflare firewall rules

Issue: SSL certificate errors - Cloudflare: Ensure SSL mode is "Full (strict)" - Verify Railway has valid SSL cert


Success Criteria

  • CDN cache hit rate > 80%
  • Railway memory usage < 150MB
  • Page load time (p95) < 500ms
  • No increase in error rates
  • Successful cache purges
  • Cost within budget ($0-20/month for Cloudflare)

Cost Comparison

Solution Monthly Cost Setup Time Memory Reduction Best For
Cloudflare Free $0 1-2 hours 400-500MB Most projects
Cloudflare Pro $20 1-2 hours 400-500MB More page rules needed
Vercel Hobby $0 2-4 hours 100% (serverless) Small projects
Vercel Pro $20 2-4 hours 100% (serverless) Team features needed
AWS CloudFront $10-50 4-6 hours 400-500MB Already on AWS

Recommendation: Start with Cloudflare Free + Railway (no cost increase, best bang for buck)


Last Updated: 2025-10-25 Next Steps: Implement Option 1 (Cloudflare) if approved