Skip to content
Writing/Detail

The migration shipped green with every redirect broken

06/07/2026 (edited)
Tech
864 Words
4Min read

I moved a site off Rails onto a static build. Parity was checked properly: marketing pages 12 of 12, blog 86 of 86, legal 8 of 8, case studies 3 of 3. The SEO and AI discovery files all returned 200.

The deploy was green. Seventy-four legacy URLs returned 404.

The file was correct and the host ignored it

The redirects lived in a _redirects file. Correct syntax, correct paths, generated from the content rather than hand-written.

The first host I deployed to does not read that file. It is not a supported feature there. So the file sat in the output directory, deployed faithfully, and did nothing at all.

Here is why it survived a careful check: a missing redirect and a missing page produce the same response. A 404. There is no error, no warning, no build failure. The only way to detect it is to request an old URL and look at what comes back, and if your parity check is a list of new URLs — which mine was — you will never request one.

I had verified that everything I built existed. I had not verified that everything I deleted still went somewhere.

Redirect files are host-specific dialects

Moving to a host that does support the file did not immediately fix it either.

The two hosts share a filename and a rough syntax and diverge in the details. One supports a force suffix — 301! — to override an existing file at that path. The other does not: it parses the status as a number, gets zero, and fails the deploy.

That is the better failure of the two, because at least it is loud. But it makes the point. _redirects looks like a standard. It is a convention with per-vendor extensions, and rules copied between hosts can be silently inert, loudly rejected, or fine, with no way to tell by reading them.

I moved the force suffix out of the templates and into the generator, so the dialect is decided in one place by the thing that knows which host it is targeting.

Two more silent losses in the same migration

The contact form became a mailto: link. Somewhere in the port, a working form was replaced by an email link. Functionally that is a feature deletion. It passed every check because the page still rendered, still returned 200, still looked right in a screenshot. No test covered “a submission reaches an inbox”, so nothing noticed.

Bare paths were fragile. The site was configured for URLs without trailing slashes, but the build emitted directory-style output, so whether a bare path resolved depended on host behaviour rather than on anything in the repository. The durable fix was making the build emit files rather than directories, so the URL shape is decided at build time and not negotiated per host.

Both share the shape of the redirect bug: the failure is an absence, and absences do not raise errors.

Verify by diffing behaviour, not by counting successes

The check that finally worked was this: pick a URL where the old site and the new site behave differently, and confirm you get the new behaviour.

Not a page both serve — that tells you nothing about which one answered. A URL that 301s to a new location on the new site and 404s on the old one. Request it. If you get the redirect, you are on the new deployment. If you get a 404, you are not, whatever the deploy log said.

That single request is worth more than a hundred pages returning 200, because it distinguishes between the two states you actually care about.

Final verification after the fix: 8 of 8 redirects behaving, 134 of 134 pages returning 200, apex redirecting to www, DNS records for email untouched.

The tail is longer than the cutover

Weeks later I was still finding remains. Open pull requests against directories that no longer existed. Automated fix suggestions referencing deleted files. Documentation that still named the old host while the deploy config in the same repository named the new one.

A migration is not finished when traffic moves. It is finished when nothing in the repository still describes the previous world, and that takes a deliberate sweep, because none of it breaks anything. It just quietly misleads whoever reads it next — increasingly, an agent.

What I do now

Test the old URLs, not the new ones. Take the previous sitemap, request every entry against the new deployment, assert a 301 to somewhere that resolves. This is now a build step: it generates the redirects from the content and fails the build if any target does not exist.

Never trust a redirect file you did not verify on that host. Deploy one rule, curl it, then generate the rest.

After any route move, grep the repository for the old namespace in markdown, text and config, not just in code. The dead references live in the documents, and documents do not have tests.

Back to writing
End of Post