Use the delegation boundary as the decision rule, not the customer's plan tier: write the records yourself when the zone is delegated to nameservers you operate, and show copy-paste records when the domain stays at the customer's registrar. Every other question in custom domain onboarding — what the UI renders, how long the spinner spins, who gets paged — falls out of that one line.
The thing you're optimizing isn't propagation. It's cache expiry.
A domain doesn't go live when your API call returns. It goes live when the last resolver holding a stale answer lets that answer expire, and the length of that wait was decided by the TTL you published before the change, not by how fast you wrote the new record. So the flow is the same in both branches: classify the domain, pre-stage the TTLs, publish (write or show), then read the zone back from the authoritative nameservers and from a handful of public resolvers before you flip the tenant to active. The classifier is a pure function of about fifteen lines, which is why I like prototyping it in a notebook and then importing the exact same function into the worker instead of rewriting it.
Should the onboarding flow write the records, or show them for the customer to copy?
The test is ownership of the zone, and it's binary. Query the domain's NS set. If every nameserver in that set is one of yours, the customer has delegated, you hold the zone, and writing records directly is both possible and kinder. If even one NS points somewhere else, you don't hold the zone, and the honest product is a screen with exact strings and a verification check next to each one.
Mixing the two inside a single flow is where support cost explodes. A customer who has been told "we'll handle DNS for you" and then hits a screen full of TXT values has been lied to twice — once by the marketing page, once by the wizard — and the ticket that follows is never about DNS. State the branch up front, on the screen where the domain is entered, before either path starts.
There's a structural trap on the copy path that no amount of UI polish fixes: a CNAME can't coexist with other data at the same name (RFC 1912 §2.4), so if you ask a customer to put a CNAME at the apex of a domain that already carries MX and TXT records, you're asking for something the standard forbids. Route the customer to a subdomain instead, and keep the apex for mail.
Three steps that keep propagation delay off the critical path
Step one is classification plus pre-staging. The moment a domain is entered, lower the TTL on any record you're about to change to 300 seconds, and wait out one old-TTL period before touching values. Skipping this is the single most common reason a "fast" cutover takes an afternoon.
Step two is publishing, which is where the branch finally matters:
from dataclasses import dataclass
OUR_NS = {"ns1.platform.example.", "ns2.platform.example."}
@dataclass(frozen=True)
class Record:
name: str
rtype: str
value: str
ttl: int = 300
def onboarding_plan(domain: str, delegated_ns: set[str]) -> tuple[str, list[Record]]:
"""Return ('write' | 'show', records) for one tenant domain."""
records = [
Record(f"app.{domain}", "CNAME", "edge.platform.example."),
Record(f"_acme-challenge.app.{domain}", "CNAME", f"{domain}.acme.platform.example."),
Record(f"mail._domainkey.{domain}", "TXT", "v=DKIM1; k=rsa; p=MIIBIjAN..."),
]
mode = "write" if delegated_ns and delegated_ns <= OUR_NS else "show"
return mode, records
Step three is the part teams skip, and it's the one that earns the trust: read the zone back instead of trusting your own intent. Two different questions hide inside "is it live yet" — whether the record exists in the zone at all, and whether the world can see it yet — and they have completely different answers in the UI.
import dns.exception
import dns.message
import dns.query
import dns.rdatatype
import dns.resolver
PUBLIC_RESOLVERS = ["1.1.1.1", "8.8.8.8", "9.9.9.9"]
def authoritative_ips(zone: str) -> list[str]:
names = [rdata.target.to_text() for rdata in dns.resolver.resolve(zone, "NS")]
return [rr.address for name in names for rr in dns.resolver.resolve(name, "A")]
def seen_at(server_ip: str, record: Record) -> bool:
query = dns.message.make_query(record.name, dns.rdatatype.from_text(record.rtype))
try:
answer = dns.query.udp(query, server_ip, timeout=3.0)
except dns.exception.Timeout:
return False
wanted = record.value.rstrip(".").lower()
return any(
wanted in rr.to_text().rstrip(".").lower()
for rrset in answer.answer
for rr in rrset
)
def status(zone: str, record: Record) -> str:
if not all(seen_at(ip, record) for ip in authoritative_ips(zone)):
return "not_published" # the zone is wrong: tell the customer which string is missing
if all(seen_at(ip, record) for ip in PUBLIC_RESOLVERS):
return "live"
return "propagating" # correct but cached elsewhere: show a clock, not instructions
That runs on dnspython 2.7+, and the three return values map onto three different screens. not_published is the only state where the customer should see the record strings again. propagating deserves a countdown and nothing else — re-showing instructions there is how you get a customer to "fix" a record that was already correct.
Keep a fixture file of a dozen real-world zone shapes (apex CNAME attempt, wildcard, split-horizon, a zone with an existing SPF record near the lookup limit) and run the classifier against it in CI. It's the same instinct as an eval set for a model: the classifier is cheap, the failure is expensive, and regressions arrive quietly.
What the copy path owes the customer beyond prettier instructions
Verification is the product on this path. Instructions are just the input to it.
The failure mode that bites hardest is negative caching. If your UI polls for a record before the customer has pasted it — or worse, offers a big "Check now" button on the screen where the instructions first appear — every resolver in the path learns that the name doesn't exist, and RFC 2308 lets them keep remembering that absence for the zone's negative TTL, commonly 3600 seconds. The customer does everything right and still sits in a broken state for an hour, which reads to them as your bug. Poll authoritative nameservers on a short interval, poll public resolvers slowly, and don't let a manual button query anything except the authoritative set.
Mail makes the copy path slower in a way that has nothing to do with DNS speed. If you send on behalf of the customer's domain, DMARC requires that an SPF or DKIM pass authenticate a domain aligned with the one in the From header (RFC 7489 §3.1), so the records that matter live inside a zone you don't control, and they have to be right before the first campaign, not eventually. Adding your include: to an existing SPF record can also push it past the ten-DNS-lookup ceiling in RFC 7208 §4.6.4, and the resulting permerror looks like a mail outage while every record you asked for is present and spelled correctly. Check the lookup count in the UI before you tell anyone to save.
Moving tenant zones off a registrar's own API, without freezing signups
The B2B SaaS version of this problem is a migration: hundreds of tenant zones sitting behind a registrar's own record API, one that models records as mutable IDs rather than as desired state, offers no upsert, and rate-limits per account rather than per zone. Onboarding code tends to absorb all three quirks, and the absorption is invisible until you try to leave.
| Write path (delegated zone) | Copy path (customer-held zone) | |
|---|---|---|
| Who fixes a typo | your worker, one call | the customer, in their hours |
| Clock starts | when the write is acknowledged | when the last record is pasted |
| Wait dominated by | old TTL, then negative cache | human latency, then TTL |
| Rollback | re-apply the previous values | an email and a support ticket |
| UI must show | zone state read back | exact strings plus a per-record check |
The migration itself is unglamorous. Put an adapter in front of both providers with two methods — read the zone as a set of records, apply a desired set — and let the onboarding code talk only to that. Dual-write for a while and diff the two zones on a schedule; disagreements are how you discover which quirks your code was quietly relying on. Only then move the delegation, and leave the old zone serving until the parent NS TTL expires, which for many TLDs is 172800 seconds. Deleting the old zone early is a self-inflicted outage that no rollback fixes.
Zone-as-code tools like octoDNS and DNSControl solve the adjacent problem well — a fixed set of zones under version control, reconciled from a repo. The catch is that per-tenant zones created at signup don't fit a commit-and-deploy loop, so most teams end up with the library for the platform's own zones and an API path for tenant zones. I'm not sure that split ever gets elegant; it does stay honest.
The checklist I'd hand to whoever is on call
Before a cutover: confirm which branch the domain is in and show it, lower TTLs a full old-TTL period ahead, snapshot the current zone as records rather than as a screenshot. During: write or show, never both, and log the exact strings you rendered so a support reply can quote them. After: poll authoritative first and public resolvers second, keep the two states visually distinct, and hold the old zone until the parent delegation TTL has passed. The metric worth watching isn't average time-to-live-domain — it's the share of domains that needed a human message after the wizard said "done", because that number is the one your support load actually tracks.
Instrument the three states, alert on not_published lasting longer than a day, and let propagating be boring.
References
- RFC 7489 — Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
- RFC 7208 — Sender Policy Framework (SPF), §4.6.4 DNS lookup limits: https://datatracker.ietf.org/doc/html/rfc7208
- RFC 2308 — Negative Caching of DNS Queries: https://datatracker.ietf.org/doc/html/rfc2308
- RFC 2181 — Clarifications to the DNS Specification: https://datatracker.ietf.org/doc/html/rfc2181
- RFC 1912 — Common DNS Operational and Configuration Errors: https://datatracker.ietf.org/doc/html/rfc1912
- RFC 8555 — Automatic Certificate Management Environment (ACME): https://datatracker.ietf.org/doc/html/rfc8555
- dnspython documentation: https://dnspython.readthedocs.io/en/stable/
Top comments (0)