DEV Community

FinnOakley52947
FinnOakley52947

Posted on

Customer Support DNS Zone Migration with Enumerate Diff Apply and Verify

TL;DR: Treat a DNS move as a reconciliation job, not a copy button. Export the current zone, express the intended customer-support records as data, normalize both sides, review a deterministic diff, apply only that plan to the destination, and query the destination nameservers directly before changing delegation. The decisive signal is zero unexplained drift, especially around mail.

That answer adds a little code up front, but it removes configuration guesswork at cutover. For a support operation, losing a web alias is visible; silently changing MX, SPF, DKIM, or DMARC behavior can be much harder to spot. I optimize for a boring final diff.

Why isn't a successful import enough?

An import reports that an input was accepted. It does not prove that the published destination zone matches intent. Source exports can contain provider-specific shapes, relative names, trailing dots, differently ordered values, or records that should no longer exist. A green request can coexist with semantic drift.

Mail makes that gap consequential. DMARC is published as a DNS TXT record, and RFC 7489 defines both its discovery location and policy model. A migration therefore has to preserve the intended record, not merely produce some TXT record that the destination API accepts. Keep the source snapshot for evidence, but compare the destination against a reviewed manifest. The manifest is the decision.

My constraint here is stricter than "copy every record": the customer-support team needs inbound mail and authenticated outbound mail to retain their intended DNS state while the registrar-specific API disappears from the deployment path. That changes the design. The provider adapter becomes thin glue. Normalization and planning stay local, testable, and provider-neutral.

How should you enumerate, diff, apply, and verify a DNS zone migration?

Use one canonical record shape on both sides. In this example, data is already serialized by each adapter into the same zone-file-style representation. That boundary matters. TXT quoting, escaped characters, and structured MX fields should be handled by an adapter with a real parser, not by splitting arbitrary strings.

type RecordType = "A" | "AAAA" | "CNAME" | "MX" | "TXT";

type DnsRecord = {
  name: string;
  type: RecordType;
  data: string;
  ttl: number;
};

type Change =
  | { op: "create"; record: DnsRecord }
  | { op: "delete"; record: DnsRecord };

interface ZoneAdapter {
  list(zone: string): Promise<DnsRecord[]>;
  apply(zone: string, changes: readonly Change[]): Promise<void>;
}

const canonicalName = (name: string): string =>
  `${name.trim().toLowerCase().replace(/\.$/, "")}.`;

const normalize = (record: DnsRecord): DnsRecord => ({
  ...record,
  name: canonicalName(record.name),
  data: record.data.trim(),
});

const key = (record: DnsRecord): string =>
  JSON.stringify([record.name, record.type, record.data, record.ttl]);

function plan(current: DnsRecord[], intended: DnsRecord[]): Change[] {
  const have = new Map(current.map(normalize).map((r) => [key(r), r]));
  const want = new Map(intended.map(normalize).map((r) => [key(r), r]));
  const changes: Change[] = [];

  for (const [id, record] of have) {
    if (!want.has(id)) changes.push({ op: "delete", record });
  }
  for (const [id, record] of want) {
    if (!have.has(id)) changes.push({ op: "create", record });
  }
  return changes.sort((a, b) => key(a.record).localeCompare(key(b.record)));
}

async function reconcile(
  zone: string,
  destination: ZoneAdapter,
  intended: DnsRecord[],
): Promise<void> {
  const before = await destination.list(zone);
  const changes = plan(before, intended);

  if (changes.length === 0) return;
  console.table(changes);
  await destination.apply(zone, changes);

  const remaining = plan(await destination.list(zone), intended);
  if (remaining.length !== 0) {
    throw new Error(`Destination still differs by ${remaining.length} change(s)`);
  }
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally small. It gives the CLI one readable plan and one failure condition.

No giant config framework.

There is a sharp edge: the example treats TTL as part of identity, so a TTL edit appears as one deletion plus one creation. That is easy to review and portable across basic adapters, though an adapter may translate the pair into an atomic update when its API supports one. Record sets also need validation before apply: a CNAME owner cannot be treated like an arbitrary bag of unrelated values, and mail-related TXT values must survive serialization intact. Those checks belong before the first write.

The intended data should be mundane and explicit: the help-center host, the support mailbox MX set, the SPF TXT value, each DKIM selector in use, and the _dmarc TXT record. Do not infer this manifest from the destination after import. That would declare the import correct by definition.

Apply the plan without turning it into a leap

Run enumeration twice before writing. If the 2 source snapshots differ, something else is changing the zone; stop and resolve ownership instead of racing it. Then store the reviewed intent and the generated plan with the deployment artifact. This is useful evidence when a later question is "who removed that selector?"

Stop there.

Application order is a trade-off, not a universal sequence. Creating missing records before deleting obsolete ones reduces absence windows, but conflicts can make that impossible. A CNAME replacement is the obvious case. The planner should group changes by owner name and record type, let the adapter use atomic record-set operations where available, and fail closed when it cannot represent the intended transition. Never continue through a partial error and call the zone migrated. Retries need the same restraint: re-list, recalculate, and apply the remaining plan. Blindly replaying a stale batch can duplicate values or fight an operator's intervening edit. Idempotence comes from reconciliation against current state, not from hoping every registrar endpoint implements identical retry semantics. I would put a human approval between plan and apply for a production support domain. That costs a minute, but it makes unexplained deletion visible while the old delegation still serves traffic, which is exactly when review is cheap.

Verify the servers that will become authoritative

An API read-back is necessary but weak. It proves control-plane state. Before changing nameservers, query each destination authoritative server directly for every intended owner and type, bypassing recursive caches. Compare the answers after applying the same canonicalization rules, and treat missing, extra, or altered values as drift.

Check absence too. If an obsolete verification token remains published, a positive-only test will miss it. The final report should have 3 counts, and each one drives a different decision:

Count What it answers Cutover rule
Intended records Did the verifier receive the full reviewed manifest? Must equal the approved manifest size
Matching records Did each intended value appear on every destination nameserver? Must equal intended records
Unexplained records Did the destination publish anything outside intent? Must be 0

DMARC deserves an explicit assertion because its lookup location and tags carry policy meaning. Verify the exact intended TXT value at _dmarc.<domain>. Do the same for active DKIM selectors and the support domain's MX set. This does not test mail delivery itself, so send synthetic inbound and outbound messages through the support path as a separate application check, observing authentication results without using a real customer ticket. DNS equality and application behavior are related gates, not substitutes.

Only after authoritative answers match should delegation change. Recursive resolvers may retain earlier answers according to caching behavior, so post-cutover monitoring must cover both authoritative state and user-visible service. Keep the old zone unchanged during the transition window defined by the team's rollback plan.

What I would change at scale

For one zone, a JSON manifest and a dry-run table are enough. At dozens of support brands, I would add schema validation, record-set-aware diffs, signed plan artifacts, bounded concurrency, and per-nameserver verification results. I would also benchmark adapters on time to enumerate a large zone and time to converge after an interrupted apply. Request latency alone is the wrong number; convergence is what operators wait for.

The trade-off is machinery. Approval systems, artifact storage, and richer parsers add code that can itself fail. Add them when zone count, change frequency, or team boundaries justify them. Keep the invariant small: reviewed intent in, deterministic plan out, authoritative equality before delegation.

A clean migration is boring by design. The registrar API can change, the adapter can be replaced, and the decision rule stays put. No unexplained drift means proceed. Anything else means the nameservers do not move.

Further reading

Top comments (0)