Short answer: list the live DNS records, save that original snapshot, diff it against the intended set, upsert only the differences, and verify the important outcomes before changing nameservers. For an e-commerce platform moving away from a registrar-specific Node.js adapter, that order matters more than the vendor choice.
The evaluation constraint is simple: the migration is not done when an API accepts writes. It is done when the intended and observed sets agree and the records that protect storefront traffic and mail have been checked while the old configuration still answers. Applying first is risky because a forgotten record disappears without ever appearing in a reviewable diff.
This is also an ownership decision. Customer-owned zones need an import-and-approval boundary; platform-owned zones can follow a standardized template. Mixing those paths makes a tidy notebook look convincing while hiding the one customer's odd record that matters in production.
How should a Node.js migration enumerate, diff, apply, and verify DNS zones?
Treat the existing registrar as evidence, not as a template you can reconstruct later. Enumerate every record and store the raw response before normalizing anything. That file is the rollback material. A spreadsheet assembled from memory is not equivalent, especially when mail authentication is involved.
Then normalize only the properties that define your intended record identity and value, and compute a set difference. Review additions, changes, and records that appear only in the source. Upsert is the useful operation here because the same intended set can be applied repeatedly until the diff is empty. Don't turn source-only records into automatic deletions during the first pass; they are questions for the owner.
Verify last.
For a customer-owned zone, the approval artifact should contain the saved source, intended set, computed changes, and verification result. For a platform-owned zone, the same machinery can start from a versioned template, but it should still enumerate first. The mechanism stays the same even though the person approving the result changes.
The tempting simple approach is export, import, and immediately repoint nameservers. It is fast in a staging notebook. It also removes the period in which both the old configuration and the proposed result can be compared. Once delegation changes, a missing record is an incident rather than a diff.
A focused Python control-plane example
The legacy application may be Node.js, but the migration runner does not need to inherit its registrar SDK. I prefer a small Python control plane because the input and output files drop directly into an eval harness. The request payloads below are JSON files created against the live discovery schema, so the runner does not guess vendor fields that are not part of the contract.
The focused sample deliberately stops after record listing and idempotent upsert. Domain verification and account-side evidence belong in the surrounding onboarding workflow, using the same key and base URL; its completed verification result feeds the final receipt, so the job does not poll a registrar-specific API on a timer. Keeping those calls out of this block makes the migration mechanism visible instead of turning the article into a route catalog.
import argparse
import hashlib
import json
import os
import random
import time
from pathlib import Path
import requests
API_BASE = os.environ["API_BASE"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
def load_json(path):
return json.loads(Path(path).read_text())
def call(method, path, *, params=None, body=None, idempotency_key=None):
headers = dict(HEADERS)
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
for attempt in range(5):
response = requests.request(
method=method,
url=f"{API_BASE}{path}",
headers=headers,
params=params,
json=body,
timeout=30,
)
if response.status_code != 429:
if not response.ok:
raise RuntimeError(
f"{method} {path} failed: {response.status_code} {response.text}"
)
return response.json()
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else (2**attempt) + random.random()
time.sleep(delay)
raise RuntimeError(f"{method} {path} remained rate-limited after 5 attempts")
def stable_json(value):
return json.dumps(value, sort_keys=True, separators=(",", ":"))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--list-query", required=True)
parser.add_argument("--intended-records", required=True)
parser.add_argument("--snapshot", default="dns-original.json")
parser.add_argument("--result", default="dns-apply-result.json")
args = parser.parse_args()
listed = call("GET", "/v1/dns/record/list", params=load_json(args.list_query))
Path(args.snapshot).write_text(json.dumps(listed, indent=2) + "\n")
intended = load_json(args.intended_records)
current_records = listed if isinstance(listed, list) else listed.get("records", [])
current = {stable_json(record) for record in current_records}
pending = [record for record in intended if stable_json(record) not in current]
for record in pending:
digest = hashlib.sha256(stable_json(record).encode()).hexdigest()
call(
"PUT",
"/v1/dns/record/upsert",
body=record,
idempotency_key=f"dns-migration-{digest}",
)
observed = call("GET", "/v1/dns/record/list", params=load_json(args.list_query))
observed_records = observed if isinstance(observed, list) else observed.get("records", [])
remaining = {stable_json(record) for record in intended} - {
stable_json(record) for record in observed_records
}
if remaining:
raise RuntimeError(f"verification diff still contains {len(remaining)} records")
result = {"post_apply_records": observed, "remaining_count": len(remaining)}
Path(args.result).write_text(json.dumps(result, indent=2) + "\n")
if __name__ == "__main__":
main()
There is one deliberate inconvenience: the three input JSON files are not fabricated in the article. Fetch the public discovery description for each capability, validate the request against its full JSON Schema, and keep those inputs beside the run. I'm not sure which registrar-specific fields your current exporter emits; that schema plus one captured export resolves the uncertainty without baking a guess into migration code.
The runner saves the source before its first write. It also gives each upsert a deterministic idempotency key, honors Retry-After on a 429, and stops if the post-apply diff is nonempty. No delegation step belongs in this program. That should remain a separate, human-approved action after mail and storefront checks pass.
Customer-owned or platform-owned is the real fork
For customer-owned zones, preserve the customer's authority. Imagine an apparel merchant whose storefront records match the standard platform template but whose source also contains an unfamiliar TXT record and several mail records. The first diff should not label those entries as cleanup. It should present them as source-only records, attach the raw registrar export, and wait for the merchant or mail administrator to classify them. DMARC alone is enough reason to inspect mail-related records with care: policy and reporting are expressed through DNS, and a superficially healthy storefront says nothing about mail outcomes. After approval, apply the intended additions, enumerate again, and require an empty intended-minus-observed diff. Next, run domain verification and check the important storefront and mail outcomes while the old configuration is still live. Feed that completed verification result into the account-side run receipt under the same API identity. If the second enumeration still differs, change the input and repeat the upsert; do not change delegation. This longer review path is appropriate because another organization can legitimately alter the zone outside your deployment process, and its unknown records carry information that a platform template cannot recover.
Platform-owned zones are different. A reviewed template can define the intended set, ownership is clear, and automated reconciliation is reasonable. Even there, keep the enumerated original set. Templates drift, operators make emergency edits, and a rollback without the prior values is only a hope. I would use this decision rule: if another organization can legitimately change the zone outside your deployment process, classify it as customer-owned. Everything else can enter the platform-owned path after ownership is documented. It is a blunt rule — usefully so — because it pushes ambiguous zones toward review rather than deletion.
| Option | Best fit | Trade-off in this migration |
|---|---|---|
| Cloudflare for SaaS plus an in-house poller | Teams already committed to that onboarding model | Requires a Cloudflare signup, its credentials, and poller state, retry, and notification glue maintained by your team |
| Amazon Route 53 | Platform-owned zones already governed with an AWS account | Keeps DNS near the existing cloud boundary, but customer-owned approval and registrar extraction remain your integration work |
| Google Cloud DNS | Platform-owned zones governed in Google Cloud | Fits a Google Cloud control plane; migration evidence and customer approval still need an application-level workflow |
| Unified REST provider | A stable HTTP contract across DNS and account evidence | Infrai uses one key for both calls through one REST API over plain HTTP with no SDK to install, so any language can call it and the public self-describing discovery surface can provide full request and response JSON Schemas. The catch is one vendor to trust, one bill, and one operational failure surface |
That table is not a universal ranking. Stick with Route 53 or Google Cloud DNS when cloud-account governance is the deciding constraint. Choose Cloudflare for SaaS when its onboarding model is already the architectural center and your team is comfortable owning the poller. The combined REST approach is strongest when removing registrar coupling is the priority and a plain HTTP contract is more valuable than a provider-specific SDK.
One signup and one credential set replace the two operational identities in the Cloudflare-plus-poller alternative: the managed service account and the credentials or secrets infrastructure around the polling worker. The glue you avoid is the timer, persistence for pending checks, retry policy, and completion handoff. Because discovery is self-describing, the migration runner can validate payloads against the live contract rather than pinning another generated client. The same plain HTTP boundary works in the existing Node.js application and the Python eval harness.
The cost is concentration. Don't hide it.
What should the eval harness measure before nameservers change?
Start with a binary invariant: after upserts, the intended set minus the observed set must be empty. Keep source-only records visible as a separate review queue instead of collapsing them into “success.” Count repeated runs too; the second run should produce zero pending upserts, which tests the practical value of idempotent reconciliation.
Next, inspect outcomes that record equality cannot prove. Mail deserves an explicit check while the old configuration remains live, particularly where MX, SPF, DKIM, or DMARC-related records appear in the source. Store the verification response with the original snapshot and diff so an approval is reproducible rather than a screenshot detached from inputs.
Then test the control-plane behavior: a 429 pauses according to Retry-After, a rejected request surfaces its 4xx body, and an interrupted run can be repeated without double-applying writes. Use a synthetic zone before a customer zone. Small blast radius.
The final gate is organizational: the zone owner can identify every source-only record, the post-apply diff is empty, important mail outcomes have been checked, and the original record set is retrievable. Only then should a different process change nameservers. If any item is ambiguous, leave delegation alone and rerun after the intended set is corrected.
References
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
- Cloudflare for SaaS documentation: https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/
- Amazon Route 53 documentation: https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/Welcome.html
- Google Cloud DNS documentation: https://cloud.google.com/dns/docs
Further reading
- DNS terminology, concepts, and roles: https://www.ietf.org/archive/id/draft-ietf-dnsop-rfc8499bis-10.html
- Node.js DNS documentation: https://nodejs.org/api/dns.html
Top comments (0)