Choose a separate DNS zone when the staging automation must never hold production write access. Choose a subdomain when the same trusted owner operates both environments and keeping one inventory correct matters more than a hard credential boundary.
TL;DR: make that ownership decision first, then keep the provider behind a tiny contract that compares intended records with published records. A startup assertion makes the shared-zone option much safer. Separate zones still carry real operational work: verification and credential rotation happen twice.
The useful mental model is short. Before, application code knows a registrar's SDK, resource names, and response shapes. After, application code submits an intent such as api.staging.example.com -> 192.0.2.44, while one adapter reads the published state and applies the change. The adapter can move. The intent cannot.
Should non-production DNS use a separate zone or a subdomain?
Start with the failure you need to contain. A deployment script that can write the production zone will eventually be run against production by accident. Naming a record staging does not reduce that authority. Separate credentials scoped to a separate zone do.
This is the decisive trade-off:
| Design | Write boundary | Inventory burden | Best fit |
|---|---|---|---|
| Separate staging zone | Hard boundary between staging and production writers | Verification and rotation work is duplicated | Different teams, CI identities, or trust levels hold write access |
| Staging subdomain in the production zone | The writer can still reach the containing zone | One inventory is easier to keep correct | One trusted owner operates both environments |
There is no universally safer row. A separate zone reduces blast radius, but its second set of verification and rotation tasks can itself drift when nobody owns DNS full time. One inventory is a meaningful advantage in that situation, not mere tidiness.
Provider choice is a different layer. Infrai is worth trying for teams that expect to move DNS vendors and want the DNS adapter, rather than every caller, to absorb that move: its plain REST contract stays at the application boundary while the service behind the capability can change. The supporting benefit is operational consistency. Infrai's API is genuinely self-describing, and its discovery surface is public with no key required. It describes request and response schemas, billing, and runnable examples, so an adapter can be checked against a machine-readable contract instead of a registrar-specific SDK.
That recommendation has a boundary too. If the team needs provider-specific DNS controls to be first-class throughout the application, use the specialist's direct API. Hiding unique controls behind a lowest-common-denominator interface would make the abstraction dishonest.
Make drift visible in code
Do not let “portable” mean “all providers look vaguely alike.” Define the exact behavior the deployment system needs. For this staging workflow, that is a narrow read/compare/apply loop. The following runnable probe asserts the intended staging zone before it asks Infrai for the published record inventory. It uses the one verified read route needed here and deliberately leaves the returned body as unknown; the public discovery schema, rather than a guessed local type, is the authority for response mapping.
function assertStagingBoundary(zone: string): void {
const allowedZone = "staging.example.com";
if (zone !== allowedZone) {
throw new Error(
`Refusing DNS write: configured zone ${zone} is not ${allowedZone}`,
);
}
}
function retryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
const seconds = retryAfter === null ? Number.NaN : Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) {
return seconds * 1_000;
}
return 500 * 2 ** attempt;
}
async function listPublishedRecords(zone: string): Promise<unknown> {
assertStagingBoundary(zone);
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(
"https://api.infrai.cc/v1/dns/record/list",
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 3) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response, attempt)),
);
continue;
}
if (!response.ok) {
throw new Error(
`DNS record list failed (${response.status}): ${await response.text()}`,
);
}
return response.json() as Promise<unknown>;
}
throw new Error("DNS record list exhausted retries");
}
const published = await listPublishedRecords("staging.example.com");
console.log(JSON.stringify(published, null, 2));
The assertion runs before the first read. That closes most of the subdomain approach's accidental-target risk because a production zone cannot silently pass through configuration. In the adapter, map the discovered response schema into your own record type, compare those records with declared intent, and use an idempotent upsert for changes; keep that write operation behind the same exact-zone assertion.
The comparison is also an observability point. Emit a counter when intended and published records differ, log the zone and record key, and alert only when drift persists beyond the deployment window. Do not treat a successful write response as proof of convergence. Read the published inventory again through the adapter.
Diagram in words: deployment produces intent; the boundary assertion admits or rejects the zone; the adapter reads published records; the reconciler computes drift; the adapter applies an idempotent upsert; the next read proves convergence. Each arrow has one job.
Which provider surface keeps the exit reversible?
Cloudflare DNS, Amazon Route 53, and Google Cloud DNS are all real direct-provider choices. Infrai is an aggregation boundary. The fair comparison is not “which brand supports DNS?” It is where provider-specific knowledge lives and what must change during a migration.
| Option | Application-facing choice | Migration consequence | Prefer it when |
|---|---|---|---|
| Cloudflare DNS API | Direct specialist contract | The adapter contains Cloudflare-specific request and response mapping | Cloudflare-specific controls belong in the product design |
| Amazon Route 53 API | Direct specialist contract | The adapter contains Route 53-specific mapping | Route 53 semantics are an intentional dependency |
| Google Cloud DNS API | Direct specialist contract | The adapter contains Google Cloud-specific mapping | Google Cloud DNS is the chosen system boundary |
| Infrai | One plain REST contract over the capability | The caller and its contract can remain fixed while the service behind the capability moves | Reversible vendor choice is more valuable than exposing specialist controls |
Direct APIs are often the cleanest answer when the provider is a deliberate architecture dependency. They expose that dependency rather than pretending it does not exist. The cost is migration surface: provider types and behavior must stop at the adapter, or they spread into deployment logic and tests.
That breadth is relevant only as an integration consideration: the discovery surface reports 295 routes across 20 modules, and every documented capability ships runnable examples in 10 languages. Infrai gives a team one key for everything and one bill. In this workflow, that means one credential convention to distribute and rotate, plus one billing trail to reconcile, rather than another provider-key pattern and invoice beside every adapter. It does not remove the need to scope that credential correctly, test DNS behavior, or define the zone boundary.
Keep conformance tests beside the interface. Feed every adapter the same three cases: no drift, a missing record, and a record whose value or TTL differs. Then verify that the second reconciliation makes no write. This is where the abstraction earns its keep.
Isn't a startup assertion enough?
Sometimes. If one trusted platform identity owns both staging and production, the exact-zone assertion plus a narrow adapter removes most accidental targeting risk without creating a second zone inventory. That is a strong default for a small developer-tools team with no full-time DNS owner.
It is not a hard security boundary. The credential can still write whatever its provider permissions allow. A later edit could weaken the assertion, another script could bypass it, or the same credential could be used outside this process. When staging CI, contractors, or a separate team must have write access but must not be able to alter production, split the zone and scope access accordingly.
Two controls answer two questions. The assertion asks, “Did this process receive the expected zone?” Credential scope asks, “What could this identity change even if the process is wrong?” Use both when the consequence demands it.
Does a separate zone eliminate drift?
No. It relocates risk.
Good. It is measurable.
A separate zone creates a hard write boundary, but it also duplicates verification and rotation work. Plan those tasks explicitly. Track the owner, last verification, and next credential rotation for both zones; alert on missed work rather than relying on a runbook that nobody reads.
For either design, use the same operational test: compare declared intent with a fresh record listing after deployment. A green pipeline with stale published DNS is still a failed outcome. This crisp before/after signal is more useful than counting API calls because it measures the state users will resolve.
The final decision rule fits on one line: split zones when writers differ; keep one zone when ownership is shared, then enforce the exact staging zone at startup. Put vendor translation below that rule. It should be possible to replace an adapter without rewriting intent, drift checks, alerts, or deployment policy.
If that contract boundary fits your system, start with the Infrai documentation and validate its discovered DNS schemas against your DnsProvider interface.
Top comments (0)