Migrating a CDN from Cloudflare to Fastly

The Drupal platform I work on serves a few dozen brand sites across many countries, all from one Acquia Site Factory codebase. For years the CDN in front of it was Cloudflare. Before the move I spent a while tuning it, and got the cache hit ratio from 82% to 96%. Then the platform moved to Fastly.

On paper a CDN migration is mostly configuration. Set up the service, add the domains and certificates, then change DNS. In practice, with many domains and several teams involved, the real question is a different one: how do you know that every domain actually moved, and that each one still behaves the way it did yesterday?

I didn't want to answer that by opening sites in a browser one by one, so I wrote four small scripts. Each one reads the same domains.txt and checks one layer.

Layer 1: does DNS point at Fastly?

The cheapest check. Resolve each domain's CNAME and see whether it ends at Fastly. This catches the domains whose DNS change was forgotten, is still waiting in someone's ticket queue, or points at the wrong place.

const fs = require("fs");
const dns = require("dns").promises;

async function checkCNAME(domain) {
  try {
    const [cname] = await dns.resolveCname(domain);
    return { domain, cname, isFastly: cname.toLowerCase().includes("fastly.net") };
  } catch (err) {
    return { domain, error: err.message };
  }
}

(async () => {
  const domains = fs.readFileSync("domains.txt", "utf-8")
    .split("\n").map((d) => d.trim()).filter(Boolean);

  for (const domain of domains) {
    const r = await checkCNAME(domain);
    if (r.error) console.log("[!] " + r.domain + " - " + r.error);
    else console.log((r.isFastly ? "[ok] " : "[no] ") + r.domain + " -> " + r.cname);
  }
})();

Run it before the change to see the starting point, then again after each batch. The output is a list that can go straight into a status update: done, not done, broken.

Layer 2: is Fastly actually serving it?

Correct DNS doesn't prove that the request went through the right service. An apex domain with an A record, a cached DNS answer or a domain missing from the Fastly service config can all look fine at the DNS level. So the second script sends a HEAD request and looks for headers that only Fastly adds: X-Served-By with the POP name, X-Timer, X-Cache, and the Fastly-* debug headers.

const https = require("https");

const FASTLY_HEADERS = [
  "x-served-by", "x-cache", "x-cache-hits", "x-timer",
  "fastly-debug-digest", "fastly-request-id", "server",
];

function checkDomain(domain) {
  return new Promise((resolve) => {
    const req = https.request({ hostname: domain, method: "HEAD", timeout: 5000 }, (res) => {
      const found = {};
      for (const h of FASTLY_HEADERS) if (res.headers[h]) found[h] = res.headers[h];
      resolve({ domain, status: res.statusCode, found });
    });
    req.on("timeout", () => req.destroy(new Error("timeout")));
    req.on("error", (err) => resolve({ domain, status: "ERROR", error: err.message }));
    req.end();
  });
}

Layer 3: what does a real browser get?

A HEAD request from a script isn't what a visitor sends. Bot protection, redirects and headers that vary by device can all return something different to a script. For the caching check I used a headless browser with a normal user agent and normal accept headers, and recorded the cache headers of the page it ended up on:

const puppeteer = require("puppeteer-extra");
puppeteer.use(require("puppeteer-extra-plugin-stealth")());

const CACHE_HEADERS = [
  "cache-control", "surrogate-control", "surrogate-key",
  "age", "etag", "last-modified", "expires", "x-cache",
];

async function cacheHeaders(domain) {
  const browser = await puppeteer.launch({ headless: "new" });
  try {
    const page = await browser.newPage();
    const res = await page.goto("https://" + domain, { waitUntil: "networkidle0", timeout: 15000 });
    const headers = res.headers();
    return Object.fromEntries(CACHE_HEADERS.filter((h) => headers[h]).map((h) => [h, headers[h]]));
  } finally {
    await browser.close();
  }
}

What I looked for: Cache-Control and Surrogate-Control matching what Drupal is supposed to send, Surrogate-Key being present so tag-based purging works, and an Age that grows between two requests. That last one shows the page is actually served from cache.

Layer 4: features that depend on the CDN

This is the layer that is easy to forget. Some features of the sites relied on the CDN itself. The clearest example was geolocation: the sites expose a small JSON endpoint that tells the front end which country the visitor is in, and that information comes from the edge. After the switch every domain needed a check that the endpoint still returned a country and coordinates:

const res = await page.goto("https://" + domain + "/geolocation/json");
const data = JSON.parse(await res.text());

const ok = data.ip
  && data.ip.country != null
  && data.ip.latitude != null
  && data.ip.longitude != null;

If you migrate a CDN, list everything your application reads from it: geolocation, client IP headers, device detection, bot scores, purge APIs. Each of those needs its own check.

What the checks actually found

The scripts paid off in the first round of testing. Comparing a site on Fastly with its twin still on Cloudflare turned up a list of differences that nobody had mentioned in the migration plan:

# test site, on Fastly
x-served-by: cache-iad-...-IAD, cache-iad-...-IAD, cache-sof...-SOF
x-cache: MISS, MISS, MISS, HIT
cache-control: no-store, no-cache, must-revalidate, max-age=0
strict-transport-security: max-age=31557600

# live site, on Cloudflare
server: cloudflare
cf-cache-status: HIT
cache-control: max-age=86400, public, s-maxage=2764800, stale-if-error=60, stale-while-revalidate=60
strict-transport-security: max-age=31536000; includeSubDomains

The x-served-by and x-cache headers are also worth learning to read. Fastly lists every node the request passed through, here two in Virginia and one in Sofia, with a hit or miss for each. That tells you whether shielding is working and which POP actually served you.

Geolocation: not just a different header name

The sites needed the visitor's country, and on Cloudflare it came from CF-IPCountry. Fastly doesn't send that header, so the hosting team added an X-Country-Code header instead. Renaming the header in our code was the easy part. Two other things weren't:

Latitude and longitude weren't available at all at first. They had to be requested from the hosting team as additional headers.

Purging the new cache

Drupal has to tell the CDN when content changes. The custom module that purged Cloudflare's cache was retired and removed from every site's configuration, and a Fastly purge module went on the list to replace it.

Making AJAX responses cacheable

The move also made it worth fixing filtered listings. Drupal Views AJAX responses had no cacheable Cache-Control header, so every filter click went to the origin and took 700 ms to 1.5 seconds. With a proper header, Fastly serves them in about 30 ms. The full story, including why each listing page hit Solr three times, is in Cutting Solr Requests per Page View from Three to One.

One platform-specific thing to know: on Acquia, the Cache-Control header can't simply be switched to private on a response; this is a known limitation. If something must never be cached, design for that early instead of trying to override the header at the end.

Things Cloudflare taught me that still apply

Takeaway

The CDN configuration was the easy part. What made the migration predictable was a set of small scripts that could tell me, for every domain, which layers were done and which weren't. They are short enough to write in an afternoon, and you can run them again after every batch of changes.

← All posts