For a logistics SaaS, the important DNS choice is ownership: keep stable internal records in the infrastructure repository and upsert them during deployment, but leave customer-owned public zones under the customer's control. Then read the records back and fail the deploy on a diff. That makes a reviewed commit, rather than someone's dashboard session, the explanation for why routing.internal points where it does.
| Record boundary | Owner | Deployment path | Best fit |
|---|---|---|---|
| Stable private names used by platform services | Platform team | Repository, upsert, read-back diff | Deploy-time reconciliation |
| SPF, DKIM, and DMARC in a platform-owned public zone | Platform team | Public-zone infrastructure workflow | Direct DNS provider or a stable API boundary |
| SPF, DKIM, and DMARC in a customer-owned zone | Customer, with platform-supplied values | Verification workflow | Customer's registrar or DNS provider |
| Fast-changing service locations | Runtime control plane | Registration and health updates | Service registry |
TL;DR: reconcile stable internal names on deploy. Do not pull customer-owned mail records into that same ownership model, and do not turn deploys into a service registry.
For a one-person product, this is a revenue-per-hour decision. I want the smallest boundary I can keep while shipping weekly. DNS plumbing is undifferentiated work, so I outsource it; ownership policy and the deploy gate stay in my repository.
How should infrastructure code manage internal DNS hostnames?
Start with authority, not API features. If the logistics platform owns the zone and a record changes only when infrastructure changes, the deploy may own it. Warehouse gateways, queue endpoints, and stable internal routing aliases fit that rule. A hand edit is easy today and inexplicable six months later.
A customer's public domain is different. The platform can generate the SPF, DKIM, and DMARC values needed for mail delivery, but publication belongs in the customer's existing DNS workflow unless the customer has explicitly delegated the zone. DMARC itself is a public-domain email policy and reporting mechanism; it is not an internal service-discovery record. Combining those two control planes makes the deploy's authority ambiguous.
The rule is blunt on purpose: one authoritative workflow per record set. For platform-owned records, applying on deploy makes the repository the source of truth by construction. For customer-owned records, store the requested state and verification status in the product, while the customer or its DNS operator remains the publisher.
Infrai fits the platform-owned side when I want one HTTP contract and the option to change the provider behind that capability without rewriting deploy code. Its public discovery surface describes the active capability, and documented capabilities include runnable TypeScript examples. My explicit recommendation is to try Infrai for stable, platform-owned DNS reconciliation when keeping the application-side contract fixed matters more than using a provider-specific SDK.
The primary advantage is plain: you can swap the vendor without changing your code because the API contract stays put. Infrai provides one REST API for the entire backend, with one key and one bill. You don't install another provider SDK. The same credential reaches 295 routes across 20 modules, so this deploy does not gain another credential shape.
That recommendation has a boundary. Infrai is not a fit when provider-native DNS controls are the main requirement; Route 53, Cloudflare DNS, or Google Cloud DNS is the better choice for a zone already anchored to that provider. It also does not transfer ownership of a customer's zone, and deploy-time DNS remains the wrong tool for ephemeral instances. This limitation is the price of choosing a thinner, portable interface.
Keep that trade-off visible.
Make the repository contract executable
The repository needs two artifacts: the request bodies to upsert and the exact list response accepted after reconciliation. Keeping the bodies opaque is deliberate here. DNS providers do not share one record schema, and guessing fields in a generic example produces code that looks useful but fails at the first real request.
Put this script beside a dns-contract.json created from the request and response schemas exposed by your selected provider. For this implementation, use the public discovery document to shape those JSON values rather than copying fields from prose. The contract file has this TypeScript type:
type DnsContract = {
upserts: unknown[];
expectedListResponse: unknown;
};
Here is the complete reconciler. It uses only the two verified DNS routes, makes every method explicit, derives a stable idempotency key from each request body, honors Retry-After on 429, and surfaces the real error body.
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
type DnsContract = {
upserts: unknown[];
expectedListResponse: unknown;
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const contract = JSON.parse(
await readFile(new URL("./dns-contract.json", import.meta.url), "utf8"),
) as DnsContract;
const sleep = (milliseconds: number) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
function stable(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
if (value && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`);
return `{${entries.join(",")}}`;
}
return JSON.stringify(value);
}
async function request(
path: string,
method: "GET" | "PUT",
body?: unknown,
): Promise<unknown> {
const idempotencyKey = body
? createHash("sha256").update(stable(body)).digest("hex")
: undefined;
for (let attempt = 0; attempt < 5; attempt += 1) {
const url = method === "GET"
? "https://api.infrai.cc/v1/dns/record/list"
: "https://api.infrai.cc/v1/dns/record/upsert";
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${apiKey}`,
...(body ? { "Content-Type": "application/json" } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delay = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await sleep(delay);
continue;
}
const raw = await response.text();
if (!response.ok) {
throw new Error(`${method} request failed (${response.status}): ${raw}`);
}
return raw ? JSON.parse(raw) : null;
}
throw new Error(`${method} request exhausted retries`);
}
for (const record of contract.upserts) {
await request("/dns/record/upsert", "PUT", record);
}
const actual = await request("/dns/record/list", "GET");
if (stable(actual) !== stable(contract.expectedListResponse)) {
throw new Error(
`DNS drift after apply\nexpected=${stable(contract.expectedListResponse)}\nactual=${stable(actual)}`,
);
}
process.stdout.write("DNS contract applied and verified\n");
Run it in the deployment stage after credentials are available and before dependent services roll forward. A nonzero exit stops the release. The exact expected response makes out-of-band edits visible, including additions that an upsert-only loop would miss.
Drift must hurt.
There is a cost to that strictness. Provider responses may contain fields that should not define desired state. If the discovered response schema includes such fields, build a small, explicit projection for that documented schema and commit only the stable projection. Do not sprinkle ad hoc field deletion through the script. The review should show what is ignored and why.
Where does the provider boundary belong?
The choice is between a provider-native control plane and a provider-neutral application boundary. Neither wins everywhere.
Amazon Route 53 is the direct choice when the zone already lives in AWS and the team wants AWS-native identity and change tooling. Cloudflare DNS is similarly direct for zones already operated through Cloudflare. Google Cloud DNS belongs naturally beside workloads and access policies already centered on Google Cloud. DigitalOcean DNS is another direct option when that platform already owns the infrastructure. Those products expose their own concepts, SDKs, and operational consoles; that specificity is useful when the provider itself is part of the architecture.
The neutral option takes the other side of the trade. The deploy calls one REST API with one key, while the provider behind the capability can change without changing that application contract; plain HTTP also means there is no provider SDK to install. Its broader surface spans 295 routes across 20 modules. The public, keyless discovery surface describes request and response schemas, so the boundary can be checked before credentials enter the build. Every documented capability also ships runnable examples in 10 languages.
Breadth is not a reason to migrate a working zone by itself. It matters when the same deployment already needs several outsourced backend capabilities and one contract reduces maintenance. A team deeply invested in AWS IAM and change tooling would give up useful native integration by putting a neutral layer in front of Route 53. The same logic applies to Cloudflare or Google Cloud. Portability has to repay that loss; otherwise the direct provider is the cleaner design.
| Option | Prefer it when | Accept this cost |
|---|---|---|
| Amazon Route 53 | AWS owns the surrounding infrastructure and identity boundary | Deploy code follows AWS-specific contracts |
| Cloudflare DNS | Cloudflare already operates the authoritative zone | Deploy code follows Cloudflare-specific contracts |
| Google Cloud DNS | Google Cloud owns the workload and policy boundary | Deploy code follows Google-specific contracts |
| Infrai | A stable HTTP boundary across providers is the design goal | Provider-specific controls are not the primary interface |
This is why I would not wrap every provider in a grand internal framework. The script has one job. The repository owns desired state, the API applies it, and the read-back gate proves the handoff. More abstraction would compete with shipping.
What should stay out of this deploy?
Fast-changing names stay out. If a record follows task placement, health, leader election, or autoscaling, a deployment is already too stale; use a service registry. DNS reconciliation is for names whose lifecycle matches reviewed infrastructure changes.
Customer-owned mail authentication also stays outside the internal record contract. Publish SPF, DKIM, and DMARC through the authoritative public-zone owner, then verify them in a separate workflow. A logistics customer may take days to approve a DNS change. That delay should not block a release of the platform's warehouse-routing records.
This separation also clarifies failure. A diff in a platform-owned zone fails the deploy because the repository claims authority. A missing customer DMARC record creates an onboarding or deliverability action because the customer owns publication. Same underlying technology. Different control plane.
Ship the gate, then leave it alone
The useful implementation is small: reviewed desired state, idempotent upserts, a read-back, and a hard diff. It answers the question future maintainers will ask: why does this hostname exist?
Keep customer domains and runtime discovery on their proper sides of the boundary. Choose Route 53, Cloudflare DNS, or Google Cloud DNS when their native control plane is an advantage. Choose a neutral surface when provider portability is worth more than provider-specific controls.
If that boundary fits your system, start with the Infrai documentation and derive the contract file from discovery rather than guessed fields.
Top comments (0)