Skip to content

Migrating an Apex-CNAME Domain to deSEC: Rejected Records and the minimum_ttl Trap

The Problem

A domain whose apex is a CNAME (very common when a vanity domain points at a main site) cannot be moved to deSEC as-is. The zone looks trivially small — a couple of records — and the migration looks like a copy job right up until the API starts refusing records, or worse, until it silently starts serving a value that goes stale weeks later.

Three distinct surprises, in the order you hit them.


1. deSEC refuses an apex CNAME, and has no ALIAS

Namecheap's FreeDNS serves a CNAME at the zone apex. This is non-standard — RFC 1034 says a CNAME cannot coexist with other records at the same name, and an apex always has SOA and NS. deSEC enforces the standard. From their API docs, verbatim:

a CNAME is not allowed at the zone apex (empty subname), as it would always collide with the NS record (and the internally managed SOA record)

And the usual escape hatch is closed too — ALIAS / ANAME are unsupported, returning 400 Bad Request. deSEC suggests the HTTPS record type instead, which they themselves caveat as not universally supported by browsers.

So the apex must become a real A record. Which leads directly to the next problem.

2. Copying the target's current IP is a silent time bomb

The obvious substitution is to resolve whatever the apex CNAME pointed at and write that address in as an A record. This works perfectly on cutover night.

It breaks the first time the address changes — and if the target is a home-hosted site on a residential connection, that address is dynamic by design. Check before assuming it is stable:

# what is the CNAME target, and what TTL does it carry?
dig +noall +answer A target-domain.com
# a 300s TTL on an apex A is a strong hint something is updating it

A short TTL on the target's A record is the tell: nobody sets a 5-minute TTL on a static address. If a DDNS updater maintains it, a copied literal is stale the moment the ISP reassigns — with nothing watching, because from DNS's point of view the record is perfectly healthy.

Fix: point the apex at a dynamic updater of its own, so the new zone tracks the same address rather than snapshotting it. See §4.

3. minimum_ttl is 900, but the dynDNS endpoint ignores it

New deSEC zones come with a per-domain floor:

curl https://desec.io/api/v1/domains/example.com/ \
  --header "Authorization: Token $TOKEN"
# {"name": "example.com", "minimum_ttl": 900, ...}

Any RRset created through the normal REST API is rejected below that floor. It is easy — and wrong — to conclude that every record in the zone is therefore stuck at 900s, and that a domain moving off a provider with a 300s TTL is taking a downgrade.

The dynDNS update API overrides the floor

deSEC's IP-update endpoint sets its own TTL. From their docs: records updated this way get "a TTL value of 60 seconds." So the one record that genuinely needs to move fast — the dynamic apex — ends up at 60s, faster than the 300s it likely had before.

minimum_ttl only constrains statically-managed records (www, _dmarc, and so on), which do not need low TTLs anyway.

Verify after the first update, rather than trusting either claim:

dig +noall +answer A example.com @ns1.desec.io
# example.com.  60  IN  A  203.0.113.10     <-- 60, not 900: the updater wrote this

A record still showing the provisioned 900 means the updater has not run successfully yet.


4. Wiring the dynamic apex

deSEC's IP-update endpoint is a single authenticated GET, so a timer and curl are enough — no dedicated client needed.

curl -fsS --ipv4 -m 20 --retry 3 --retry-all-errors --retry-delay 3 \
  -H "Authorization: Token $DESEC_TOKEN" \
  "https://update.dedyn.io/?hostname=example.com&myipv6=preserve"
# -> good

Details that matter:

  • Token in the Authorization header, never the query string. deSEC logs request URLs for several days; the query-string form (which they document but discourage) puts the token in their logs in clear text.
  • myipv6=preserve is not optional cosmetics. deSEC warns that when you omit the IP parameters they fall back to the connection's source address — so an on-path attacker who redirects the connection gets their address written into your DNS. TLS does not help; it doesn't authenticate the IP. Pinning IPv6 also stops a v6 path silently introducing an AAAA the zone never published.
  • --retry-all-errors, not plain --retry. Plain --retry does not retry DNS resolution failures — curl exits 6 immediately and skips the retries entirely.
  • The response body is good on success (dyndns2 protocol). Check for it: a 200 with an unexpected body is not a success.

Scope the token

An account-wide DNS token sitting in a file on a Docker host is more authority than an updater needs. deSEC supports per-RRset policies — create a deny-by-default policy first, then a single allow:

# default deny (must exist before any specific policy)
curl https://desec.io/api/v1/auth/tokens/$TOKEN_ID/policies/rrsets/ \
  -H "Authorization: Token $MANAGING_TOKEN" -H "Content-Type: application/json" \
  --data '{"domain": null, "subname": null, "type": null, "perm_write": false}'

# allow exactly the apex A
curl https://desec.io/api/v1/auth/tokens/$TOKEN_ID/policies/rrsets/ \
  -H "Authorization: Token $MANAGING_TOKEN" -H "Content-Type: application/json" \
  --data '{"domain": "example.com", "subname": "", "type": "A", "perm_write": true}'

Creating tokens requires a token with perm_manage_tokens — a plain API token returns 403 on /auth/tokens/, which is the correct default and not a bug.

Do not use allowed_subnets to pin a DDNS token

deSEC can restrict a token to specific source addresses, and on a fleet host that looks like free hardening. For a dynamic DNS updater it is actively harmful: the host's address is precisely the value that changes, so the pin invalidates the token at exactly the moment the updater is needed.

A DDNS updater fails open

If the update call breaks, the process keeps running and exits fine; the record simply stops tracking reality. Nothing looks wrong until the address changes, at which point everything depending on that name goes dark at once, with no earlier signal.

Ping a dead-man switch only after a confirmed-good response, never unconditionally — a ping on every run turns the monitor into a liveness check for the timer instead of for the DNS record.


5. DNSSEC is established, not migrated

deSEC signs by default and generates a DS. If the old provider had no DNSSEC, the chain is being created for the first time — and the DS must be submitted at the registrar, which is a separate step from anything the DNS host does. Until then, dig +dnssec will not set the ad flag no matter how correct the zone is.

curl https://desec.io/api/v1/domains/example.com/ -H "Authorization: Token $TOKEN" \
  | python3 -c 'import sys,json; [print(d) for k in json.load(sys.stdin)["keys"] for d in k["ds"]]'

6. Provision early — propagation across deSEC's own nameservers is not instant

A newly created zone does not appear on all of deSEC's nameservers at once. Observed on 2026-09-07: ns2.desec.org answered for a new zone roughly 45 seconds before ns1.desec.io did, and a later record change showed the same split.

Because the zone is not delegated yet, there is no reason to create it during the cutover window. Provision it days ahead, query it directly at ns1.desec.io / ns2.desec.org to confirm every record, and let the cutover be nothing but the nameserver flip.

# validate the new zone while live traffic still uses the old provider
for ns in ns1.desec.io ns2.desec.org; do
  dig +noall +answer A example.com @$ns
  dig +noall +answer TXT _dmarc.example.com @$ns
done