My dynamic DNS job got my home IP banned by the registrar
Blog · networking

My dynamic DNS job got my home IP banned by the registrar

288 API calls a day, an HTTP 000, and the change-detection design that dropped it to zero.

My dynamic DNS job called my domain registrar’s API 288 times a day. Every five minutes, it asked “what is the current IP on my apex record?”, whether or not anything had changed. One day in July 2026 the registrar’s firewall decided that was enough and banned my home IP. I could not even log in to the registrar’s website except through a VPN.

The fix was not a slower schedule. It was to stop asking the expensive question when the cheap one already has the answer. This post covers the ban, the change-detection design that replaced polling, and three DNS details that tripped me on the way: the apex record that cannot be a CNAME, a wildcard CNAME that hijacked my certificate challenge, and a monitor that alarmed on every successful run.

Contents · 10 sections

TL;DR #

  • 288 API calls per day; that was enough for a rate-limited registrar API to firewall-ban my home IP. The symptom was an HTTP code of zero: DNS resolved, the TCP connection never opened, while other sites worked.
  • 0 registrar calls on a stable day; the fix is change detection: check your public IP with a cheap service every few minutes, cache the last value, and call the registrar only when the IP changes.
  • When the API is unreachable or rate-limiting, the job should skip quietly and retry next cycle, never hammer.
  • The apex of a domain cannot be a CNAME, so it needs an A record kept current by your job.
  • A wildcard CNAME also covers _acme-challenge, which can send DNS validation for certificates to the wrong zone.
  • A finished Job pod is never “Ready”. Verify a DNS job by its effect: query the record and compare it with your real WAN IP.

The setup #

My home connection has a dynamic public IP. My domain’s DNS is hosted at the registrar, GoDaddy. The subdomains were simple: a wildcard CNAME points at a dynamic DNS hostname that always follows my current IP. But the bare domain, the apex, cannot be a CNAME.

That is a DNS rule, not a registrar quirk: the apex already has SOA and NS records, and a CNAME cannot coexist with other records at the same name. So the apex needs a plain A record, and an A record goes stale every time the ISP changes the IP. In early July I found mine pointing at an old address.

So I wrote a Kubernetes CronJob: every 5 minutes, read the current WAN IP, read the apex record from the registrar API, and update it if they differ. It fixed the stale record. A week later it got me banned.

The ban #

On 9 July 2026 the CronJob started failing. From inside the pod:

curl -s -o /dev/null -w 'code=%{http_code} connect=%{time_connect} total=%{time_total}\n' \
  https://api.<registrar>.com/
curl -s -o /dev/null -w 'code=%{http_code} connect=%{time_connect} total=%{time_total}\n' \
  https://api.ipify.org/

The registrar returned HTTP code 000, meaning no completed connection: DNS resolved fine, the TCP handshake never succeeded. The control site answered 200 instantly. That pattern, “everything works except one provider, and that one fails at TCP”, is what an IP-level firewall ban looks like from the client side.

The cause was arithmetic: 12 runs per hour × 24 hours = 288 API calls per day, for a small account with a handful of domains. Registrar APIs are rate-limited, and limits for small accounts are tight. I had treated a third-party API as if it were free to poll.

Step 1: stop feeding the ban #

The first move was to suspend the CronJob. A temporary ban only lifts once the traffic stops, and every retry extends it:

kubectl -n <namespace> patch cronjob <ddns-job> -p '{"spec":{"suspend":true}}'

Then an interim version: every 6 hours instead of every 5 minutes (4 calls per day), and a graceful skip. The job makes one GET, captures the HTTP code, and if the API is unreachable or rate-limiting, it exits successfully without doing anything:

code=$(curl -s -o /tmp/rec.json -w '%{http_code}' -H "Authorization: $AUTH" "$API/records/A/@")
case "$code" in
  200) ;;                                   # continue
  000|429) echo "registrar unreachable or limiting ($code): skip"; exit 0 ;;
  *) echo "unexpected $code"; exit 1 ;;
esac

The write (PUT) still fails loudly if the registrar is reachable but rejects it, because that is a real problem I want to hear about.

Step 2: change detection #

Four calls a day is safe, but it means up to six hours of a wrong apex record after an IP change. The better design separates the cheap question from the expensive action:

  1. Ask a free “what is my IP” service, every five minutes.
  2. Compare it with the last IP I wrote, cached in a tiny persistent volume.
  3. Only if it differs, call the registrar once to update the record, then advance the cache.

On a stable day the registrar is contacted zero times. After a real IP change, the record is updated within 5 minutes.

wan=$(curl -s --max-time 10 https://api.ipify.org)
last=$(cat /state/last-ip 2>/dev/null || true)

[ -z "$wan" ] && { echo "no WAN IP: skip"; exit 0; }
[ "$wan" = "$last" ] && { echo "unchanged ($wan): registrar NOT contacted"; exit 0; }

code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT \
  -H "Authorization: $AUTH" -H 'Content-Type: application/json' \
  -d "[{\"data\":\"$wan\"}]" "$API/records/A/@")

case "$code" in
  200) echo "$wan" > /state/last-ip; echo "updated to $wan" ;;
  000|429) echo "registrar unreachable ($code): retry next cycle"; exit 0 ;;
  *) echo "PUT failed: $code"; exit 1 ;;
esac

Details that matter:

  • The cache is advanced only after a successful write, so a failed update is retried on the next run.
  • The state volume needs to be writable by the job’s non-root user (I used fsGroup on a tiny volume).
  • An init container seeds the cache with the current WAN IP if it is empty. That is only safe if the record already matches, so I checked the public record against two public resolvers before enabling it. Otherwise the job would cache the new IP and never write it.

A wildcard CNAME that hijacked my certificates #

On the same day I fixed the apex record, I also set up a wildcard TLS certificate with Let’s Encrypt. Wildcards require the DNS-01 challenge; Traefik’s built-in HTTP-01 resolver cannot issue them. I used lego with the registrar’s DNS API.

Issuance failed with an “unknown domain” error from the API. The reason was my own wildcard CNAME: *.example.com also matches _acme-challenge.example.com. lego follows CNAMEs for the challenge record by default (a useful feature for delegated validation), so it followed mine into the dynamic DNS provider’s zone, where my registrar account has no control.

The fix:

  • create an explicit _acme-challenge TXT record, which takes precedence over the wildcard;
  • disable lego’s CNAME following (LEGO_DISABLE_CNAME_SUPPORT=true);
  • add a propagation wait so validation does not start before the authoritative servers have the record. On my registrar, changes reached the authoritative name servers in about 4 minutes.

One more lego detail: version 5 has no renew command. lego run obtains a certificate, or renews it when it is within --renew-days of expiry, and otherwise does nothing. That makes it ideal for a CronJob. All flags go after run.

The monitor that alarmed on success #

In August my daily digest reported POD_NOT_READY for the DDNS job. Nothing was wrong. The CronJob runs every 5 minutes and every pod showed Completed; the public apex record matched the live WAN IP exactly when checked against a public resolver.

A Job pod that has finished is 0/1 Ready by definition. Any check that counts ready containers without also checking phase=Succeeded will alarm on every successful short-lived job. The right check for a DNS job is its effect:

dig +short example.com A @1.1.1.1
curl -s https://api.ipify.org

If those two agree, the job works, whatever its pods look like.

A related one: in September a single run failed twice within its 60-second deadline, and every run after it succeeded. Kubernetes keeps the last failed Job visible according to failedJobsHistoryLimit, so a one-off failure keeps an alert alive until the next failure replaces it. I checked the effect, confirmed the record was correct, and deleted the stale failed Job.

Checklist #

  • Never poll a rate-limited third-party API on a tight schedule. Ask the cheap question often, the expensive one only when needed.
  • Make API clients skip quietly when the API is unreachable or limiting, instead of retrying into a ban.
  • Cache the last written value and advance it only after a successful write.
  • Seed caches only after verifying the current state is correct.
  • Remember the apex cannot be a CNAME; keep its A record current.
  • With a wildcard CNAME, add an explicit _acme-challenge record and disable CNAME following in your ACME client.
  • Monitor DNS jobs by effect (dig vs WAN IP), not by pod readiness.

Frequently asked questions #

Why can’t I use a CNAME on my root domain? #

The apex of a zone must have SOA and NS records, and DNS does not allow a CNAME to coexist with other records at the same name. Use an A/AAAA record kept up to date by a dynamic DNS job, or a provider feature like ALIAS/ANAME flattening if your DNS host offers it.

How often should a dynamic DNS client call the provider’s API? #

Only when your IP actually changes. Check your public IP frequently with a cheap service, compare with the last value you wrote, and call the provider only on a difference. That gives fast updates and almost no API traffic.

How do I know if an API provider has banned my IP? #

Compare a request to that provider with a request to a neutral site from the same machine. If DNS resolves but the TCP connection to the provider never completes (curl reports HTTP code 000) while the other site works, you are probably blocked at the network level. Stop the traffic and wait.

Why does my wildcard DNS break Let’s Encrypt DNS-01 validation? #

A wildcard CNAME also matches _acme-challenge.<domain>, and many ACME clients follow that CNAME to place the TXT record. If it points into a zone you cannot edit, validation fails. Create an explicit _acme-challenge record and turn off CNAME following.

The lesson #

The DDNS job was correct and still did harm, because it was designed around the question I cared about instead of the cost of asking it. Separating a cheap, frequent check from a rare, expensive action is a pattern far bigger than DNS: it applies to any sync job against an API you do not own. And when a job’s job is to change the world, check the world, not the job.