A live static IP and a site nobody could reach

Title card: A live static IP and a site nobody could reach

Phase seven of the migration is the scary one. It detaches the static IP from the old server and attaches it to the new one, and from that moment the new server is what the internet sees. I’d done it plenty of times by then. The new box came up, the IP moved, the smoke test passed against the IP.

Then phase eight, the SSL certificate, failed. And when I went to look at the site in a browser, there wasn’t one. Not an error page. Nothing resolved at all.

(This is another one from moving a fleet of WordPress sites off Bitnami Lightsail onto AWS’s managed blueprint. The pillar post explains the phases if you want the map.)

What had actually happened

Here’s the embarrassing bit. The migration hadn’t broken anything. The domain had no A records. None, for the apex or for www. The zone existed at the CDN, the nameservers pointed at it correctly, and inside the zone there was nothing telling anyone where the website lived.

It had been like that before I touched it.

Which means the pipeline had spent a good while faithfully provisioning a new server, exporting the site, shipping it through S3, restoring it, verifying it against the IP and swapping the static address across, all for a site that no visitor could reach, either before or after. It did every one of those steps well. It just never asked whether anyone could find the thing.

The SSL step is where it finally surfaced, because Let’s Encrypt’s HTTP-01 challenge goes and fetches a file from your domain. No DNS, no fetch, no certificate. The failure message was about ACME. The actual problem was upstream of everything.

I rolled it back by hand and went to have a think.

Checking the box and not the road

Every check in phase zero, the preflight, was about the source server. Is it the Bitnami blueprint? Is WordPress actually installed where we think? Can we install the migration plugin? All of it true for this site. The server was fine.

What preflight didn’t check was the one assumption the last two phases depend on completely: that the site’s domain resolves to the server we’re about to replace. The IP swap only means something if DNS points at that IP. The certificate only issues if DNS points at that IP. I’d been validating the start of the pipeline against the start of the pipeline, and the end of it against nothing.

So preflight grew a DNS check.

The check

Resolve the canonical hostname and its sibling (apex and www), and classify what comes back:

function Resolve-Host([string]$name) {
    try {
        $r   = Resolve-DnsName -Name $name -Type A -Server '8.8.8.8' -DnsOnly -QuickTimeout -ErrorAction Stop
        $ips = @($r | Where-Object { $_.Type -eq 'A' } | ForEach-Object { $_.IPAddress })
        if ($ips.Count -eq 0) { return @{ status = 'no-record'; ips = @() } }
        return @{ status = 'resolved'; ips = $ips }
    } catch {
        if ($_.Exception.Message -match 'does not exist|NXDOMAIN') {
            return @{ status = 'nxdomain'; ips = @() }
        }
        return @{ status = 'error'; ips = @(); error = $_.Exception.Message }
    }
}

It asks a public resolver on purpose. My local resolver has a cache and opinions, and I don’t want the answer coloured by either.

The verdicts that came out of it, roughly in order of how often I’ve seen them:

Verdict What it means What happens
ok resolves to the source server’s IP carry on
cdn-cloudflare resolves to Cloudflare, and a request through it comes back 2xx/3xx carry on
cdn-cloudflare-origin-broken resolves to Cloudflare, but Cloudflare can’t reach the origin (hello, 530) stop, fix the origin setting
mismatch resolves to some other IP entirely stop, the site probably lives elsewhere
no-record the zone answers but has no A record stop, this is the one that got me
nxdomain the domain doesn’t exist stop

Stopping means preflight exits with a distinct code and a human-readable reason in its summary, and the batch runner logs it and moves on to the next site instead of treating it as a crash.

Two wrinkles

The CDN case. A fair chunk of the fleet sits behind Cloudflare, so the A record is a Cloudflare edge IP and will never match the server. I can’t see the origin setting from outside without access to the client’s Cloudflare account, which I usually don’t have. What I can do is check the IP against Cloudflare’s published ranges, then make a real request through Cloudflare and look at the status code. A 2xx or 3xx means Cloudflare is reaching an origin and something is serving the site. A 5xx, especially Cloudflare’s own 530, means it isn’t.

That’s not proof the origin is this server. It’s good enough to know the road exists, and the post-swap verification catches the rest.

The sibling case. I check both apex and www, but only the canonical one is allowed to fail the run. Some sites live on a subdomain like portal.example.com, and the script’s helpful guess at a sibling, www.portal.example.com, quite reasonably doesn’t exist. The sibling result goes in the log as information. The canonical decides.

There’s an escape hatch, -SkipDnsCheck, for the rare deliberate case like copying an orphaned server for archiving. With it set, phases one to six run and seven and eight fail loudly, which is what you’d want.

The general version

Preflight checks should test the assumptions of the last step, not just the first. It’s natural to write a preflight that asks “can I start?”, because that’s the question in front of you when you’re writing it. The expensive failures come from “will the ending mean anything?”, and those tend to get asked about an hour too late, with a freshly swapped IP and a site that was never reachable to begin with.

I’m still slightly amazed it had been like that for who knows how long, and nobody had mentioned it.

Leave a Reply

Your email address will not be published. Required fields are marked *