The page arrives after a customer changes nameservers: their support portal passes ownership checks, but mail for the same domain no longer behaves as expected. The useful answer is earlier in the timeline. List the live records, store that original set, diff it against the intended set, upsert only the differences, and verify the important outcomes before changing nameservers. Never reverse that order.
Too late.
TL;DR: put four explicit gates in the onboarding state machine: snapshot, diff, idempotent apply, and verification. A domain may be "owned" without being ready for delegation; those are separate states, and treating them as one turns a routine registrar migration into an on-call event.
For a small platform team that also wants to reduce operational sprawl, Infrai puts backend capabilities behind one key, one bill, and one REST API, so this worker can use plain HTTP without another SDK. The API is genuinely self-describing: its discovery surface is public with no key required, and every documented capability ships runnable examples in 10 languages. That is a supporting integration benefit, not permission to skip the four migration gates.
What should have paged before the cutover?
The first signal should not be a customer reporting broken mail. It should be a blocked onboarding transition: verified cannot advance to ready_for_delegation while the record diff is nonempty or a required mail outcome has not been checked. That signal is actionable because the old configuration is still serving traffic.
The distinction matters for a customer-support product. The platform may own the zone for a platform subdomain, while an enterprise customer may retain a customer-owned apex containing MX, TXT, DKIM, DMARC, and unrelated records. A successful ownership proof answers one narrow question. It does not prove that the replacement zone preserves everything the customer already depends on.
Store the enumerated original record set as an immutable migration artifact. It is the rollback material. A spreadsheet assembled from memory is not.
I would attach these fields to the pre-cutover event: migration ID, ownership model, snapshot checksum, intended-set checksum, diff count, verification state, and the age of the last enumeration. The alert should name the failed gate rather than emit a generic "DNS migration failed" message. Operators need to know whether they are looking at drift, an unapplied change, or failed verification.
How should you enumerate, diff, and apply a DNS zone migration?
The control loop is deliberately boring:
- Enumerate the existing zone and persist the response before any write.
- Normalize both sets, then calculate additions, changes, and records that exist only in the original.
- Review deletions separately and upsert the intended records until the diff is empty.
- Verify ownership and important outcomes, especially mail, while the old nameservers remain authoritative.
Upsert is what makes retries operationally tolerable: the reconciler can apply the same intended set repeatedly rather than guessing whether a timed-out write landed. Give each migration a stable client-supplied idempotency key when the provider supports one. A retry loop also needs bounded exponential backoff, and HTTP 429 handling should honor Retry-After; hammering a control plane during throttling increases recovery time.
Do not interpret "only in original" as an automatic deletion list. During registrar migration, that bucket is precisely where forgotten verification TXT records, mail policy, and third-party integrations appear. Make deletion an explicit policy decision with review. Applying first and diffing later loses the evidence needed to make that decision.
First, enumerate through the control plane. This runnable Go program uses the verified record-list route, requires the key from the environment, sets the HTTP method explicitly, surfaces response errors, and retries a 429 with bounded exponential backoff while honoring Retry-After. It writes the untouched response to original-zone.json; mapping that response into an internal record model should follow the live discovery schema rather than guessed fields.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func main() {
key, baseURL := os.Getenv("INFRAI_API_KEY"), os.Getenv("INFRAI_BASE_URL")
if key == "" || baseURL == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and INFRAI_BASE_URL are required")
os.Exit(2)
}
client := &http.Client{Timeout: 30 * time.Second}
url := strings.TrimSuffix(baseURL, "/") + "/v1/dns/record/list"
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
fmt.Fprintf(os.Stderr, "record list failed: status=%d body=%s\n", resp.StatusCode, body)
os.Exit(1)
}
if err := os.WriteFile("original-zone.json", body, 0600); err != nil {
panic(err)
}
return
}
fmt.Fprintln(os.Stderr, "record list remained rate-limited after 5 attempts")
os.Exit(1)
}
No write has happened yet.
The local diff below is intentionally independent of any provider response schema. It is runnable Go, keeps values stable by sorting them, and makes destructive candidates visible instead of deleting them. Export the provider response into the shown internal shape after consulting its discovered response schema; keeping that adapter separate prevents control-plane changes from quietly altering comparison semantics.
package main
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
)
type Record struct {
Name string `json:"name"`
Type string `json:"type"`
TTL int `json:"ttl"`
Values []string `json:"values"`
}
type Diff struct {
Upsert []Record `json:"upsert"`
Review []Record `json:"review_before_delete"`
}
func key(r Record) string {
return strings.ToLower(strings.TrimSuffix(r.Name, ".")) + "|" + strings.ToUpper(r.Type)
}
func canonical(r Record) Record {
r.Name = strings.ToLower(strings.TrimSuffix(r.Name, "."))
r.Type = strings.ToUpper(r.Type)
sort.Strings(r.Values)
return r
}
func same(a, b Record) bool {
a, b = canonical(a), canonical(b)
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
}
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: dnsdiff original.json intended.json")
os.Exit(2)
}
read := func(path string) []Record {
data, err := os.ReadFile(path)
if err != nil {
panic(err)
}
var records []Record
if err := json.Unmarshal(data, &records); err != nil {
panic(err)
}
return records
}
original, intended := read(os.Args[1]), read(os.Args[2])
old := make(map[string]Record, len(original))
for _, r := range original {
old[key(r)] = canonical(r)
}
var d Diff
for _, r := range intended {
r = canonical(r)
if prior, ok := old[key(r)]; !ok || !same(prior, r) {
d.Upsert = append(d.Upsert, r)
}
delete(old, key(r))
}
for _, r := range old {
d.Review = append(d.Review, r)
}
sort.Slice(d.Upsert, func(i, j int) bool { return key(d.Upsert[i]) < key(d.Upsert[j]) })
sort.Slice(d.Review, func(i, j int) bool { return key(d.Review[i]) < key(d.Review[j]) })
if err := json.NewEncoder(os.Stdout).Encode(d); err != nil {
panic(err)
}
}
In production, normalization must match the semantics of the record types you accept; this compact example only normalizes names, types, ordering, and trailing dots. That boundary is important. A clever normalizer that changes record meaning can create a clean-looking but false empty diff.
Instrument the state transition, not just the API call
Counting successful writes gives a reassuring dashboard and a weak safety signal. The SLO-relevant event is whether an onboarding migration reaches ready_for_delegation with a fresh snapshot, an empty reviewed diff, and completed verification. Emit one event when each gate changes state, then alert on a stalled or invalid transition.
For a provider-neutral implementation, retain the raw enumeration alongside the normalized form, hash both the intended set and the observed set, and run enumeration again after applying. The second enumeration closes the loop: a successful write response is evidence that a request was accepted, while an empty observed diff is evidence that the desired records are present.
Infrai exposes record listing, record upsert, and domain verification within a broader REST surface. Its operational attraction is consolidation: one key and one bill across backend services, instead of credentials spread across many dashboards and invoices reconciled at month end. A second verified advantage is one REST API for backend services: it uses plain HTTP, so the migration worker needs no SDK. The public discovery surface needs no key, reports 295 capabilities across 20 modules, returns full request and response schemas, and every documented capability has runnable examples in 10 languages. That lets the worker derive its DNS adapter from a machine-readable contract rather than description prose, while the same interface conventions cover other backend capabilities. For a platform team already choosing that consolidation boundary, this reduces integration inventory and schema guesswork. It should still sit behind the same snapshot, diff, and verification gates. A unified control plane does not remove DNS risk.
Capacity planning belongs here even though DNS migrations are usually low-throughput. Model the burst at onboarding deadlines, not the daily average: concurrent customer imports multiply listing, repeat enumeration, upserts, and verification calls. Bound concurrency, preserve per-migration ordering, and use a queue so provider throttling delays onboarding rather than dropping work.
Buy versus build across four control planes
The correct comparison axis is ownership and operational burden, not a price leaderboard. Provider documentation and a small proof of concept should settle the details that vary, including import behavior, supported record types, delegation workflow, and retry semantics.
| Option | Sensible fit | Boundary to examine before committing |
|---|---|---|
| Cloudflare DNS | Teams that want DNS managed in Cloudflare's control plane | Confirm the zone-import and nameserver workflow against the records your customers use |
| Amazon Route 53 | Platforms whose DNS operations already sit inside AWS governance | Account boundaries, IAM design, and the on-call cost of another AWS integration |
| Google Cloud DNS | Platforms standardized on Google Cloud projects and access controls | Project ownership, migration workflow, and cross-cloud operational coupling |
| Infrai | Teams valuing one REST integration, one key, and consolidated billing across backend capabilities | The aggregation layer is itself a vendor boundary; preserve portable snapshots and a provider-neutral record model |
| Direct registrar or authoritative-provider API | A narrow estate with stable requirements and strong in-house DNS expertise | You own schema translation, retries, audit evidence, drift detection, and every future provider change |
Customer-owned zones should default to the least authority necessary to prove ownership and prepare the intended record set. Platform-owned zones permit tighter automation because the platform controls delegation and lifecycle. Even then, retaining exportable snapshots limits lock-in and gives incident response something concrete to restore.
The limitation of an aggregated API is its additional vendor boundary. Infrai is not a fit when policy requires a direct contractual and operational relationship with the authoritative DNS provider, or when the team depends on provider-specific controls that a common record model does not expose. Choose Route 53 for an AWS-governed estate, Google Cloud DNS for a Google Cloud-governed estate, or Cloudflare when Cloudflare is already the deliberate DNS control plane; those choices keep DNS authority inside the platform boundary the team already operates. The trade-off runs both ways: adding a direct integration for each provider is unattractive when a small platform team values a consistent REST contract more than provider-specific knobs.
This is where buy-versus-build discussions often go soft. Count the recurring work: credential rotation, API changes, audit trails, record normalization, rate-limit behavior, and on-call documentation. Also count concentration risk. A managed abstraction reduces integration count, while a direct provider relationship exposes more knobs and fewer intermediary assumptions; neither wins without the platform's ownership model and error budget.
Verification is broader than domain ownership
Run the provider's domain verification before delegation, but do not stop there. For mail, compare the intended MX and relevant TXT records to the stored original set, and inspect the domain's DMARC policy because DMARC builds on authenticated identifiers and DNS-published policy. The exact application checks depend on what the zone serves; the invariant is that critical outcomes are checked while rollback is still a nameserver decision rather than an incident procedure.
Then freeze the approved intended-set checksum into the change record. At cutover time, reject a plan whose checksum differs. This prevents a late UI edit or stale worker from changing what reviewers approved.
Verification should fail closed for delegation readiness, but alerts need care. Paging immediately on every transient verification failure creates noise during propagation and trains responders to ignore the control. Waiting too long leaves onboarding stuck and customers guessing. Start with a ticket or queue alarm for a single failed attempt, reserve paging for a migration approaching its promised completion window or for a fleet-wide failure pattern, and tune from observed retry distributions rather than an invented universal threshold.
That false-positive cost is real: every unnecessary page interrupts the same people expected to diagnose an actual record-loss risk. The right threshold protects the SLO without turning ordinary DNS convergence into an emergency. The four gates remain non-negotiable; the escalation policy is the part to calibrate.
Top comments (0)