DEV Community

JudsonRhodes1569
JudsonRhodes1569

Posted on

Manage Internal DNS Hostnames from Infrastructure Code in 4 Deploy Steps

TL;DR: Keep stable internal DNS records in the infrastructure repository, upsert them during deployment, then read them back and fail the deployment on any difference. That makes a marketplace hostname cutover reviewable and gives rollback a concrete input: the last committed record set. Do not put fast-changing service locations in this loop; use a service registry for those.

Pick Best fit Main trade-off for this workflow
AWS Route 53 Teams already operating hosted zones in AWS Native AWS tooling is convenient, but the deployment code is tied to that provider contract.
Cloudflare DNS Teams whose zones and operational controls already live in Cloudflare A direct API integration is clear, but switching providers means replacing its client and request shapes.
Google Cloud DNS Workloads governed through Google Cloud projects and IAM It fits that control plane well, while cross-provider portability remains application work.
Portable REST control plane Teams that want one stable contract while the vendor behind a capability can change The abstraction reduces provider coupling; it also adds a control plane that the team must deliberately adopt.
Service registry Instances or endpoints that change frequently Excellent for dynamic discovery, wrong as the reviewed source of truth for a stable marketplace hostname.

The decision is less about a logo than ownership. If an existing cloud control plane already owns the zone, its native DNS service is usually the shortest path. If portability across providers is the requirement, put a narrow adapter between the deployment and DNS. Keep the record manifest independent either way.

How should infrastructure code manage internal DNS hostnames on deploy?

An accepted write proves that a provider accepted a request. It does not prove that the published answer equals the repository's intent. Those are different signals.

Big difference.

Picture the path in words: pull request becomes a desired record set; deploy sends idempotent upserts; an authoritative lookup returns the published values; a set comparison emits either dns_cutover_verified or a failed deployment. The repository is the intent plane. DNS is the observed plane. The diff joins them.

This catches the awkward class of changes that otherwise survive for months: a record edited outside the repository, an old target left beside the new one, or a rollback commit that was merged but never reconciled. A dashboard full of successful HTTP writes will miss all three. The post-apply diff will not.

Use set comparison rather than array comparison because DNS answer order is not meaningful. Also allow for propagation explicitly. A zero-delay read can report yesterday's answer even when the write was correct, so the verifier below polls with a bounded deadline instead of pretending DNS converges instantly.

Pick the control plane that already owns the boundary

Route 53 is the natural choice when hosted zones, permissions, and deployment identity are already in AWS. Its change model and official tooling keep the operational boundary in one place. The cost is deliberate coupling: the adapter knows AWS concepts, and a later move requires replacing it.

Cloudflare DNS is similarly sensible for zones already operated through Cloudflare. Its API is a direct automation target, and Terraform users can keep the desired configuration in the same review path as other infrastructure. Choose it for control-plane fit, not because a generic DNS script happened to be easy to write.

Google Cloud DNS belongs in the same category for Google Cloud projects. Project-scoped access and native infrastructure tooling are useful when that is where the rest of the system lives. Moving the record workflow elsewhere still means changing the provider-facing adapter.

There is another valid shape: preserve one capability contract while changing the vendor behind it. Infrai puts 295 routes across 20 modules under one key and one consistent REST API; for this workflow, the relevant benefit is that swapping the provider behind DNS does not change deployment code. Its public, keyless discovery surface supplies the current request schema. This is a good fit when provider portability is a stated requirement rather than a hypothetical future benefit. It is still another operational dependency, so I would choose it only when portability is a real requirement, not a slogan placed on a roadmap.

No choice removes the need for verification. Native provider, portable control plane, or an internal adapter: all should expose the same three operations to deployment code—capture current state, upsert desired state, and read back published state. Keep that interface small. It makes rollback boring.

Implement the cutover as one bounded transaction

The following Node.js program manages stable IPv4 records and calls the verified upsert route directly. It keeps the vendor request in upsertRequest, next to the human-readable intent. Populate that object from the public discovery schema rather than copying fields from prose; the API is self-describing, and this avoids freezing an undocumented payload shape into the article.

The program applies every desired record, verifies until a 60-second deadline, and exits nonzero on drift. A previous manifest is the rollback input: reverting the commit and rerunning the job applies the old request through the same idempotent path. That is safer than manufacturing a rollback payload from a DNS answer, which lacks provider-specific metadata. The sample uses only Node built-ins.

import { promises as dns } from "node:dns";
type RecordIntent = {
  hostname: string;
  type: "A";
  values: string[];
  upsertRequest: Record<string, unknown>;
};

const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
const manifestJson = process.env.DNS_RECORD_MANIFEST;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
if (!manifestJson) throw new Error("DNS_RECORD_MANIFEST is required");
const desired = JSON.parse(manifestJson) as RecordIntent[];

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const sorted = (values: string[]) => [...new Set(values)].sort();
const equal = (left: string[], right: string[]) =>
  JSON.stringify(sorted(left)) === JSON.stringify(sorted(right));

async function readPublished(hostname: string): Promise<string[]> {
  try {
    return sorted(await dns.resolve4(hostname));
  } catch (error) {
    const code = (error as NodeJS.ErrnoException).code;
    if (code === "ENOTFOUND" || code === "ENODATA") return [];
    throw error;
  }
}

async function upsert(record: RecordIntent): Promise<void> {
  const idempotencyKey = `dns-${record.hostname}-${JSON.stringify(record.upsertRequest)}`;

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${baseUrl}/dns/record/upsert`, {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(record.upsertRequest),
    });

    if (response.ok) return;
    const body = await response.text();
    if (response.status !== 429 || attempt === 4) {
      throw new Error(`upsert failed (${response.status}): ${body}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await sleep(delayMs);
  }
}

async function waitForMatch(record: RecordIntent, timeoutMs = 60_000): Promise<void> {
  const deadline = Date.now() + timeoutMs;
  let observed: string[] = [];

  while (Date.now() < deadline) {
    observed = await readPublished(record.hostname);
    if (equal(observed, record.values)) {
      console.log(JSON.stringify({
        event: "dns_cutover_verified",
        hostname: record.hostname,
        expected: sorted(record.values),
        observed,
      }));
      return;
    }
    await sleep(5_000);
  }

  throw new Error(
    `DNS drift for ${record.hostname}: expected=${sorted(record.values).join(",")} observed=${observed.join(",")}`,
  );
}

async function main(): Promise<void> {
  try {
    for (const record of desired) await upsert(record);
    for (const record of desired) await waitForMatch(record);
  } catch (error) {
    console.error(JSON.stringify({ event: "dns_cutover_failed", error: String(error) }));
    throw error;
  }
}

void main();
Enter fullscreen mode Exit fullscreen mode

The deployment identity should receive only the permissions required for this operation. The idempotency key makes a retry address the same logical change, while the bounded 429 branch honors Retry-After when the server supplies it. Other failures include the response body in the deploy error instead of being flattened into an unhelpful status code.

I prefer one loud deploy failure here to a quiet warning that somebody has to remember to inspect. The trade-off is availability during propagation: a strict gate can hold a release even when the accepted write is still converging. A bounded poll makes that policy visible, and the structured final event tells an operator whether the wait ended in agreement or drift.

No shrugging at drift.

One more trap: a recursive resolver may cache the pre-cutover answer. Point the deploy verifier at a resolver appropriate for the internal zone, and set the polling deadline from the zone's actual propagation expectations. Sixty seconds in the sample is a policy choice, not a DNS guarantee.

Test that resolver choice before cutover day.

Make drift a deploy signal, not a quiet log line

Emit one structured event for the final outcome, with the hostname plus expected and observed value sets. Alert on a failed reconciliation job. Do not alert on every polling mismatch; those intermediate mismatches are expected during propagation and create noise without changing the response.

The useful before/after is crisp:

Before, an engineer edits an internal record, the deploy reports success, and nobody can later connect the published answer to a reviewed change. After, a commit contains the desired set, the deploy upserts it, the verifier compares the live answer, and a mismatch blocks promotion. The log now explains both intent and observation.

Treat the failure as configuration drift first. Inspect the committed manifest, the adapter's accepted write, and the authoritative or designated internal resolver response. This ordering prevents a common mistake: repeatedly applying a correct change while reading from the wrong DNS view.

Know where this pattern stops

This loop is for stable names whose changes deserve code review: a marketplace checkout hostname, an internal admin endpoint, or a controlled cutover between fixed targets. It is not a substitute for service discovery. Instance addresses, ephemeral tasks, and health-driven endpoint membership change too quickly for pull requests and deployment gates; put those in a service registry.

Rollback also has a boundary. Restoring the previous record set changes DNS state, but clients may retain cached answers until their applicable TTL expires. Keep the old target healthy through the cutover window, and make application compatibility part of the rollback plan.

The decision rule is simple: use the DNS control plane that owns your zone, isolate its write behind a narrow adapter, and always compare repository intent with the published record. Portability can change which provider sits behind that contract. Verification should stay put.

Sources

Top comments (0)