A campaign domain is a promise. When John Harber decided his primary web address should be harberforatoka.com instead of mayorharber.com, the name change felt like a one-line edit. It was eleven files, three infrastructure layers, two bug fixes, and a lesson about SSL certificates that nobody documents until it bites them.
The codebase sweep was the easy part. Eleven files — environment config, routing fallbacks, Supabase auth callbacks, login placeholders, the database seeder. Find and replace, verify nothing hardcoded survived, commit. Three minutes of work that touches every layer of the stack because a domain name isn't just a URL. It's embedded in email addresses, OAuth redirect URIs, display text, and seed data. Change it in one place and miss another, and you get a login page that sends you to a domain that no longer answers.
Forge renamed the site directory automatically when the primary domain changed — /home/forge/harberforatoka.com/ — which is either helpful or terrifying depending on whether your deploy scripts use absolute paths. The aliases got updated to cover all six campaign domains plus the basecamp subdomains. Six domains, one site, one candidate, one message.
The SSL certificate is where things got interesting. The original twelve-domain Let's Encrypt cert had been wiped during a Cloudflare token rollover — a side effect nobody expected. Re-issuing it required temporarily disabling Cloudflare's proxy on every domain so the ACME HTTP-01 validation could reach the server directly. Cloudflare proxies the request otherwise, and Let's Encrypt can't verify you own the domain if it's talking to Cloudflare's edge instead of your server. Single-domain certs work fine through the proxy. Multi-domain certs don't. That distinction lives nowhere in the documentation. It lives in the forty minutes you spend figuring out why validation keeps failing.
Two bugs surfaced during the migration. File downloads were throwing 500 errors because Supabase's S3 implementation fails on HEAD requests — the standard way Laravel checks if a file exists before streaming it. The fix: skip the existence check, use readStream() directly, and pull the file size from the database record instead. The Resend webhook controller was referencing columns that didn't exist in the migration — message_id and raw instead of resend_id. The kind of mismatch that happens when a controller and a migration are written in different sessions by different contexts.
Email routing spread across four alias domains through Cloudflare — john@ on each one forwarding to John's inbox. The primary domain keeps its Resend MX records for the campaign inbox. Stale MX records on the old primary got deleted. A ghost zone that was never fully transferred got removed.
Three commits. A domain swap, a file download fix, a webhook schema alignment. The kind of session that looks like plumbing from the outside but feels like defusing a bomb from the inside — because every wire connects to something, and you don't know which ones are live until you touch them.