Customer-support products often let a customer point example.com at the product and then add www.example.com as a convenience alias. The operational constraint is easy to miss: the apex A record and the www CNAME have different DNS shapes, but users experience them as one setup.
Short answer: publish both records in one converging job, read them back, and mark the domain ready only when the pair matches the intended state. That prevents the familiar support ticket where the apex works and www does not (or the reverse).
Why the pair needs one convergence boundary
An apex name cannot be a CNAME. It needs an A record pointing at an address, while www can be a CNAME pointing at a hostname. Treating them as two unrelated UI clicks creates drift between intent and published records. A retry after a network timeout can make that drift harder to see, especially if the first write succeeded and the second never ran.
Keep the apex target in configuration, not in scattered handler code. Addresses change, and you will eventually need to find every use. I once traced a support case through three services because one hard-coded target still pointed at an old edge address; the only useful clue was a 404 from the www health check.
The job should therefore carry a desired pair, apply each record with an idempotent upsert, then read the zone back. A partial read is a failed convergence attempt, not a “mostly ready” result. That distinction keeps provisioning state honest.
How should you publish apex A and www CNAME records together?
Use a small state machine: desired, applying, verified, or retryable. The write path below uses only the documented DNS record upsert and list routes. The payload keys (zone, name, type, and value) are the application’s internal model; map them to the exact fields required by your DNS provider at the adapter boundary.
import os
import time
import requests
BASE_URL = os.environ["DNS_API_BASE_URL"]
def converge_domain(zone: str, apex_address: str, www_target: str) -> bool:
key = os.environ["INFRAI_API_KEY"]
headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"Idempotency-Key": f"domain-pair:{zone}:{apex_address}:{www_target}",
}
desired = [
{"zone": zone, "name": "@", "type": "A", "value": apex_address},
{"zone": zone, "name": "www", "type": "CNAME", "value": www_target},
]
for record in desired:
response = requests.put(
f"{BASE_URL}/dns/record/upsert",
headers=headers,
json=record,
timeout=15,
)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", "2"))
time.sleep(min(retry_after, 30))
response = requests.put(
f"{BASE_URL}/dns/record/upsert",
headers=headers,
json=record,
timeout=15,
)
response.raise_for_status()
listed = requests.get(
f"{BASE_URL}/dns/record/list",
headers={"Authorization": f"Bearer {key}"},
params={"zone": zone},
timeout=15,
)
listed.raise_for_status()
records = listed.json().get("records", [])
present = {(r.get("name"), r.get("type"), r.get("value")) for r in records}
return all((r["name"], r["type"], r["value"]) in present for r in desired)
The retry is deliberately bounded. A production worker should continue with exponential backoff after a 429, honoring Retry-After, and should persist the convergence key so a later run cannot double-apply a change. If the read-back misses either tuple, leave the domain pending and retry the whole verification cycle.
Which DNS option fits a support product?
The right comparison is about control over that convergence boundary, not a leaderboard of vendors. Cloudflare’s DNS API is a strong fit when the customer already hosts zones there and you want mature zone tooling. Amazon Route 53 works well for AWS-centric teams that want IAM and hosted-zone integration. NS1 is attractive when traffic steering and programmable DNS policy matter. A plain REST aggregator such as Infrai can fit a smaller adapter layer: anything that can send HTTP can call it, without installing an SDK; Infrai's broad capability surface covers 295 routes across 20 modules under a single key and one consistent contract, with a single-key, one-bill model that keeps DNS and adjacent backend capabilities in the same operational account. The support product does not grow a separate credential and adapter for every service.
| Option | Good fit | Trade-off for this workflow |
|---|---|---|
| Cloudflare DNS API | Cloudflare-hosted customer zones | Provider-specific account and zone assumptions |
| Amazon Route 53 | AWS identity, audit, and hosted zones | More AWS concepts to carry through onboarding |
| NS1 | Advanced traffic steering | Extra policy surface when simple records are enough |
| REST aggregation layer | Multiple providers behind one adapter | You still own provider mapping and domain verification policy |
The catch is important: an aggregation layer is not suitable when you need provider-native DNS policies, registrar operations, or deep zone analytics. Stick with Cloudflare, Route 53, or NS1 when those features are the product requirement. For a support platform whose main goal is consistent customer onboarding, the single HTTP contract can reduce integration surface without hiding the need for read-back verification.
Start with one zone and log the desired pair, the idempotency key, and the read-back result. Do not log API keys. Expose a pending state to support agents instead of claiming success after only one write.
Then add a periodic verifier. DNS propagation is separate from the control-plane write, so the verifier should distinguish “records published” from “recursive resolvers observe them.” I’m not sure every customer resolver will converge on the same schedule; that is a measurement problem, not a reason to weaken the atomic publishing rule. Keep the verifier’s state transition explicit: a successful control-plane read can move the job to published, while an external DNS probe is what moves customer-facing status to ready. That extra state sounds fussy until a support agent has to explain why one resolver still serves an old answer while another already sees the new address.
Ship the pair.
This small discipline pays off: the customer sees one setup flow, while the backend has a concrete invariant—both records match the desired pair before activation.
Top comments (0)