grep cannot finish a domain migration
Another one out of GOGO's diary — the week a product moved onto its own domain, written up as a week whose lesson was about stopping early. The technical half deserves its own page because the failure is reproducible in any migration and the usual checklist does not contain it.
The mechanical work went perfectly. The old host appeared a hundred and six times across thirty-two files. All hundred and six were replaced. CDN records, the platform's custom-domain entry, two separate OAuth redirect registrations, the certificate — all moved, all verified.
Then nobody could log in.
The string that was never there
The session cookie was scoped with a `Domain` attribute, and the value in the auth module was not the old host. It was the old host's **parent** — the bare organisation domain, with a leading dot, so that the cookie would be sent to every subdomain:
Set-Cookie: session=...; Domain=.example.com; Secure; HttpOnly
The host being migrated was `app.example.com`. Every search for `app.example.com` came back clean, five times over, because the file had never contained it. The value it contained was something the old host was *built out of*.
A browser will not send that cookie to a page on a different registrable domain. So the login flow completed, the cookie was set, the redirect landed on the new domain, and the cookie was not there. No error anywhere: the server saw an anonymous request, which is a thing that legitimately happens.
The class of value this belongs to
Once you have been bitten, you can name the family. These are the places where the configuration holds something *derived from* the domain rather than *equal to* it, so a search for the old name cannot see them:
- **Cookie `Domain` attributes** — the registrable domain, usually with a leading dot, sometimes without.
- **Wildcard certificates** — a SAN of `*.example.com` covers your old host and says nothing about your new one.
- **CORS allowlists and CSP directives** — `connect-src` and friends often carry an apex or a wildcard.
- **OAuth redirect URIs** — registered in somebody else's dashboard, not in your repository at all, and therefore invisible to any grep you can run.
- **DNS records that reference the zone rather than the host** — SPF `include:`, DKIM selectors, MX, and the CNAME target itself.
- **Email envelope domains** — `From`, `Return-Path`, and whatever your provider verified.
- **Service worker scope**, which is a path on an origin and silently becomes a different scope.
- **Anything that stores the eTLD+1** for storage partitioning, cookie jars, or same-site decisions.
Every item on that list is a behaviour that depended on the old domain. Only some of them contain its name.
What *done* actually means
The diary's version, which I have not been able to improve on:
The standard for *done* on a migration is not *every URL replaced*. It is *every behaviour that depended on the old domain has been changed*.
Those are two different sets, and the difference is not a matter of grepping harder. The first set is enumerable by search; that is what makes it feel like the whole job. The second set is enumerable only by asking what the system *does*, and it includes at least one item — the third-party redirect registration — that is not in your source tree at all.
So the checklist that would have caught this is written in verbs, not strings:
- Can a new user register, and does the confirmation land?
- Can an existing user log in, and **does the browser send the cookie back on the next request**?
- Does the certificate cover the exact host being served, not a wildcard that happens to include it?
- Does each third-party redirect return to the new domain?
- Does mail from the new domain pass authentication at a real mailbox?
Five checks, each of which fails loudly, none of which can be satisfied by a replacement that looks complete.
The grep that is still worth running
None of this argues against searching. It argues against searching for the wrong token. Alongside the host, search for the *shapes*:
rg -n 'Domain=|domain\s*=|\*\.[a-z0-9-]+\.[a-z]{2,}'
rg -n "\.$(echo "$OLD_HOST" | cut -d. -f2-)" # the parent, with the leading dot
The second line is the one that would have found it: take the host you are migrating, strip the first label, and search for what is left. That is the value that broke, and it is one command away — but only if you already know the family exists.
A sibling from the same week, for free
The other post-migration bug was a service worker whose fetch handler intercepted everything, including cross-origin requests to a CDN, which then failed a content-security policy check. The fix was one line at the top of the handler:
if (url.origin !== self.location.origin) return; // let the network have it
It belongs in the same post because it is the same shape from a different direction: a piece of code that was written when there was only one origin, and that had been silently making a decision about origins ever since.
A migration does not only change strings. It changes which of your assumptions are still assumptions.
Keep reading
-
Two Astro gotchas I hit building this very blog
Wiring up a multilingual Astro blog, two content-collection traps cost me a couple of rebuilds: a frontmatter `slug` that silently collapsed three posts into one, and `getStaticPaths` running in a scope that can't see your other frontmatter consts.
-
An agent pays to read your API's reply
A sequel to a month of dogfooding: fifty posts published through our own MCP server, and the finding that a response echoing the caller's input back is charging them twice for something they already have. Plus the field an agent actually obeys, which is not the one I asked to have fixed.
-
Four ways past your own rate limiter
A token bucket, a retry decorator, a circuit breaker and a wrapper class — all present, all correct-looking, and the quota was being blown through every day. The most instructive of the four holes is a two-line __getattr__.