On-Premise to Cloud CDN Transition: Moving from Self-Hosted to Managed Delivery

Back in 2018 I was on call when our self-hosted Varnish cluster in Chicago fell over during a product launch. Traffic had roughly tripled in an hour, disks filled with cache objects we couldn't purge fast enough, and our one senior sysadmin who actually understood the VCL was on a flight. We kept the site up by frantically pointing DNS back to origin and praying. That night is why I tell teams that self-hosting works until the moment it really matters.

When Your Self-Hosted Stack Starts Fighting You

Most teams don't start self-hosted because they want to run a CDN. They do it because it was cheaper, or they needed full control over headers and routing, or someone built a clever Nginx + Varnish + ATS setup years ago and it just kept running. I've maintained a few of those stacks myself, and I get the appeal. You can touch every config file, you don't have a vendor ticket queue, and for a regional audience with predictable traffic, it feels manageable.

Problems creep in slowly. You add a second origin for failover, then you need consistent purging across PoPs, then you need TLS cert automation, then log aggregation, then WAF rules that actually stay updated. Suddenly you're not running a cache layer anymore, you're running a distributed infrastructure company on the side. The last team I advised was spending 18-22 hours a week just on patching, cert rotations, and cache anomaly triage for their four-node footprint. That's not including the on-call burden. If you're mapping out a move, the Comprehensive CDN Migration Checklist: Every Step from Planning to Cutover is a solid companion to this walkthrough for making sure nothing gets missed in that operational shift.

The trigger for migration is rarely just performance. It's usually a combination of scale, staffing, and risk. When you can't purge globally in under a second, can't get real edge observability without building it yourself, or can't confidently handle a 10x burst, that's the sign your architecture has outgrown the model. Managed CDNs aren't magic, but they solve a specific set of hard problems — global PoP density, anycast routing, DDoS absorption, and continuous software updates — that are frankly miserable to replicate on-premise.

# Typical self-hosted origin shielding + stale-while-revalidate
# We used this pattern to hide origin latency before we moved
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=STATIC:100m inactive=7d use_temp_path=off;

server {
    location ~* \.(js|css|png|jpg|jpeg|woff2)$ {
        proxy_cache STATIC;
        proxy_cache_valid 200 7d;
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
        proxy_cache_background_update on;
        proxy_cache_lock on;
        add_header X-Cache-Status $upstream_cache_status;
        # This helps but doesn't replicate tiered edge + shield
        proxy_pass http://origin_app;
    }
}
# On a managed CDN, this becomes an edge rule with tiered cache and shield PoP
# No disk management, no cache lock tuning at 2am

Auditing What You Actually Have Before You Move Anything

You can't plan a transition without an honest inventory. I've seen migrations stall for months because teams forgot about that one legacy subdomain serving PDFs with 30-day cache headers set in application code, or that wildcard route that bypassed Varnish entirely. Before you talk to vendors, capture your real traffic shape. I usually start by pulling 30 days of logs from the edge — or from your load balancer if you don't have edge logs — and breaking down requests by content type, cacheability, and origin dependency.

That audit should cover four things: cache policies, origin behavior, security controls, and operational workflows. For cache policies, document every Cache-Control, Surrogate-Control, and Vary header you're sending and where it's set. For origins, note your connection reuse, timeout, and retry logic. For security, list every WAF rule, bot check, and IP allowlist you've hand-rolled. And for workflows, be explicit about how you deploy, purge, and roll back today. According to the HTTP Working Group Specifications, Cache-Control semantics are straightforward on paper, but in practice, inconsistent header logic between app and cache is the number one source of migration bugs I see.

Once you have that map, vendor evaluation gets much easier. You aren't shopping for vague "performance" — you're matching specific capabilities like instant purge, edge compute, image optimization, or log streaming to gaps in your current stack. I tell teams to score providers against their actual requirements instead of marketing checklists. The CDN Vendor Evaluation Matrix: Scoring Providers on What Actually Matters gives you a structured way to do that, and when you're ready to engage vendors, you'll want your requirements written clearly enough to get comparable answers — the CDN RFP Template: Writing Requirements That Get Meaningful Vendor Responses will save you two rounds of back-and-forth. Be brutally specific: "We need purging of 5,000 URLs in under 500ms globally" beats "fast purge" every time.

// Audit script we run before any migration
// Compares what your headers SAY vs what actually gets cached
// Run against your self-hosted edge and then against the managed CDN in staging

async function auditCacheBehavior(urls) {
  for (const url of urls) {
    const res = await fetch(url, { method: 'HEAD' });
    const headers = {
      'cache-control': res.headers.get('cache-control'),
      'cdn-cache-control': res.headers.get('cdn-cache-control'),
      'vary': res.headers.get('vary'),
      'age': res.headers.get('age'),
      'x-cache': res.headers.get('x-cache') || res.headers.get('x-cache-status')
    };
    // Flag common gotchas: no-store on static assets, missing Vary: Accept-Encoding
    // or Set-Cookie accidentally preventing caching
    const hasCookie = res.headers.get('set-cookie') !== null;
    console.log(url, headers, hasCookie ? 'WARNING: Set-Cookie present' : 'ok');
  }
}

// Pair this with web.dev guidance on caching best practices
// https://web.dev/articles/ for HTTP caching and stale-while-revalidate patterns

Comparing the Reality: Self-Hosted vs. Managed Delivery Side by Side

Teams often ask me if moving to a managed CDN will actually move the needle on performance. My answer is yes, but not always where you expect. The biggest win usually isn't a dramatic drop in p50 latency for users near your self-hosted PoP — it's consistency globally and resilience under load. A well-tuned managed setup smooths out those long-tail p95 and p99 times because requests hit a nearby edge with warm cache, and your origin stops getting thundering herds on every purge.

Operations changes even more than performance. On-premise, your team owns everything from kernel tuning to OpenSSL patches. With a managed provider, you trade that low-level control for configuration via API, Terraform, or a dashboard, and you get features that are hard to self-build well — like tiered caching where a shield PoP absorbs most origin misses, or real-time log delivery and edge analytics without maintaining your own Kafka pipeline. You'll still own your cache strategy — don't assume the CDN will fix bad headers — but you'll stop owning the undifferentiated work of keeping the cache infrastructure alive.

To set expectations, I share numbers from a mid-size e-commerce migration we completed last year. Roughly 80M requests per month, 60% image and static assets, origins in us-east-1. The self-hosted baseline was three Nginx/Varnish PoPs we managed ourselves. The managed target was a standard enterprise CDN with tiered cache and origin shield enabled. No application code changes, just header cleanup and rule migration. Here's how the first 30 days compared after cutover:

Metric (30-day avg) Self-Hosted (3 PoPs) Managed CDN (Global + Shield) Change
Edge Cache Hit Ratio 82.4% 96.1% +13.7 pts
Origin Egress Bandwidth 18.2 TB / mo 4.1 TB / mo -77% offload
Global p95 TTFB (all assets) 412 ms 148 ms -64%
Time to Purge (5k URLs) 45-90 seconds (eventual) 180 ms median (global) ~250x faster
On-call Hours / Month (CDN-related) ~68 hours ~7 hours -90%

Running Dual Delivery: How to Build the Parallel Stack Without Breaking Production

You've done the hard part — auditing what you've got and admitting where the self-hosted stack is costing you sleep. Now don't make the classic mistake and try a big-bang cutover on a Friday afternoon. We've seen that go sideways too many times. The teams that pull this off cleanly don't rip and replace; they run parallel delivery for 2-4 weeks and let real traffic tell them what's broken before customers do.

The pattern that works is simple: keep your origin exactly where it is, but put the managed CDN in front of a shadow copy of your traffic. Start with 5-10% of non-critical assets — images, JS bundles, fonts — and route them via the new CDN while your existing Nginx/Varnish stack still serves the other 90%. You're looking to validate two things here: cache hit ratio and origin shielding behavior. If you can't get above an 85-88% edge hit ratio on static assets in the first 72 hours, your cache-control headers are wrong, not the CDN. And if your origin sees a 3-4x spike in requests during that pilot, you haven't configured origin shield correctly.

This is where a proper CDN Proof of Concept Planning: Designing a PoC That Answers Real Questions pays for itself. Don't just test "is it faster?" — test purge latency, failover when you take an origin node down, and what happens when you push a bad config. We always set up a dedicated cdn-staging.yourdomain.com origin that mirrors prod, then use weighted DNS or a worker to split traffic. It gives you clean CDN Pilot Testing Methodology: Metrics, Duration, and Success Criteria data — TTFB at p50/p95, cache status headers, and origin egress in GB/hour — without touching your primary DNS.

# Example: Staged origin with shielding + correct cache control
# Terraform for managed CDN origin (CloudFront / Fastly / Cloudflare style)

resource "cdn_origin" "primary" {
  hostname          = "origin-prod.internal.example.com"
  origin_shield     = "iad-01"  # Pick shield closest to origin, not users
  shield_enabled    = true
  connect_timeout   = 5s
  read_timeout      = 15s
}

# Send 10% of /assets/* to new CDN via weighted routing for pilot
resource "cdn_route" "pilot_assets" {
  path_pattern      = "/assets/*"
  origin_id         = cdn_origin.primary.id
  cache_behavior {
    ttl             = 86400          # 24h at edge
    stale_while_revalidate = 3600
    # Don't let origin's no-cache dev headers nuke your hit ratio
    override_origin_cache_control = false
  }
  header_rules {
    # Pass real client IP and add debug header to verify HIT/MISS
    add_request_header  = "X-Cache-Debug: 1"
    add_response_header = "X-Cache-Status: {cache_status}"
  }
}

Advanced Patterns and Troubleshooting When Cache Gets Weird

Once you're live on dual delivery, you'll hit the fun edge cases. The number one call we get at 2 a.m. is "we purged but users still see the old file." Nine times out of ten it's not purge delay — it's that you've got three layers of caching and only purged one. If you're running Varnish in front of your origin for historical reasons, plus the managed CDN edge, you've got to purge both and bust the browser cache with a versioned URL. Managed CDNs will purge globally in 150-300ms, but if your origin still sends Cache-Control: max-age=31536000, immutable without a content hash in the filename, you're stuck for a year. Fix your naming convention before you migrate, not after.

Origin shielding is the other place folks leave 30-40% performance on the table. Without it, every one of the 300+ PoPs will hit your origin on a cold miss — that's a self-inflicted DDoS. With a single shield PoP enabled, you'll see origin requests drop by 60-75% overnight and your origin p95 latency stabilize under 120ms. If you're still seeing origin 5xx errors during a traffic spike after enabling shielding, check your origin's keepalive and connection limits. We've debugged too many setups where the origin's Nginx was still set to 512 worker connections — fine for one Varnish box, not for a shield PoP hammering it with 4,000 concurrent revalidations.

And don't ignore stale-while-revalidate and stale-if-error. They're not just nice-to-haves; they're how you survive an origin failure without serving 500s. A solid config is Cache-Control: public, max-age=3600, stale-while-revalidate=86400, stale-if-error=604800. That way, if your origin goes down for 10 minutes during a deploy, the edge keeps serving stale for up to 24 hours and revalidates in the background. If your checkout or API routes can't tolerate stale, segment them — don't apply the same TTL to /api/* as you do to /static/*. That's how you get oversold inventory or stale pricing.

Frequently Asked Questions

How long should we run in dual-delivery mode before full cutover?

At least 2 full business cycles — usually 14-21 days. You need to see a weekday peak, a weekend trough, and one deploy/purge cycle. If you cut over after 3 days of green dashboards, you'll miss the long-tail cache poisoning issues that only show up after 7-10 days. Use that window to validate your CDN Traffic Cutover Strategy: Migrating Without Downtime or Data Loss with weighted DNS (10% → 50% → 100%) rather than a hard CNAME flip.

Will we lose control over cache invalidation compared to self-hosted Varnish?

You'll actually gain granularity, but the API is different. Instead of SSHing in and running varnishadm ban, you're calling a purge API with surrogate keys. Tag your content at origin — e.g., Surrogate-Key: product-123, category-shoes — and you can purge 50,000 product pages in under 400ms with one call. It takes a bit of rework to add those headers, but it's far more surgical than regex bans.

What do we do about dynamic content and authenticated requests?

Don't cache it at the edge by default. Set Cache-Control: private, no-store for /account/*, /cart/*, and any API with an Authorization header. Where you can win is with edge acceleration — TCP/TLS optimization and Argo-style smart routing — which will shave 40-80ms off dynamic TTFB even with a cache MISS. For semi-dynamic pages like category listings, use a 30-60s TTL with stale-while-revalidate; users won't notice a 45-second delay and you'll cut origin load by 80%.

How do we prevent origin overload on cache purge or cold start?

Two things: origin shield as mentioned, and request coalescing (also called collapsed forwarding). Without coalescing, 1,000 simultaneous requests for the same cold object will all hit your origin. With it enabled, only one request goes through and the other 999 wait for that response. Every major managed CDN has it — just make sure it's turned on. We've seen it drop origin concurrency from 8,000 req/s to under 120 req/s during a full purge.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker