stayz3ro.dev

The DNS panel that wasn't authoritative

Phase 3 of the VPS lab was meant to be short. Put Caddy in front of Uptime Kuma, get a Let’s Encrypt cert for status.stayz3ro.dev, done. Caddy does ACME automatically. This is close to a solved problem.

Instead it turned into a night of DNS debugging, and two of the three lessons had nothing to do with Caddy.

The symptom

Caddy started, tried to issue a certificate, and failed with a real ACME error: Let’s Encrypt got NXDOMAIN for status.stayz3ro.dev.

The record existed. I’d just added it in Porkbun’s DNS panel. And querying Porkbun’s own nameservers directly returned the right answer.

The first theory, which was wrong

DNS negative caching. The record had genuinely been missing a few minutes earlier, and the zone’s negative-cache TTL was 1800 seconds, so a resolver that looked it up during that window would hold the NXDOMAIN for half an hour.

Plausible, and partly true, but not the cause, because it kept failing well past any TTL that could explain it.

The check that actually told the truth

When resolution makes no sense, stop trusting the resolver in front of you. I queried the domain over DNS-over-HTTPS (plain HTTPS to https://dns.google/resolve, which never touches port 53 and can’t be intercepted by anything local) and asked for the nameservers:

# via DoH, bypassing every local resolver
dig NS stayz3ro.dev
felipe.ns.cloudflare.com
melissa.ns.cloudflare.com

Not Porkbun’s. Cloudflare’s.

The domain’s nameservers had been switched to Cloudflare when the blog moved to Cloudflare Pages, a separate decision, in a different repo, made around the same time. Porkbun’s DNS hosting still let me log in and edit records, and its nameservers still answered when queried by name. But the internet’s chain of delegation, from registry to nameservers to zone, no longer pointed at Porkbun. Every edit I made that night went to a zone nobody was reading.

Cloudflare's DNS records for stayz3ro.dev during the debugging session:
only the apex and www CNAMEs to Pages exist, which is the zone that's
actually live, and where the status record needed to go

The fix was one record, added in the right dashboard. Caddy picked it up and issued the cert on the next retry:

Caddy access log: repeated "served key authentication certificate" for
status.stayz3ro.dev, then "certificate obtained successfully" from
acme-v02.api.letsencrypt.org

A panel that lets you edit records is not proof it’s the zone that’s delegated. dig NS <domain>, through a resolver you don’t control, tells you which nameservers the internet actually uses in one query. An unexpected answer carrying the aa (authoritative answer) flag is the signal to stop debugging your symptom and check the delegation itself.

The compounding trap

There was a reason my earlier “check against a different resolver” attempts hadn’t caught this. The workstation running every dig had Tailscale’s MagicDNS enabled, pointing the system resolver at 100.100.100.100. Tailscale can transparently intercept port-53 traffic system-wide, including an explicit dig @8.8.8.8. So “I checked against Google’s DNS too” was, unknown to me, still being answered by the same local proxy.

DoH sidesteps this because it’s just HTTPS. There’s no port 53 to intercept.

When a machine runs a VPN or a MagicDNS-style resolver, naming a different DNS server on the CLI doesn’t guarantee an independent answer. Check the resolution path itself (resolv.conf, tailscale dns status), or use DoH and skip the question.

The one that had nothing to do with DNS

While waiting on propagation, a third problem. Uptime Kuma’s first-run setup, the screen where you create the admin account, is unauthenticated by design. Whoever loads the page first claims it.

And the moment Let’s Encrypt issues a certificate for a hostname, that hostname is published in Certificate Transparency logs, which bots scan continuously. There’s a real window between “cert issued” and “admin account created” where a stranger could win the race.

The fix didn’t need rushing or exposing anything early. Docker’s bridge network means the VPS host can always reach a container directly, even with no published port:

ssh -L 3001:<container-ip>:3001 netcup-prod-01
# now http://localhost:3001 tunnels straight to Kuma, bypassing
# the public hostname, Caddy, and the cert entirely

docker compose ps: caddy with 80/443 published, uptime-kuma with only
its internal 3001/tcp, never published to the host

Admin account claimed over the tunnel before DNS had even finished propagating. From outside, the backend port stays unreachable:

curl to the VPS IP on ports 3000 and 3001: both connection
timed out

For any self-hosted service with unauthenticated first-run setup, claim the admin account over a private path (SSH tunnel, VPN, docker exec) before the public hostname resolves. Don’t plan to be faster than a bot.

Closing note

Four things I took away from the night. Confirm the delegated zone before you debug DNS propagation, with dig NS over DoH first. A VPN can intercept your dig, so verify the resolution path or use DoH. “Public” and “publicly discoverable” arrive on different timelines, DNS and cert issuance versus CT-log scanning, so secure first-run setup through a private path. And a runbook step that assumes a default needs checking against the actual config. docker compose logs caddy was silent here, because this Caddyfile logs to a file, not stdout. The evidence was in the file the whole time.