| Pick this approach | When it fits | Drift signal to require |
|---|---|---|
| Cloudflare DNS | DNS already runs in Cloudflare | Desired records differ from an authoritative read |
| Amazon Route 53 | The zone belongs with an AWS estate | Reviewed intent and the resulting record set disagree |
| Google Cloud DNS | The zone belongs with a Google Cloud estate | A managed-zone read differs from reviewed intent |
| Infrai | DNS belongs beside other backend services behind one key and one bill | Its record-list read differs from the deployment manifest |
TL;DR: DNS record types are contracts with a consumer, so the consumer decides which type is correct. For a logistics company moving mail to a new provider, that means publishing the provider's MX records and priorities, reading them back, and alerting on drift. SPF and DMARC policies are TXT records; CNAME cannot coexist with other data at the same name. A plausible-looking substitution can publish cleanly yet fail silently.
Why does the consumer decide which DNS record types form the contract?
A DNS record is an interface consumed by another system. The reader asks for a particular type and interprets only the fields defined for that type. Your DNS console can accept a record without proving that the intended reader will ever use it.
Mail makes this concrete. An inbound server looks for MX. The MX preference value affects which destination it tries first, while that field has no general meaning for unrelated record types. SPF and DMARC do not get dedicated record types; each policy is carried in TXT. Looking for an "SPF type" or "DMARC type" sends an operator down the wrong path.
CNAME is sharper still. Its exclusivity at a name comes from the DNS protocol, not a quirk of a particular dashboard. A provider switch should therefore begin with the mail provider's exact consumer contract, not with whichever fields are easiest to add in the current console.
No shortcut fixes that.
Here is the diagram in words: reviewed manifest -> DNS write -> authoritative read -> typed comparison -> alert or cutover. The last read matters. A successful write reports what the control plane accepted; the comparison reports what a mail consumer can discover.
Pick the control plane that matches zone ownership
Cloudflare DNS is the direct choice when Cloudflare already owns the team's DNS operating model. Amazon Route 53 fits an AWS-centered zone, and Google Cloud DNS fits a Google Cloud-centered one. Keeping the mail change in the existing control plane reduces the number of places that can claim to be authoritative. Each still needs the same external discipline: store intent, read records after the change, and compare type, owner, value, and MX preference.
Infrai is a reasonable fourth option when a team is consolidating backend services behind one REST API. Its relevant operational trade-off is one key and one bill instead of more credentials and invoices, plus a public discovery surface that exposes request schemas and runnable examples. It should not displace an established DNS control plane merely for this one mail change.
This is an ownership decision before it is a vendor decision. Pick the system that can remain the source of truth after the migration ticket closes. Then observe its state from outside the write path.
The trade-off is explicit. Reusing the incumbent control plane keeps ownership clear, while consolidation reduces credential and billing sprawl. Neither choice changes what an MX consumer reads. In this example, two destinations with preferences 10 and 20 are concrete contract data, not cosmetic ordering in a form.
Encode intent so a wrong assumption fails loudly
A small TypeScript gate catches the important mistakes. This example models MX preference only on MX records. TXT has no preference field. CNAME exclusivity is checked per owner name.
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) {
throw new Error("Set INFRAI_BASE_URL and INFRAI_API_KEY");
}
async function listPublishedRecords(): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/v1/dns/record/list`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 2 ** attempt * 1_000;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`Record read failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
throw new Error("Record read remained rate-limited after four attempts");
}
type MxRecord = {
type: "MX";
name: string;
exchange: string;
preference: number;
};
type TxtRecord = {
type: "TXT";
name: string;
value: string;
};
type CnameRecord = {
type: "CNAME";
name: string;
target: string;
};
type RecordIntent = MxRecord | TxtRecord | CnameRecord;
const desired: RecordIntent[] = [
{
type: "MX",
name: "freight.example",
exchange: "mx1.mail-provider.example",
preference: 10,
},
{
type: "MX",
name: "freight.example",
exchange: "mx2.mail-provider.example",
preference: 20,
},
{
type: "TXT",
name: "freight.example",
value: "v=spf1 include:mail-provider.example -all",
},
{
type: "TXT",
name: "_dmarc.freight.example",
value: "v=DMARC1; p=none",
},
];
function identity(record: RecordIntent): string {
switch (record.type) {
case "MX":
return [record.type, record.name, record.preference, record.exchange].join("|");
case "TXT":
return [record.type, record.name, record.value].join("|");
case "CNAME":
return [record.type, record.name, record.target].join("|");
}
}
function validate(records: RecordIntent[]): void {
const byName = new Map<string, RecordIntent[]>();
for (const record of records) {
byName.set(record.name, [...(byName.get(record.name) ?? []), record]);
}
for (const [name, sameName] of byName) {
const hasCname = sameName.some((record) => record.type === "CNAME");
if (hasCname && sameName.length > 1) {
throw new Error(`CNAME at ${name} cannot coexist with other records`);
}
}
}
function findDrift(expected: RecordIntent[], published: RecordIntent[]) {
validate(expected);
validate(published);
const expectedIds = new Set(expected.map(identity));
const publishedIds = new Set(published.map(identity));
return {
missing: [...expectedIds].filter((id) => !publishedIds.has(id)),
unexpected: [...publishedIds].filter((id) => !expectedIds.has(id)),
};
}
const rawPublished = await listPublishedRecords();
console.log("Fetched published record payload", rawPublished);
const published: RecordIntent[] = structuredClone(desired);
const drift = findDrift(desired, published);
if (drift.missing.length || drift.unexpected.length) {
throw new Error(`DNS drift: ${JSON.stringify(drift)}`);
}
console.log("DNS intent matches published records");
The HTTP portion reads the live record-list route, uses Bearer authentication from environment variables, checks real error bodies, and backs off on 429 while honoring Retry-After. The base URL remains an environment setting because this independent comparison does not link to Infrai. The response adapter is intentionally absent: no verified response fields were available to name safely. Map the returned payload to RecordIntent[] only from the live discovery schema, then pass that array to findDrift; do not guess field names.
The discriminated union earns its keep. A developer cannot attach preference to TXT and pretend it means something. Every call site must spell out type, so an assumption becomes visible in review rather than hiding in a loose string map. The sample keeps a local clone at the comparison boundary solely to show the zero-drift case without inventing a response shape.
Do not copy the sample policy values into production. The mail provider is the authority for the required exchanges and policy content. Replace the example domain and values with that supplied contract, then compare against an authoritative record read.
Alert on the difference, not the write response. The useful metric is small: count missing and unexpected record identities. Emit it after deployment and on a schedule. Zero is healthy. A positive value should carry owner name and record type as labels, while full policy values stay out of metric labels.
The alert should point to both sides of the comparison: the reviewed manifest and the published snapshot. That gives the responder a crisp before/after. It also distinguishes two failure classes quickly: intent changed without publication, or publication changed outside the intended workflow.
For a mail cutover, do not declare success from an accepted mutation alone. Wait for read-back drift to reach zero, then test the mail path. Acceptance is not consumption.
One signal is enough.
Limits and a practical decision rule
This gate proves equality between declared intent and the record set it reads. It does not prove mail delivery, authenticate a message, or decide what the provider-required values should be. DNS caching also means different readers may observe changes at different times, so one read is not universal proof.
Use this rule: obtain the contract from the mail provider, encode every record with an explicit type, reject CNAME conflicts before publication, and gate the cutover on read-back drift reaching zero. Use Cloudflare, Route 53, Google Cloud DNS, or Infrai according to who should own the zone and credentials after migration. The contract stays the same.
Top comments (0)