CLOUDFLARE

We cut our site over from WordPress to Cloudflare Workers. Here's what surprised us.

May 25, 2026  ·  By Brian Kennedy

astro devops retrospective cutover

The phone call you do not want during a DNS cutover is the one where the person you are testing with says “I can reach the site from Firefox but Chrome and Edge both throw DNS_PROBE_FINISHED_NXDOMAIN.” We had that call about thirty minutes after the new site went live. The fix turned out to be one HTTP POST against a Pi-hole API. The reason it took thirty minutes to find that fix is most of what this post is about.

This is a retrospective on cutting www.4thoctet.com over from its prior life as a legacy WordPress install on Bluehost to its new life as Astro 6 + Cloudflare Workers. The cutover landed on May 24, 2026. The site has been on the new stack since. Most of it went well. The parts that went sideways were instructive enough that we are writing them down.

The brief

Move the marketing site off shared hosting and onto a stack we actually want to operate. Specifically: static-first rendering, edge-cached, no PHP, no SQL, no CMS-as-a-service we do not control. Keep the existing domain. Do not break the email path. Do not lose any subdomains. Cut over without a flag-day surprise. Be ready to roll back if something goes wrong on the new side.

The new stack: Astro 6 for the application framework, Cloudflare Workers Static Assets for the runtime, Sveltia CMS for the editor surface, n8n for form-submit orchestration, Cloudflare Turnstile for bot mitigation on forms, HubSpot Forms API for lead capture, Resend for transactional email, HMAC-signed webhooks between the Worker and n8n. The existing Cloudflare DNS zone stays where it is; we only swap A records and add Custom Domains on the worker.

The actual cutover, in order

Pre-flight: we verified that no Bluehost-side service was actually in use on the domain. MX records pointed at Microsoft 365. SPF/DKIM/DMARC were already aware of M365. The other 22 DNS records on the zone pointed at our own edge, Cloudflare proxy IPs, HaloPSA, DocuSeal, or M365 endpoints. Nothing was depending on the Bluehost origin except the apex and www A records. That made the cutover a two-record swap, not a domain migration.

Step one: delete the two A records pointing at the Bluehost origin (50.6.2.61). The site is now unreachable from public DNS at the apex.

Step two: deploy the worker with a routes block declaring 4thoctet.com and www.4thoctet.com as Custom Domains.

Step three: Cloudflare auto-creates the proxied AAAA records pointing at the worker, provisions edge certificates (~30 seconds end to end), and the site is live on the new stack.

Step four: smoke test against production. Hard-reload, verify all routes return 200, verify the contact form submits, verify the n8n workflow fires and the follow-up email lands.

That is the sequence. Each step took less than a minute. The 30 minutes happened in step four.

Eight things that surprised us

1. Wrangler’s routes block is authoritative, not additive

We declared two routes in wrangler.jsonc: 4thoctet.com and www.4thoctet.com. After the deploy, cms.4thoctet.com, which had previously been attached to the same worker via the Cloudflare dashboard, vanished. Wrangler treats the routes block as the complete list of Custom Domains for the worker. Anything not listed gets removed on the next deploy. Cloudflare also auto-deletes the associated DNS records.

The fix is to include every Custom Domain hostname in the routes block. The lesson is that wrangler’s source-of-truth behavior is more aggressive than the dashboard’s, and the dashboard state should not be relied on to survive a deploy.

2. custom_domain: true does not always create the attachment

We declared { pattern: "4thoctet.com", custom_domain: true } in the routes. wrangler deploy returned success. The Custom Domains list at /accounts/{id}/workers/domains showed nothing new. The deploy output included only the workers.dev URL as a trigger.

We worked around it by calling the API directly with a PUT to /accounts/{id}/workers/domains. That created the attachment cleanly. We do not yet know why the wrangler-declarative path silently failed; it has happened twice during this engagement, both times producing the same symptom.

3. The cert_status field on Custom Domains is unreliable

After the API attachment succeeded, Cloudflare auto-provisioned the edge certificate. We verified that with openssl s_client against the edge IP, presenting the right SNI: the certificate came back signed by Google Trust Services, with CN=4thoctet.com, valid. Site served HTTPS fine.

The cert_status field in the API response, meanwhile, said null. It still says null. It is not a useful gate for “is the cert ready”; the actual test is whether TLS handshakes succeed at the edge.

4. Workers.dev URLs bypass the zone WAF

The new site was tested for days on *.workers.dev preview URLs before the cutover. Every test passed. Form submission worked. Bots were mitigated by Turnstile. Page renders looked right on every screen size.

The moment the Custom Domains attached, the same site started returning HTTP 403 to a meaningful fraction of visitors. The reason: the zone WAF chain (Browser Integrity Check, OWASP Core Ruleset, Cloudflare Managed Ruleset, AI bot blocking, all of it) does not run for *.workers.dev URLs. Those zones are Cloudflare-owned. When you attach your own zone as a Custom Domain, the visitor traffic suddenly traverses your zone’s full security chain on the way to the worker.

The takeaway: anything that has been tested only on workers.dev preview URLs has not actually been tested against your zone’s WAF. We caught this immediately because the zone WAF settings were inherited from the WordPress era, and a static-Astro-on-Workers site has nearly zero attack surface, so the inherited paranoia was wrong for the new stack.

5. Pi-hole’s negative DNS cache outlives the cutover by an hour

Back to the opening anecdote. Firefox visitors reached the site. Chrome and Edge visitors got DNS_PROBE_FINISHED_NXDOMAIN. The difference: Firefox has DNS-over-HTTPS enabled by default, which routes DNS through Cloudflare and bypasses the LAN’s Pi-hole resolver. Chrome and Edge use the system resolver, which on our LAN means Pi-hole.

During the cutover, there was a window of roughly 30 seconds where the apex and www records did not exist in DNS at all: we had deleted the Bluehost A records, and Cloudflare had not yet created the proxied AAAA records for the new Custom Domain attachments. Anyone who queried the resolver during that window got a negative answer (no A, no AAAA), which Pi-hole cached. The default negative-cache TTL on dnsmasq is 3600 seconds.

For an hour after the cutover finished, Pi-hole on our LAN kept returning the empty answer it had cached during the 30-second blackout. We fixed it with POST /api/action/restartdns against the Pi-hole API. (The endpoint is lowercase. We learned that by trial. The dashboard documentation suggested restartDNS would work, which it does not.)

The general lesson: any DNS cutover that mutates apex or www records of a zone resolved by Pi-hole or any other caching resolver should include a planned cache-flush step in the runbook, executed the moment the new records are confirmed live. Firefox plus DoH will mask the problem until a Chrome user calls.

6. The Cloudflare-managed robots.txt blocks AI crawlers by default

Cloudflare Bot Management has an is_robots_txt_managed setting which, when on, auto-prepends a block to your /robots.txt that disallows the major AI crawler user agents (ClaudeBot, GPTBot, Google-Extended, Bytespider, CCBot, and others) via Disallow: / directives, plus a Content-Signal: search=yes,ai-train=no header.

For a publisher protecting copyrighted content from AI training, this is the right default. For a marketing site that wants to be indexed by AI search summarizers and surfaced in chatbot answers, it is the wrong default. We had inherited the default-on setting from the WordPress configuration. We turned it off.

The new robots.txt allows all crawlers. The only paths blocked are /admin/ (the Sveltia CMS surface, intentionally locked down) and /api/ (the worker’s form-submit endpoint, intentionally not for crawling). Everything else is fair game.

7. Astro Content Collections key names matter more than they look

The site has a case-studies content collection. Each markdown file lives in src/content/case-studies/. The URL is /case-studies/<slug>/. The collection is declared in content.config.ts as a TypeScript variable named caseStudies (camelCase, per JavaScript convention).

The variable name became the collection name in the export object. Pages calling getCollection('case-studies') (kebab-case, matching directory and URL) silently returned an empty array. No error, no warning beyond a single “the collection does not exist or is empty” line in the build output. The build succeeded. The index page rendered its empty-state branch. The detail page generated no slug pages. The site looked superficially fine.

The fix was renaming the export key from caseStudies to 'case-studies'. Pages found their content. The detail pages generated. We will not be making that mistake again.

8. A deploy step ran before the file we depended on was actually written

This is the incident-worth-admitting section. Mid-cutover, we ran what was effectively a chained operation: edit wrangler.jsonc to add the routes, then run a shell sequence that deleted the Bluehost A records and then wrangler deploy. The edit step failed silently. The shell sequence kept going. The A records were deleted. The deploy ran without the routes block. The site went unreachable on the new stack for the two minutes it took to notice, re-edit the config, and re-deploy.

We documented this as an anti-pattern: never chain a destructive action after an edit without verifying the edit succeeded. The shape of the failure is unambiguous and we will avoid it next time.

What we would do differently

A few things, ordered by how much we would have liked to know them yesterday:

Build the Pi-hole flush into the cutover runbook. Before any apex or www record change, the runbook should have a step that flushes the LAN resolver immediately after the new records are confirmed live. Firefox-over-DoH masking the problem from the operator is a recurring class of “looks fine to me, broken for the user” outcomes.

Smoke-test against a staging custom domain before cutting prod. Attaching staging.4thoctet.com to the same worker for a day before the cutover would have surfaced the zone WAF issues against real visitor traffic without affecting production. We were testing on workers.dev URLs the whole time, which means we were testing a path that bypasses our own zone’s security chain.

Treat inherited zone-level security as a fresh audit, not a default. The WordPress-era WAF settings were appropriate for WordPress. They were wrong for Astro-on-Workers. We dialed security_level from high to medium, turned off Browser Integrity Check, set Super Bot Fight Mode to allow definitely-automated traffic, disabled AI bot blocking, and moved all three managed rulesets to log-only mode for two weeks of tuning data. The settings appropriate for one stack are not appropriate for the next.

Trust openssl s_client over the API for cert provisioning state. It is the actual test of whether the certificate is serving correctly. The API’s cert_status field is, in our experience, not a useful gate.

Why we wrote this

This was supposed to be a placeholder blog post. The original one-line draft said “we are rebuilding the site, no customer action needed.” We replaced it with this because it is honest about what cutting over a real site actually looks like, and because the next time someone moves a site from a legacy hosting provider to Cloudflare Workers, the eight surprises above are the ones likely to bite. None of them are documented well. We had to find each of them by hitting them.

Most of what we do at 4th Octet is this kind of work. Not the cutover itself. The list of things that surprised us, and the discipline to write them down so they do not surprise us a second time.

If you are reading this and you have a similar cutover ahead of you, our inbox is open. Engineer to engineer. One business day to first response. No pitch deck.

Let's talk

Want to dig into this with us?

Your first call is with an engineer. Short, candid, free.

  • We reply within one business day.
  • Principal engineer on the call.
  • No pitch deck.

We reply within one business day, and your first call is with an engineer.