Skip to main content

Hetzner DNS Makes the RRSet TTL Required on September 30: Audit Your DNS Automation Before Updates Start Failing

9 min readDora NodaDora Noda
Share
On this page

On September 30, 2026 — twelve days from today — a small Hetzner Cloud API deprecation becomes a hard error, and any DNS automation that updates an RRSet's TTL without saying what the TTL should be will start failing. The change touches exactly one endpoint and one field. But if your platform programs DNS on behalf of tenants — external-dns reconciling custom domains, migration glue importing zones, scripts that reset TTLs — that one field sits directly in the path of every tenant domain change that also touches a TTL.

Here is the short version up front, because the deadline is the story:

  • What breaks: POST /zones/{id_or_name}/rrsets/{rr_name}/{rr_type}/actions/change_ttl with the ttl key omitted or empty. Starting September 30, that request fails. Sending an explicit number or explicit null (Zone default) keeps working.
  • Already safe: the official external-dns Hetzner webhook at v0.3.2 or newer, maintained community forks, current Terraform hcloud provider versions, and the hcloud CLI's change-ttl with --ttl or --unset.
  • Must audit: hand-rolled curl scripts, migration tooling, and any in-house code that calls change_ttl — anything that relied on "omit the TTL and the API figures it out."
  • Out of scope: DNS-01 challenge solvers (they create and delete TXT RRSets; they don't call change_ttl), and RRSet creates and record updates, which are untouched by this deprecation.

The rest of this post is the audit: what "required" actually means, a caller-by-caller blast-radius map with the exact request body each one sends, and a 20-minute procedure to prove your own automation is compliant before the cutoff.

What "required" actually means

The source is Hetzner's July 8, 2026 API changelog entry: "Omitted TTL for changing an RRSet's TTL is deprecated." The rule, effective September 30, 2026:

When updating the TTL of an RRSet, the value of ttl is no longer optional and must be provided. Set it to the desired TTL, or to null to use the Zone's default TTL. Leaving the value empty will cause the request to fail.

Three things to notice about that wording. First, null is a first-class answer, not a loophole: it explicitly means "inherit the Zone's default TTL," and every Zone carries its own default (itself readable and changeable through the separate zone-level change_ttl action). Second, the failure mode is the request failing — not a silent default, not a warning header. In a reconciler like external-dns, that surfaces as a sync error on every loop until someone notices the TTL drift. Third, the scope is genuinely narrow: only the RRSet change_ttl action. Creating an RRSet without a TTL still inherits the Zone default, and set_records / add_records never carried a TTL in the first place.

There is also a scoping subtlety inside external-dns worth knowing before you panic: the webhook only calls change_ttl when an endpoint's TTL actually changed between the old and new desired state. Steady-state syncs with stable TTLs never touch the endpoint at all. So the blast radius is TTL-change events — a TTL annotation added, changed, or removed; a provider default flipped; a migration rewriting records — not every reconciliation loop.

Blast-radius map: which callers break

I read the code paths rather than guessing. Every row below states the exact body the caller sends when no TTL is configured, because that body is the entire question.

CallerBody sent with TTL unconfiguredVerdict
Official webhook hetzner/external-dns-hetzner-webhook ≥ v0.3.2{"ttl": null}Safe
Official webhook < v0.3.2 (before PR #69){"ttl": 0}Unsafe — upgrade
Maintained community fork (bulk-mode lineage)Skips the TTL update; fills Zone default when a value is neededSafe
Terraform hcloud provider, current zonerrset resourceUnset plan TTL serializes to {"ttl": null}Safe
hcloud CLI zone rrset change-ttlRequires exactly one of --ttl / --unsetSafe
Hand-rolled curl / scripts / migration glue omitting ttl{} — key absentBreaks Sept 30
DNS-01 solvers (cert-manager webhooks, libdns-based)Never call change_ttlOut of scope — confirm yours doesn't
Other-language SDKs, Ansible roles, pinned old librariesVariesAudit per the procedure below

A few notes on how those verdicts were reached. The official webhook's January 2026 fix ("unset ttl when not configured," PR #69, released in v0.3.2) changed the unconfigured path from sending {"ttl": 0} to leaving the option unset — and hcloud-go serializes an unset TTL pointer as explicit null, a body the webhook's own test suite pins with require.JSONEq(t, '{ "ttl": null }', ...).

That is precisely the compliant form, so anything at or past v0.3.2 is already on the right side of the deadline. Versions before it were already sending a degenerate zero TTL, which is its own reason to upgrade.

The community fork lineage took the other compliant route: when the endpoint carries no TTL, it doesn't issue a TTL update at all, and where a value is required it substitutes the Zone's own default before calling. The Terraform provider's zonerrset resource only invokes ChangeRRSetTTL when the planned TTL differs, and a null plan produces the same nil-option → null body.

The CLI makes omission structurally impossible by requiring one of the two flags.

The genuinely unknown row is the long tail: Ansible's community.dns Hetzner modules, the PHP/Python/Rust SDKs, and whatever internal wrapper your team wrote two years ago. This class is real — the PHP SDK shipped a "fix handling of nullable TTL on RRSet" release precisely because null-vs-omitted handling is where every SDK in this ecosystem has had a bug. You settle each of these with the capture step in the audit below, not by assuming.

The 20-minute audit

Four steps, in order. Do them against a non-production zone first, then repeat step 3 for every tenant zone.

Step 1: Inventory every caller of the endpoint. Search your automation repos and your cluster for anything that can issue an RRSet TTL change:

bash
grep -rn "change_ttl\|ChangeRRSetTTL\|change-ttl" \
  --include='*.go' --include='*.py' --include='*.js' \
  --include='*.sh' --include='*.tf' --include='*.yaml' .
kubectl get deployments -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{" "}{range .spec.template.spec.containers[*]}{.image}{" "}{end}{"\n"}{end}' \
  | grep -i hetzner

The first command finds code; the second finds what's actually deployed, including the external-dns webhook image tag. If that tag is older than v0.3.2, your fix is a webhook upgrade — do that first and you're nearly done.

Step 2: Capture what each caller sends when TTL is unset. For Go callers built on hcloud-go, read the call site: a nil TTL pointer in ZoneRRSetChangeTTLOpts serializes to {"ttl": null} (compliant), an explicit int sends that number (compliant), and only a hand-built body that drops the key is dangerous. For raw-HTTP callers — curl in a script, a Python requests.post with a conditionally-built dict — replay one call against a scratch zone and print the body. The grading rule is binary: the key is present with a number or null (pass), or the key is absent or empty (fail — fix before September 30).

Step 3: Record what null resolves to in every tenant zone. This is the step teams skip: null means "the Zone's default TTL," and that default is per-zone. A tenant zone with a short default and one with a long default behave very differently the moment a reconciler starts writing null where it used to write numbers. List them:

bash
curl -s -H "Authorization: Bearer $HCLOUD_TOKEN" \
  https://api.hetzner.cloud/v1/zones | jq -r '.zones[] | "\(.name) default TTL: \(.ttl)"'

If any zone's default surprises you, set it deliberately with the zone-level change_ttl action before the deadline, so post-cutoff null writes land on a value you chose rather than one you inherited.

Step 4: Prove it end to end. Pick a scratch RRSet and issue both compliant bodies directly, confirming each returns a successful action:

bash
curl -s -X POST -H "Authorization: Bearer $HCLOUD_TOKEN" \
  -H "Content-Type: application/json" \
  https://api.hetzner.cloud/v1/zones/example.com/rrsets/www/A/actions/change_ttl \
  -d '{"ttl": 300}' | jq -r '.action | "\(.command): \(.status)"'
curl -s -X POST -H "Authorization: Bearer $HCLOUD_TOKEN" \
  -H "Content-Type: application/json" \
  https://api.hetzner.cloud/v1/zones/example.com/rrsets/www/A/actions/change_ttl \
  -d '{"ttl": null}' | jq -r '.action | "\(.command): \(.status)"'

Then trigger one real TTL change through your own automation — annotate a test ingress with a TTL, change it, remove it — and watch the reconciler logs for a clean sync. If your external-dns path is green and your hand-rolled callers all emit the key, you are done.

The fix pattern

If step 2 found a caller that omits the key, the fix is a two-line change in shape, not a redesign. There are exactly two compliant bodies:

json
{ "ttl": 300 }
{ "ttl": null }

Pick per call site, deliberately. Use an explicit number anywhere the TTL is load-bearing: short TTLs on records your custom-domain flow churns (verifications, ACME-adjacent records, blue-green cutovers), where a stale cache directly delays tenant onboarding. Use null where the record should track the zone — apex records, stable infrastructure names — and only after step 3 told you what each zone's default actually is. What you must never send after September 30 is the key missing, an empty string, or a zero: omission becomes an error, and zero was never a meaningful TTL.

One design note for PaaS builders: this deprecation quietly rewards making the Zone default a conscious per-tenant setting rather than an accident of zone creation. If your provisioning flow creates a zone per tenant (or per custom domain), set its default TTL in the same transaction that creates the zone. Every future null from every reconciler then inherits a value your platform chose, and the "what does null resolve to" question has one answer instead of N.

Same-day bonus: the dns_ptr deadline

The July 8 changelog carried a second deprecation with the identical September 30 deadline, and it deserves one audit line while you're here: omitting dns_ptr when updating reverse DNS — on servers, primary IPs, floating IPs, and load balancers — stops resetting the pointer and starts failing the request. The compliant forms mirror the TTL rule exactly: the desired hostname, or null to reset. If your provisioning flow touches reverse DNS (setting PTR records for tenant-facing IPs, or clearing them on teardown), grep for change_dns_ptr alongside change_ttl in step 1 and apply the same present-or-null rule. Two deprecations, one deadline, one audit.

Explicit beats implicit, on a deadline

Zoom out and this is a familiar API-maturity story: Hetzner is moving two endpoints from "absent means do the default thing" to "say what you mean — a value or an explicit null." Implicit defaults are convenient until they hide a decision the caller should have made consciously, and a DNS TTL is exactly that kind of decision: the difference between a 5-minute and a 24-hour cache is the difference between a graceful cutover and a day-long incident.

The good news is the ecosystem did its homework early. The official webhook, the Terraform provider, and the CLI all converged on explicit-null handling months before the cutoff, which tells you the compliant pattern was never controversial — just easy to miss in code nobody has touched in a year. That unmaintained glue script is the whole risk. Twelve days is plenty of time for a 20-minute audit. Run it, record your zone defaults, and September 30 becomes a non-event.

Custom-domain automation on machines you own is exactly what Bex.co is built for — push a git repo, get a running HTTPS service with TLS and DNS handled. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex