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- WAF rules hadn't come across one to one. The sites are Drupal, so the old firewall blocked probes for WordPress paths like
/wp-login.phpat the edge. On Fastly those requests went straight to the origin. Going the other way, the new WAF blocked things it shouldn't have: security scanners like securityheaders.com got a403, so we couldn't even audit the new setup. If the plan says "migrate the WAF rules", ask for proof that every rule was migrated and tested. - Cache headers changed on one environment. Live on Cloudflare and test on Fastly both returned
public, max-age=86400, s-maxage=2764800. Dev on Fastly returnedno-store, no-cache, must-revalidate, max-age=0. Same code, different headers: something at the edge wasn't respecting what the origin sent. - HSTS lost a directive. Cloudflare sent
max-age=31536000; includeSubDomains. Fastly sentmax-age=31557600with noincludeSubDomains. Easy to miss, and a real change in what browsers enforce. - The certificate issuer changed, from Google Trust Services to Let's Encrypt. Technically fine, but it's the kind of change a client's security team wants to hear about from you, not discover later.
- A reverse-proxied path stopped working on the dev environment and had to be looked at separately.
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:
- Accuracy. Testing from Turkey, Cloudflare said
TR. Fastly saidBG, the country of the Sofia POP that served the request. In Europe the results didn't look reliable, and if the country is wrong, coordinates derived the same way probably are too. We asked the hosting team whether they had tested it themselves before building on it. - Caching. If a response depends on the visitor's country, the cache must know that, or the first visitor's country is served to everyone.
X-Country-Codewent into theVaryheader.
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
- A purge isn't instant from the visitor's point of view. After a purge, check the response headers. If
Ageis still lower thanmax-age, you are looking at a cached copy from somewhere: the browser, an intermediate cache or a POP that has not caught up. Wait it out before you report that the purge didn't work. - WAF rules can break admin work. On Cloudflare, Drupal batch operations in the admin UI sometimes failed with an HTTP 406. The cause was a firewall rule that didn't like the batch requests, and the fix was a rule change on the CDN side. Acquia has a support article about exactly this. If an admin action fails only on the public domain, test it without the CDN before you debug Drupal.
- Keep a switch to bypass the CDN integration locally. Our sites had a setting to turn the CDN integration off, and every local and cloud IDE environment set it in
settings.local.php. That way, clearing a local cache never talks to the real CDN.
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.