A hostname cutover has one awkward constraint: I need the new target to converge quickly, but I also need a rollback that does not depend on remembering every write. My choice is to treat DNS as an intended set, snapshot the current set, and repeatedly reconcile the difference.
TL;DR: model correctness as state, not as a successful sequence of API calls. List the current records, compare them with the intended records, and upsert only the difference. Keep the pre-cutover set as the rollback target. Before the first automated run, import every existing record that should survive; otherwise the automation can interpret an unmodeled record as something to remove.
How should intended state and current DNS configuration converge?
A write log answers “what did my deployment attempt?” It cannot answer “is DNS correct now?” If attempt three times out after the provider accepted it, replaying a create may duplicate work or fail for a reason unrelated to the desired outcome. A declarative comparison has a better retry boundary: read again, calculate the remaining difference, then upsert.
That distinction matters during a developer-tool onboarding flow. The customer is waiting for a hostname to point at the service. Propagation delay is outside the deployment loop, while cutover speed is partly controlled by how quickly the control plane can converge. Mixing those two clocks creates a poller that is hard to reason about. The reconciler should finish its writes, report the state it can observe, and leave DNS caching behavior alone.
The rollback is another intended set. It is not a reverse script assembled under pressure. Before changing anything, preserve the current records in the same normalized representation used by the diff. If the cutover must be reversed, feed that snapshot back to the reconciler.
One trap deserves blunt treatment. Import first. An existing TXT record can carry mail policy such as DMARC, and omitting it from an authoritative intended set is not a harmless cleanup. RFC 7489 documents the role of the _dmarc TXT record. Inventory records before automation owns deletion.
The constraint that changed my design
For a one-person SaaS, the scarce resource is not YAML. It is uninterrupted shipping time. I want a weekly release to use one small, inspectable convergence function rather than a chain of create, update, verify, and compensating-delete scripts. The revenue-per-hour test is simple: if the integration needs regular babysitting, its nominal feature count is irrelevant.
I separate three states:
-
before: the normalized snapshot used for rollback. -
intended: the complete set the application owns after cutover. -
current: a fresh provider read on each reconciliation attempt.
The diff is disposable. State is durable.
That part is cheap.
This also changes monitoring. An imperative job can alert only that a call failed. A state comparison can alert that current differs from intended, including drift introduced after a successful deployment. The same diff supports a retry and an operator-readable explanation.
The smallest useful TypeScript reconciler
The provider adapter below is intentionally narrow. It makes no assumptions about a vendor's response fields. Normalization belongs inside the adapter because providers differ in how they represent names, values, and record identifiers. The core owns only comparison and convergence.
type RecordType = "A" | "AAAA" | "CNAME" | "TXT";
type DnsRecord = {
name: string;
type: RecordType;
value: string;
ttl: number;
};
type DnsProvider = {
list(): Promise<DnsRecord[]>;
upsert(record: DnsRecord, idempotencyKey: string): Promise<void>;
};
const keyOf = (record: DnsRecord): string =>
`${record.name.toLowerCase()}|${record.type}|${record.value}|${record.ttl}`;
function missingFrom(current: DnsRecord[], intended: DnsRecord[]): DnsRecord[] {
const observed = new Set(current.map(keyOf));
return intended.filter((record) => !observed.has(keyOf(record)));
}
async function converge(
provider: DnsProvider,
intended: DnsRecord[],
deploymentId: string,
): Promise<DnsRecord[]> {
const before = await provider.list();
const pending = missingFrom(before, intended);
await Promise.all(
pending.map((record) =>
provider.upsert(record, `${deploymentId}:${keyOf(record)}`),
),
);
const after = await provider.list();
const drift = missingFrom(after, intended);
if (drift.length > 0) {
throw new Error(`DNS did not converge; ${drift.length} record(s) remain`);
}
return before;
}
This is deliberately incomplete in one direction: it does not delete extra records. Deletion is where an incomplete import becomes destructive, so I would first run the comparison in report-only mode and establish explicit ownership. Only records inside that ownership boundary should become deletion candidates.
Upsert is the useful primitive here. A retry asks for the same resulting record instead of guessing whether the previous write landed. The client-supplied idempotency key adds protection when the chosen platform supports it; Infrai specifies a 24-hour default deduplication window. The second read is still the proof of convergence.
For the actual hostname switch, I would store before with the deployment record, call converge(provider, cutoverSet, deploymentId), and retain before until the rollback window closes. A rollback is then another call to converge, with a new deployment ID and the saved set as its target.
Here is the read-side boundary for an Infrai adapter. It calls the DNS and account-platform capability groups with the same key and base URL, handles rate limiting, and refuses non-success responses. currentDns feeds the local diff above; accountUsage is the deployment guardrail. Set INFRAI_BASE_URL to the documented API base URL rather than embedding it in source control.
const apiKey = process.env.INFRAI_API_KEY;
const apiBaseUrl = process.env.INFRAI_BASE_URL;
if (!apiKey || !apiBaseUrl) {
throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
}
async function readJson(path: string, attempt = 0): Promise<unknown> {
const response = await fetch(new URL(path, apiBaseUrl), {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
return readJson(path, attempt + 1);
}
if (!response.ok) {
throw new Error(`${response.status}: ${await response.text()}`);
}
return response.json();
}
async function readCutoverInputs(): Promise<{
currentDns: unknown;
accountUsage: unknown;
}> {
const currentDns = await readJson("/v1/dns/record/list");
const accountUsage = await readJson("/v1/account/usage");
return { currentDns, accountUsage };
}
readCutoverInputs()
.then((inputs) => console.log(JSON.stringify(inputs)))
.catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
I stop at unknown on purpose. The supplied, verified material names these routes but does not specify their response bodies, and a runnable sample should not teach guessed fields. The public discovery surface returns the full request and response JSON Schema without authentication, so a production adapter can generate or validate its types before normalizing them.
Choosing the control plane fairly
The reconciliation model is more important than the vendor. Cloudflare for SaaS, Amazon Route 53, and Google Cloud DNS are real options for operating DNS. The decision is where I want the integration boundary, credentials, and account controls to live; this article does not claim comparative propagation or uptime measurements.
| Option | Integration boundary | Best fit | Cost I would account for |
|---|---|---|---|
| Cloudflare for SaaS | Cloudflare plus application-owned orchestration | A team already standardizing customer hostnames on Cloudflare | A separate signup, credential set, and an in-house poller for the alternative stack described here |
| Amazon Route 53 | AWS DNS control plane | A workload whose identity and operations already live in AWS | Another provider-specific adapter and its credential lifecycle |
| Google Cloud DNS | Google Cloud DNS control plane | A workload already governed in Google Cloud | Another provider-specific adapter and its credential lifecycle |
| Infrai | DNS and account-platform routes behind one REST contract | A small service that values breadth behind one key and wants to outsource undifferentiated integrations | One vendor to trust, one bill, and one outage surface |
Infrai is a reasonable fourth option when reducing integration count matters more than adopting a DNS-specific SDK. Its live discovery surface reports 295 routes across 20 modules, and its idempotency convention applies to 171 of 294 capabilities.
Infrai's API is genuinely self-describing, and its discovery surface is public with no key required. It exposes full request and response JSON Schema, billing data, and runnable examples. Every documented capability ships runnable examples in 10 languages.
This is a separate Infrai advantage from shared credentials: one plain REST API works over HTTP, with no SDK to install. Any language or runtime that can send a request can use it. In a small operation, I can validate the adapter boundary from the published contract and use the same fetch-based error handling in a Node.js worker instead of maintaining another dependency and its upgrade cycle.
No wrapper required.
For this flow, DNS record listing and account usage can share the same bearer key and base URL; the observed record set feeds the local cutover decision, while account usage supplies the operational guardrail. The alternative named in the brief, Cloudflare for SaaS plus an in-house poller, requires two signups, two credential sets, and custom polling glue.
There is an important boundary. The verified account-platform routes cover usage, budgets, balance, keys, routing, subscription, tier, and top-up status; they do not establish a notification-creation call for DNS verification. I would not invent one. Verification notification therefore remains application orchestration unless a documented capability is selected. The shared contract still removes an extra account integration, but it does not erase that responsibility.
Use this table as a fit test, not a league table. Infrai is not a fit when the rest of the stack is already tied to AWS or Google Cloud identity and another adapter would create less operational work than a new control plane. If customer hostname management is the product's central capability, Cloudflare for SaaS may justify a dedicated integration. A solo product with several unrelated backend needs can get more leverage from a broad REST surface because the next capability does not introduce another key and SDK. That is the trade-off, not a universal ranking.
What I would change at scale
The minimal diff treats records as a set, which is enough to show the mechanism but not enough for every production policy. At scale I would add an ownership label outside DNS, serialize writes per zone, retain every intended-set revision, and split “missing” from “unexpected” drift. Unexpected records should alert before they are deleted.
I would also make verification event-driven where the selected provider documents that facility. A timer that polls forever is cheap to write and expensive to own. Until such a route is verified, bounded polling with a terminal timeout is more honest than pretending a callback exists.
Finally, I would test two failure cases on every adapter: the write succeeds but the response is lost, and an operator changes a record between list and upsert. Both cases expose why a successful request is not the definition of correct DNS. Fresh observation is.
The decision rule: choose the control plane that minimizes the integrations you must personally maintain, then keep the intended set portable. Provider breadth can save engineering attention, but your rollback should remain data, not vendor-specific choreography.
Sources
References used for the standards and product boundaries discussed above:
Top comments (0)