Four Hugo sites I run moved from Netlify to Cloudflare Pages this month: this one, SIFS, Prompted Pours, and the Type & Signal company site. Type & Signal started on October 5. The other three followed, and this site cut over on October 10.

The static sites were the easy part. The parts that needed care were the DNS records that had nothing to do with the website, and one client-side app that depended on a Netlify rewrite rule.

Write the baseline first

Every site got a CLOUDFLARE_MIGRATION.md in its repository before anything changed. Each one records the same things:

  • The production URL, the Netlify rollback URL, and the production branch
  • The commit the site was built from, and confirmation that local matched origin with a clean working tree
  • The build command, output directory, and pinned Hugo version
  • The current DNS records, with which are proxied and which are DNS-only
  • Status codes and content types for the homepage, a representative page, robots.txt, sitemap.xml, and an unknown path

The baseline sounds like paperwork, but it is what makes the validation checklist meaningful. Without a recorded 200 and a recorded 404 from the old host, “the new site works” is a feeling. With them, it is a diff.

The same document holds the rollback plan. For this site, rollback is restoring the previous apex and www web records. Netlify stays intact for the observation period, then gets disabled rather than deleted, and kept for a week or two before final removal.

Mail is on the same domain

The domains also carry email through MailRoute, and that MX record is the one thing in the zone that would hurt if I got it wrong. Every migration note says the same thing in the same place: the MX 1 mail.mailroute.net record is DNS-only, and it is not touched.

So the cutover on this site was narrow. I replaced only the old apex web record with a proxied CNAME to mattparker.pages.dev. The proxied www record stayed, and a single permanent redirect now sends both HTTP and HTTPS www requests to https://mattparker.com, preserving path and query string. Everything else in the zone stayed as it was.

Check the project before the domain

The Pages project was created without the custom domain attached. I validated it on mattparker.pages.dev and on a branch preview first, and only then associated the domain. The checklist covered:

  • The homepage and the main Hugo routes returning HTML
  • robots.txt and sitemap.xml keeping their content types
  • The stylesheet and representative images loading
  • An unknown URL returning the site’s 404 page with an actual 404 status
  • Desktop and 390-by-844 mobile layouts rendering with a clean browser console
  • A non-production branch producing a working preview deployment

Two build details are worth writing down because they are easy to miss. The theme is a PaperMod Git submodule, so the build only works if Cloudflare clones submodules. And HUGO_VERSION is pinned in the Pages environment variables for both Production and Preview, so a build never depends on whatever version the platform defaults to. The sites do not all use the same version: this one pins 0.155.1, while Prompted Pours and Type & Signal pin 0.167.0 with the extended build.

After the cutover I repeated the whole checklist against the public domain, including the DNS and redirect checks.

The part that was not a static site

Shikomi Concord lives under this site as a client-side application. Its routes, like /shikomi-concord/overview, do not exist as files. On Netlify, that was a one-line rewrite in netlify.toml:

[[redirects]]
  from = "/shikomi-concord/*"
  to = "/shikomi-concord/index.html"
  status = 200

Cloudflare Pages has no netlify.toml, so the behavior had to be rebuilt. I did it with a Pages Function at functions/shikomi-concord/[[path]].js. It lets the request through to the static assets first, and only if the result is a 404 for a GET or HEAD does it serve the application shell instead:

if (
  response.status !== 404 ||
  (context.request.method !== "GET" && context.request.method !== "HEAD")
) {
  return response;
}

const appUrl = requestUrl;
appUrl.pathname = "/shikomi-concord/";

return context.env.ASSETS.fetch(new Request(appUrl, context.request));

Deep links work, real files are served normally, and the function only runs for that one path prefix.

Missing assets should be 404s

A catch-all fallback has a well-known failure mode. If a hashed JavaScript file is missing, say because a browser has an old HTML page that references a bundle from a previous build, the fallback hands back HTML with a 200. The browser then tries to parse HTML as JavaScript and fails in a confusing way.

So the function treats /shikomi-concord/assets/ differently. Asset paths never receive the application shell. A missing asset returns a real 404, and the validation checklist has an explicit line for it.

The stale HTML response

During testing, one asset path returned an HTML response cached under the main JavaScript URL. The cause was a bad response from earlier in the migration being served from cache, and it stayed there long enough to matter. The function now notices when a 200 on an asset path comes back as text/html, which should never be true, and re-fetches it with a throwaway query parameter to bypass the cached copy:

if (response.status === 200 && contentType.startsWith("text/html")) {
  requestUrl.searchParams.set("pages-asset", "98b8305");
  return context.env.ASSETS.fetch(new Request(requestUrl, context.request));
}

The parameter value is the short hash of the commit that introduced the workaround, so it is easy to find later. It is a targeted patch for a migration artifact, and I would rather have it visible in a short function than rely on remembering to purge a cache.

What I left behind

There was a second rewrite in netlify.toml for /shikomi-talk/*. That app no longer exists. Instead of recreating the rule, I left it out on purpose and added a check that /shikomi-talk/ still returns 404. Migrations are a good moment to find redirects that were only keeping a dead path alive.

Watching it

The production domain is now served by Cloudflare. The observation period started October 10 and runs at least 24 hours, with Netlify still intact. The rollback is restoring two DNS records, which is why I wanted the old values written down before I touched anything.

If I did this again, I would start from a shared template on day one. The sites differ in build commands and Hugo versions, but all four migration records ended up with the same shape: baseline, project settings, validation, cutover, rollback.