CDN Configuration Migration Automation: Converting Rules Across Provider Formats

I've migrated CDN configurations more times than I can count over the last decade, and I can tell you the midnight cutover is never the hard part. The hard part is week three of rule translation, when you've got 400 edge rules in Akamai Property Manager and someone asks why the Cloudflare conversion is dropping authenticated API traffic. That's exactly what happened to us during a retail client's cross-provider migration last fall — 18,000 lines of edge logic, and no automated way to prove the new rules actually behaved the same.

Why Rule Translation Fails When You Treat It Like Syntax Conversion

Most teams approach config migration as a find-and-replace problem. You take an Akamai rule, you look up the Cloudflare equivalent, you map the fields. I've seen spreadsheets with 200 rows trying to do this mapping manually, and they always collapse under their own weight. The issue isn't that the syntax is different — it's that the execution models have fundamentally different assumptions about order, scope, and default behavior. A cache-key modification that runs early in Fastly's VCL might run after authentication in another provider's phased rule engine, and that timing difference will break your application in ways that don't show up until production traffic hits it.

This is where a lot of cross-provider migration projects stall. You can't just translate what a rule says, you have to translate when and how it executes. For example, Akamai's Property Manager uses a nested tree with inherited criteria, while Cloudflare Rules and Fastly VCL use a flat, sequentially evaluated list. If you don't account for that structural mismatch, your rule conversion will produce a technically valid config that makes completely different decisions. I learned this after a media customer spent six weeks manually rewriting rules only to discover their geo-blocking logic was evaluating after cache lookup instead of before, effectively exposing content they meant to restrict. If you're still planning your approach, the Comprehensive CDN Migration Checklist: Every Step from Planning to Cutover is a far better starting point than jumping straight into translation.

// Intermediate Representation (IR) for deterministic rule conversion
// Don't translate Akamai -> Cloudflare directly. Normalize first.
{
  "id": "rule-042-cache-bypass-api",
  "source_format": "akamai_pm_v1",
  "normalized": {
    "phase": "request", // request | cache_lookup | response
    "priority": 100,
    "criteria": {
      "all": [
        { "field": "http.request.uri.path", "op": "matches", "value": "^/api/v2/.*" },
        { "field": "http.request.method", "op": "in", "value": ["POST", "PUT"] }
      ]
    },
    "actions": [
      { "type": "bypass_cache", "value": true },
      { "type": "set_header", "name": "x-migration-trace", "value": "bypass-apiv2" }
    ],
    "execution_order": "before_auth"
  },
  "targets": {
    "cloudflare_rules": { "phase": "http_request_cache_settings", "expression": "(http.request.uri.path matches \"^/api/v2/.*\")" },
    "fastly_vcl": { "subroutine": "vcl_recv", "condition": "req.url ~ \"^/api/v2/\"" }
  }
}

Building a Normalization Layer That Makes CDN Automation Possible

You don't automate provider-to-provider. You automate provider-to-IR and IR-to-provider, and that distinction is what makes CDN automation maintainable. My team now builds a canonical intermediate representation for every config migration, even if we're only moving between two vendors. The IR captures phase, priority, match criteria, and side effects in a provider-agnostic schema. That way, when you're moving from, say, CloudFront Functions to EdgeWorkers, you aren't rewriting your translator — you're just adding a new emitter. It feels like extra work up front, and it is, but it prevents the N-squared problem where every new provider requires you to write translators for every other provider you've ever supported.

Your translator also needs to handle what I call semantic defaults — the invisible behavior providers don't document well. Take header handling. One provider might preserve origin Cache-Control headers by default while another strips them unless you explicitly pass them through. Another might normalize header names to lowercase, breaking a downstream comparison that expects Title-Case. According to the HTTP Working Group Specifications, header field names are case-insensitive, but your application code may not be. We've had to code explicit lowercasing and passthrough directives into our IR just to keep behavior stable. The articles on web.dev Articles are surprisingly useful for sanity-checking how browsers will actually interpret the cached responses after these translations, especially around Cache-Control and Vary.

Once the IR is solid, the actual rule conversion becomes a code generation problem. We run everything through a validation pipeline before it ever touches a staging edge. That pipeline lints the IR for conflicts — like two rules with overlapping criteria but contradictory cache TTLs — and then emits provider-specific configs for testing. This is also where a proper CDN Vendor Evaluation Matrix: Scoring Providers on What Actually Matters pays off, because you've already documented which features have no equivalent on the target and need a workaround. Don't try to hand-migrate the exceptions; flag them in the IR as manual_review and fail the build until an engineer signs off.

#!/usr/bin/env python3
# cdn_translate.py - IR-based config migration emitter
import json
import glob

def load_ir_rules(path="ir_rules/*.json"):
    rules = []
    for f in glob.glob(path):
        with open(f) as fh:
            rules.append(json.load(f)["normalized"])
    # Sort deterministically by phase + priority - execution order matters
    return sorted(rules, key=lambda r: (r["phase"], r["priority"]))

def emit_cloudflare(rules):
    out = []
    for r in rules:
        if r["phase"] != "request":
            continue  # skip non-request phases for this emitter
        expr = " and ".join([f'{c["field"]} {c["op"]} "{c["value"]}"' for c in r["criteria"]["all"]])
        actions = [a for a in r["actions"] if a["type"] in ("bypass_cache", "set_header")]
        out.append({"expression": expr, "actions": actions, "priority": r["priority"]})
    return out

def validate_no_shadow(rules):
    # Simple shadowing detection: identical criteria, different actions
    seen = {}
    for r in rules:
        key = json.dumps(r["criteria"], sort_keys=True)
        if key in seen and seen[key] != r["actions"]:
            raise ValueError(f"Conflicting actions for criteria {key}")
        seen[key] = r["actions"]

if __name__ == "__main__":
    rules = load_ir_rules()
    validate_no_shadow(rules)
    with open("dist/cloudflare_rules.json", "w") as out:
        json.dump(emit_cloudflare(rules), out, indent=2)
    print(f"Emitted {len(rules)} rules -> dist/cloudflare_rules.json")

Validating Behavioral Parity Instead of Configuration Parity

A successful config migration doesn't mean the configs look similar. It means the edge behaves identically under real traffic. I've stopped comparing line counts or rule counts — they're vanity metrics. What matters is whether a request with a specific set of headers, cookies, and path gets the same cache status, response headers, and origin fetch decision on both CDNs. That's why our final stage isn't a config diff; it's a replay harness that fires 50,000 recorded production requests at dual staging stacks and compares the outcomes. You're looking for behavioral parity, and you won't find it by eyeballing JSON.

We log every difference and categorize it: TTL mismatch, header mutation, cache-key variance, status code divergence. Most mismatches trace back to an implicit default we didn't normalize. One classic example is query string handling in cache keys — some providers include all query parameters by default, others ignore them unless you explicitly whitelist them. If your IR doesn't have an explicit cache_key.include_query field, your automatically generated rules will serve the same cached response for /product?id=123 and /product?id=456. That's a cache poisoning incident waiting to happen, and you won't catch it without traffic replay.

Before you automate, make sure your requirements actually capture these edge behaviors. A vague RFP that says "must support edge rules" tells you nothing about phase execution or cache-key control. We've started requiring vendors to fill out a rule semantics questionnaire as part of procurement, which is well described in the CDN RFP Template: Writing Requirements That Get Meaningful Vendor Responses. Once you have those answers, you can build your IR to cover the deltas. The table below shows the tolerances we enforce before we'll sign off on a cutover — if any of these drift beyond threshold, the migration doesn't ship.

Building the Translation Engine: From Normalized Model to Provider Config

Once you've got a clean intermediate representation, the translation engine itself is surprisingly straightforward — it's just parsers and emitters. The mistake we see teams make is trying to do direct 1:1 string conversion with regex. Don't do that. You'll spend three weeks handling edge cases for Akamai's <match:request.path> syntax only to realize Fastly's VCL doesn't even have a declarative path match — it's an if statement with its own evaluation order. Instead, build a proper emitter for each target provider that ingests your normalized JSON and generates idiomatic config for that platform.

In practice, that means your normalized rule for something like "bypass cache for authenticated API calls" becomes a single object with fields like condition: { header: "Authorization", exists: true }, action: { cache: "bypass" }, priority: 100. Your Cloudflare emitter then renders that as a Cache Rule with an expression like http.request.headers["authorization"][0] ne "", while your Fastly emitter generates if (req.http.Authorization) { return(pass); }, and your Akamai emitter creates a property manager rule with a match.requestHeader criterion. We've run migrations with 800+ rules where the emitter pattern cut translation errors from 31% down to under 2% — mostly because each emitter can enforce provider constraints at generation time, like CloudFront's 25 cache behavior limit or Cloudflare's 10KB rule expression cap, rather than failing silently at deploy.

Where this gets operationally valuable is when you wire it into a GitOps pipeline. Store your normalized rules in Git, run the translators in CI, and push to provider staging environments via Terraform or their native APIs for validation. Don't push straight to production — you want a dry-run that diffs the generated config against what's live and flags semantic changes. For teams planning their first automated migration, start with a focused CDN Proof of Concept Planning: Designing a PoC That Answers Real Questions that migrates just 20-30 high-traffic rules and validates them under load. From there, your CDN Pilot Testing Methodology: Metrics, Duration, and Success Criteria should run for at least 7-14 days to catch TTL and cache-hit ratio regressions you won't see in a 60-minute smoke test.

# Normalized IR -> Provider Emitter Example
from typing import Dict, Any

normalized_rule = {
  "id": "bypass-auth-api",
  "condition": {
    "all": [
      {"field": "request.path", "operator": "starts_with", "value": "/api/"},
      {"field": "request.header:Authorization", "operator": "exists"}
    ]
  },
  "action": {"cache": "bypass", "set_header": {"X-Cache-Policy": "auth-bypass"}},
  "priority": 100
}

def emit_cloudflare(rule: Dict[str, Any]) -> Dict[str, Any]:
    # Emits Cloudflare Cache Rule expression
    expr_parts = []
    for cond in rule["condition"]["all"]:
        if cond["field"] == "request.path" and cond["operator"] == "starts_with":
            expr_parts.append(f'(http.request.uri.path starts_with "{cond["value"]}")')
        if "header:Authorization" in cond["field"]:
            expr_parts.append('(any(http.request.headers["authorization"][*] ne ""))')
    
    return {
        "expression": " and ".join(expr_parts),
        "action": "bypass_cache" if rule["action"]["cache"] == "bypass" else "eligible",
        "description": rule["id"]
    }

def emit_fastly_vcl(rule: Dict[str, Any]) -> str:
    # Emits idiomatic VCL for vcl_recv
    conditions = ' && '.join([
        'req.url ~ "^/api/"',
        'req.http.Authorization'
    ])
    return f'if ({conditions}) {{\n  set req.http.X-Cache-Policy = "auth-bypass";\n  return(pass);\n}}'

# CI step: validate no emitter exceeds provider limits
assert len(emit_cloudflare(normalized_rule)["expression"]) < 4096

Handling Edge Cases That Break Automated Migrations

Even with a solid normalization layer, about 15-20% of your rules will hit provider-specific quirks that no generic translator handles cleanly. The biggest one is regex. Akamai and Fastly use PCRE, Cloudflare and CloudFront Functions use RE2, and Fastly's VCL regex is case-sensitive by default while Akamai's isn't. We audited a migration of 1,200 Akamai rules last quarter and 147 of them used negative lookaheads — which RE2 flat-out doesn't support. Your automation needs to flag those at translation time, not at deploy time. We've found it's better to have the emitter fail loudly and suggest a rewrite — like splitting a single lookahead into two sequential rules — than to silently convert it to a RE2 pattern that matches 90% of traffic and leaks the other 10% to origin.

Cache-key logic is the second landmine. Most teams assume a cache key is just URL + query string, but Akamai's default is to include far more — like device characteristics and certain headers — while CloudFront defaults to almost nothing unless you explicitly allowlist it. If you directly translate a rule that says "cache on query string sort and page" without normalizing the full key composition, you'll either see your hit ratio drop from 92% to 64% overnight, or worse, serve personalized content from cache. Our fix is to always normalize the *entire* cache-key definition in the IR and require explicit mapping for query string handling: include`, `exclude`, `include_all`, `ignore_case. That forces you to reconcile that Akamai `queryStringBehavior: USE_INCOMING` means something very different than Cloudflare's `cacheKey.queryString.include`.

Finally, don't trust config validation alone — you need live behavioral validation with shadowing. Roll your translated rules to a staging property, mirror 5-10% of production traffic via a worker or edge function, and compare responses byte-for-byte for at least 48 hours. Watch for 3 things specifically: header order and casing changes that break fragile mobile clients, TTL drift where a 3600s edge TTL becomes 86400s because of default inheritance, and origin shielding differences that spike origin load by 3-4x. A phased CDN Traffic Cutover Strategy: Migrating Without Downtime or Data Loss with weighted DNS or load balancer steering lets you roll back in under 60 seconds if your p95 origin latency jumps more than 40ms or your 5xx rate ticks up by even 0.1%.

Frequently Asked Questions

Can we fully automate 100% of CDN rule migration without manual rewrites?

No, and you shouldn't try. In real-world migrations, we typically automate 80-85% of rules end-to-end. The remaining 15-20% involve provider-native features with no equivalent — like Akamai's SureRoute, Fastly Image Optimizer, or Cloudflare Workers with KV — that require manual redesign. A good automation pipeline flags those as "needs human review" instead of guessing. Aim for 100% coverage of simple cache, header, and redirect rules, and budget 2-3 hours per complex edge-compute rule for manual porting.

How long does an automated CDN migration actually take compared to manual?

For a mid-size property with 300-600 rules, manual migration takes our clients 6-10 weeks with 2 engineers, mostly spent hand-testing. With a normalization + emitter pipeline, the first migration takes 3-4 weeks to build the toolchain, but the second migration of similar size drops to 5-8 days. The bulk of that time isn't translation — it's behavioral validation. Plan for at least a week of shadow traffic comparison and two phased cutover windows at 10% and 50% traffic.

How do you validate that the new CDN behaves exactly like the old one?

We don't compare configs — we compare responses. Log both CDNs' responses for the same requests (same URL, headers, edge location) and diff status code, cache status (HIT/MISS/BYPASS), TTL headers, and body hash. You're aiming for >99.5% parity on status and cache behavior before cutover. Tools like curl --resolve with replayed HAR files or a GoReplay shadow setup work well. Don't forget to test negative paths: 404s, 403s, and origin timeouts should also behave identically, or you'll get paged at 2am.

What if the target CDN doesn't support a feature we're using today?

This happens constantly. About 1 in 10 rules will reference something proprietary. You have three options: 1) Emulate it with edge compute (e.g., recreate Akamai ESI with a Cloudflare Worker — adds ~15-25ms latency), 2) Move the logic to origin (simple but increases origin load), or 3) Drop it if it's legacy cruft. We've seen migrations where 30% of "required" rules hadn't been hit in 90 days of logs. Audit your rule hit counts for 30 days before you migrate — you'll likely decommission 20% of them outright.

Related Articles

Validation Metric Legacy CDN (Baseline) Target CDN (Auto-Converted) Allowed Variance
Cache Hit Ratio (24h replay) 92.4% 91.9% ±1.5%
TTFB p95 (global) 118 ms 124 ms ±10%
Rule Match Accuracy 100% (baseline) 99.97% ≥99.9%
Origin Requests per Minute 4,820 4,945