Robot Ships Blog ~ Astro to Cloudflare Pages (and How I Ditched DreamHost)

AI Art


Part 1 was the long road out of WordPress. This is the short road onto Cloudflare Pages, and it is short mostly because Pages doesn’t leave you much to do. You connect the repo, tell it the build command, and get out of the way. The interesting parts are a redirect that would have quietly died at cutover, and the rehearsal that let me flip DNS without holding my breath (I still did but I’m config change squeamish).

Claude’s job in this half was smaller. It wrote the cutover runbook and the checklist, and every verification step in the checklist is a curl block that can be pasted and re-run. I asked Claude to run validations after sections to make sure everything was going smoothly.

Wiring the build

The setup is all dashboard clicking. In Cloudflare, under Workers & Pages, I created a Pages project and connected the GitHub repo. Framework preset Astro, build command npm run build, output directory dist, and one environment variable (NODE_VERSION set to 22) so the build machine matches the Node on my desk. The first build came back green with 57 pages, 17 redirect rules parsed from the _redirects file, and a sitemap. The project also gets a *.pages.dev hostname, which ended up being where the entire cutover got rehearsed.

Pages builds from private repos natively, so the repo stays private and no tokens get pasted anywhere. Hosting cost is zero. A static blog fits inside the free tier with room to spare (so long as y’all don’t go crazy).

Branches are environments

One part of Pages reorganized my workflow in a way I did not expect. main deploys to production automatically on push, and branches can get their own public preview URLs. I have preview deployments locked down to dev and preview-* branch names, so a random feature branch doesn’t deploy anywhere. The dev branch’s preview URL is now my staging site. Feature work stays in a tighter local loop, but before anything merges to main I review it on the dev preview, on real hosting with real redirects instead of a dev server. Part 1 has a whole section on why reviewing real builds matters; Pages makes the real build the easy default.

Auto-deploying main also means an absent-minded push goes straight to production. GitHub’s branch protection is not available on a free-plan private repo, so the repo carries a committed pre-push hook that refuses local pushes to main outright. Day-to-day work goes onto dev, and main moves only by a deliberate pull request. A hook is weaker protection than anything server-side, but the accident I am guarding against is me (or an agent) pushing to production without thinking, and it ought to stop that just fine.

Rehearse on pages.dev

Before DNS moved, everything testable got tested against the pages.dev hostname (thank you Claude). The old WordPress URLs are date-based (/2025/03/10/slug/) and the new site preserves those paths. The checklist crawls the old URL list and sorts every result into one of three buckets. Preserved URLs have to return 200. Moved ones (renamed category slugs, the retired /feed/ path) have to 301 to the right target. And the eight URLs I deliberately let die have to 404 on the custom 404 page.

One quirk worth noting. Astro’s local preview server and Pages disagree about un-slashed URLs when trailingSlash is set to "always". The preview server answers /nope with its own terse error page before ever consulting your custom 404, while Pages serves the custom 404 for both /nope and /nope/. So the 404 behavior had to be verified on the actual hosting. The rule from part 1 applies to hosts too. At some point you have to check the actual system and not your model of it.

The redirect that would have died

The apex domain (the bare half-built-robots.com, no www) 301s to www, and it had done that for years, so I assumed it was handled somewhere in DNS. Then curl -sI on the apex showed the header x-redirect-by: WordPress. WordPress itself was issuing that redirect. The moment WordPress went away, the apex would break and every link to the bare domain would die with it (not that I had much search presence or backlinks but it’s the thought that counts).

The fix took a couple of minutes. A Cloudflare redirect rule sends the apex to www with the path and query string carried through, created and verified before the flip. Claude finding that problem is the part that impressed me. I would never have noticed which layer issues a redirect by clicking around, because the redirect works either way. Claude noticed because it habitually reads response headers and apparently does not get bored doing it.

Pull the Lever, Kronk

And now the big switchover, which was actually pretty quick and painless. Simply add www as a custom domain on the Pages project, accept the CNAME change, wait. Propagation took about ninety seconds, and then www was serving the Astro site with no WordPress fingerprints in the HTML.

WordPress was not touched. It is still sitting at DreamHost, fully intact, dreaming its PHP dreams and serving pages to absolutely no one, kept on as the rollback. Undoing the entire migration would have been repointing DNS back to the old origin.

No wrong lever this time.

Afterward

Minutes after the flip, the checklist crawls re-ran against the live domain. Sixteen preserved URLs at 200, nine redirects landing on their targets, eight deliberate 404s, and a sitemap on www. The feed also got some attention. I don’t use RSS myself, so it is hard for me to judge what feed readers want, but a broken feed is something nobody would ever report. It would just quietly stop updating for them. So the legacy /feed/ paths all 301 to /feed.xml, the feed validates, and conditional GET returns 304 so feed readers poll cheaply. For analytics I turned on Cloudflare’s built-in web analytics, which is just a toggle in the dashboard. It does not use cookies, so I do not need a cookie banner either.

Then the site sat for a couple of weeks with WordPress parked as the rollback while I watched Search Console and analytics for surprise 404s. When I first wrote this post that paragraph ended with “and now we soak.” The soak is over, and it turned up more than I expected, so here is what those two weeks looked like.

What the soak turned up

The first one showed up on cutover day, before I had even closed the dashboard tab. One of the checklist steps has Claude pull robots.txt off the live domain, and the top of the file was not mine. Cloudflare had stapled a managed block onto it telling every AI crawler it knows about to go away (GPTBot, ClaudeBot, the lot), which I gather is the default on a new zone. That is the exact opposite of what I want. I would honestly like the AI systems to index and cite this blog (that is sort of the point of writing things down), so the toggle went off and my boring allow-everything file is what serves now. The part that stuck with me is that a dashboard toggle changed what my site serves. No commit, no diff, no failing test, just a different file on the internet.

So the checklist crawl grew up and became a GitHub Actions workflow. It is just a shell script full of curl calls that runs after every deploy, every Monday morning, and whenever I poke it. It checks the redirects, the URLs that are supposed to 404, the actual body of robots.txt, and the privacy claims on the policy page (no cookies, scripts only from the hosts I named). The Monday run is the one I care about. Nothing in the repo changes on a Monday morning, so if that run fails, something outside the repo moved and I want to know before a reader does.

It failed twice before the soak was over, and both times it had found something real. The privacy probe found the analytics beacon (fine, I turned that on at cutover) and then found my privacy policy still claiming the site ran no analytics at all. That sentence was true on WordPress and I just never went back to it. Oops. The second one was a morning where every www URL came back 403 from the GitHub runner while the apex redirect passed and every browser I tried loaded the page fine. Blocked from a datacenter but fine from my laptop smells like bot detection, and sure enough Bot Fight Mode had gotten switched on in the zone during some unrelated clicking around (that was my fault). On the free plan there is no tuning it, so it had been quietly telling feed readers and search crawlers to go away too. Off it went, and it is now on the runbook’s short list of toggles that stay off no matter how helpful the dashboard makes them sound.

The other surprise was DNS, which I thought I was done with. When the DreamHost origin finally came out of the zone, plain http:// requests to the apex started coming back 522 while https:// was totally fine. Turns out the redirect rules I so carefully wrote before the flip only matched HTTPS, and the whole time the old WordPress box had been quietly answering the HTTP side for me. Turning on Always Use HTTPS closed it in about a minute. That is now twice that a redirect I depended on was being issued by a layer I had already mentally deleted. I am starting to see a pattern.

The rest of the soak was tying off loose ends. A small pull request of launch metafiles (sitemap pointer, long cache headers on the hashed assets, JSON-LD, a security.txt, a web manifest), an accessibility suite in CI that runs axe over every template in both themes, and a first release tag on main at v1.0.0.

The cutover recipe, for your robot

Part 1 ends with a recipe for the migration itself. This is the matching one for the hosting end, in the same spirit. Point your agent at it and it should be able to write you a runbook for your own flip. The steps are in the order they need to happen, and the order is most of what the recipe is. The hosting half is short, but as the section above shows, it is not the half that leaves you alone afterward.

If you are the agent reading this

Your first output is a runbook and a checklist, and every verification step in the checklist is a curl block the owner can paste and re-run. The checklist is the record. As each step is verified, the command’s actual output gets pasted under it, so the document that planned the cutover is also the document that proves it happened. Do not flip DNS until every blocker in the runbook is checked off.

Before you pick a day

  1. Close every blocker and write down that it is closed. For me that was the newsletter form wiring, the redirects file committed and pushed, and one site setting driving every canonical URL, OpenGraph URL, sitemap entry, and feed link. If canonical is www, it is www everywhere, no exceptions you will remember later.
  2. Find out which layer issues every redirect the site depends on. curl -sI and read the headers, including any x-redirect-by line. Anything the old origin issues will die with it and needs a replacement created and verified before the flip. I have now been bitten by this twice, so I am going to keep saying it.
  3. Sort the old URL list into three buckets. Preserved URLs must return 200. Moved URLs must 301 to a named target. URLs the owner has decided to let die must 404 on the custom 404 page. That list is the crawl, and it runs at every stage from here on.
  4. Decide when the migration pipeline retires. If the old site is frozen and the repo is already the source of truth, no final run is needed, and the retirement commit can land before or just after the flip.

Rehearse on the preview hostname

  1. Create the hosting project with the framework preset, build command, output directory, and a pinned Node version so the build machine matches the one on the owner’s desk. Confirm the redirects file was parsed and the sitemap was built.
  2. Run the whole crawl against the preview hostname the host gives you. Nothing about DNS has changed yet, so a failure here costs nothing but pride.
  3. Verify the custom 404 on the real host, in both the slashed and unslashed URL forms. The local preview server may answer one of those forms itself and never show you the real page (mine did).
  4. Check the feed, the sitemap, and the canonical tags for the future hostname, since they were generated with it before it resolves anywhere.

The flip

  1. Create the apex redirect rule at the new layer and verify it with curl before touching the www record.
  2. Add the canonical host as a custom domain on the project, accept the DNS change, and wait. Mine took about ninety seconds, most of which I spent refreshing.
  3. Do not touch the old origin. It is the rollback, and the rollback is one DNS record pointed back at it.
  4. Re-run the crawl against the live domain within minutes, and confirm there are no fingerprints of the old platform in the served HTML.

The first hour

  1. Feed continuity. Every legacy feed path 301s to the new feed, the feed validates, and a conditional GET returns 304. Nobody will ever report a broken feed, so this is the one thing you cannot wait for a complaint on.
  2. Fetch robots.txt from the live domain and read the whole body, not just the status code. Anything at the top that the owner did not write came from the host, and the owner decides whether it stays.
  3. If analytics gets turned on, the privacy policy changes in the same sitting. I did not, and a probe caught the policy lying later.
  4. Land the launch metafiles as one small pull request. Sitemap pointer, long cache headers on hashed assets, structured data on posts, security.txt, and a web manifest.

The soak

  1. Turn the checklist crawl into a scheduled workflow. Run it after every production deploy, after every push to the staging branch against the preview site, on a weekly schedule, and on demand. Include the privacy assertions, since they are the ones the unit suite can never see.
  2. Watch the search consoles, and do not click anything that says validate fix on the 404 buckets. Old URLs draining out is the intended behavior. The real gaps will be a handful of URL shapes nobody thought of, and they become redirects in the repo with tests.
  3. Expect zone settings to change underneath you. The signature of a bot toggle is every page on the canonical host returning 403 from a datacenter while the redirects pass and a laptop sees 200. Check the toggle before checking the repo (it will save you an afternoon).
  4. Keep the old origin alive as the rollback for at least a week. Then stop pretending you will ever roll back.

Retirement

  1. Remove the old origin from DNS. Then immediately probe plain http:// as well as https://, because a redirect rule written for one scheme will not cover the other, and the old origin may have been answering the gap without telling you.
  2. Cancel the old hosting. This is the last recurring bill, and it is the only step in this whole post that saves money.
  3. If the registration is moving too, it moves after DNS and hosting are settled, and DNSSEC goes on once registrar and DNS are at the same place. Set auto-renew while you are there.
  4. Tag the release on the production branch and keep doing it after every deliberate merge.

That is the hosting half. Between this and part 1, your agent has the whole road, and the parts of it where I fell down are marked.

Ditching DreamHost

The soak turned up nothing that needed a rollback, so the WordPress hosting got canceled, and the last recurring bill this blog had, besides the domain name, went with it (which sort of offsets the token spend right?). The domain registration followed the hosting to Cloudflare, and DNSSEC went on once the registrar and the DNS were finally under one roof. Everything I cared about had already made it out and lives in git, so turning off WordPress was not much of a moment. It was serving pages to no one anyway.

Infrastructure simplification achieved.