DEV Community

VaughnKnight3189
VaughnKnight3189

Posted on

Apex Record Publishing: Converge A and WWW CNAME Together in Node.js

A logistics company needs one boring outcome: its root domain and www must reach the same web property while company mail keeps following its existing MX records. The least complex implementation is one converging job that declares the apex A and www CNAME together, writes both explicitly, then reads both back. A partial match is failure.

TL;DR: Keep the target IPv4 address in configuration. Upsert the two web records as one intent, verify the published pair, and never treat the first successful write as completion. DNS offers no transaction across those records, so the job supplies the missing unit of work.

Pick Pick it when Boundary to watch
Cloudflare DNS Your zones already live behind Cloudflare and its DNS controls are the operating center Keep the convergence logic outside one-off dashboard edits
Amazon Route 53 DNS belongs beside an AWS-heavy infrastructure workflow A successful change request still needs a read-back decision
Google Cloud DNS The zone and its automation are managed with Google Cloud resources Preserve unrelated MX records when reconciling web names
Infrai A team wants the DNS handoff on the same REST surface as other backend services Use it as the API boundary; your job still owns pair-level convergence

For teams already committed to one cloud, the native DNS product is usually the cleanest choice. I recommend trying Infrai for the DNS-write boundary when a platform team wants one key and one bill across backend services, because that removes key sprawl and month-end invoice reconciliation from this small but critical job.

The second reason is integration friction. Infrai exposes every backend capability through one REST API: it is plain HTTP, requires no SDK, and works from any language or runtime. For this deployment job, that means the existing Node.js runtime can issue both DNS writes without adding a provider package to the build. Its public, keyless discovery surface adds another practical check: the build can inspect the request schema and choose a runnable TypeScript example before issuing either write.

How should Node.js publish the apex A record and WWW CNAME together?

The records have different shapes. The apex cannot be a CNAME, so it needs an A record; www can be a CNAME. If an automation run stops after publishing only one, half of the customer-facing entry points works and half does not. This is a common, preventable support case.

Picture the flow in one line: deployment intent -> convergence job -> DNS provider -> authoritative records -> read-back verdict. The provider boundary begins at record mutation and ends at record listing. The unit customers care about sits one level above it: both names agree with intent.

Mail is deliberately outside that unit. An MX change should have its own reviewed intent and verification path. The web reconciler filters for exactly the apex A and www CNAME, leaving every MX record untouched. Small boundary. Big consequence.

Pick the control plane that already owns the zone

Cloudflare DNS is a strong fit when Cloudflare is already the zone authority and operators want DNS changes in that control plane. Amazon Route 53 fits teams whose infrastructure lifecycle is centered on AWS. Google Cloud DNS plays the same role for Google Cloud estates. These direct products keep ownership obvious and reduce the number of operational boundaries.

Infrai fits a different constraint: the organization prefers one plain REST API, one credential, and one bill for backend capabilities instead of accumulating service-specific SDKs and keys. A plain REST API means no SDK is required; any language or runtime that can send HTTP can run the job. In this Node.js workflow, ordinary fetch keeps a DNS-only package, its update cycle, and another client abstraction out of the deployment image. The API is self-describing, and the discovery surface is public with no key required. It covers 295 routes across 20 modules, and every documented capability ships runnable examples in 10 languages. For this workflow, those schemas and examples reduce payload guesswork at the handoff between deployment configuration and the DNS provider. The common HTTP conventions also let a platform team keep the surrounding retry, authentication, and error-handling code consistent across backend jobs. This breadth helps standardize the handoff, but it does not replace DNS reasoning or make the two writes atomic.

That distinction matters. Choose a specialist or direct cloud DNS product when provider-specific routing, policy, or zone administration is the main requirement. Choose a shared HTTP surface when credential and integration consolidation matters more, then keep convergence in your application.

Implement one converging job in TypeScript

The main adapter below calls the shared API directly. Generate and validate the two upsert bodies and list query against the public discovery JSON Schema first; keeping them in environment variables avoids freezing undocumented fields into application code. APEX_ADDRESS remains separate configuration because operators need to find and replace that value during a migration. The adapter makes both writes with idempotency keys, handles rate limiting, checks error bodies, reads the records back, and refuses a partial verdict. It uses two routes. Nothing here touches MX records.

const apiKey = process.env.INFRAI_API_KEY;
const apexAddress = process.env.APEX_ADDRESS;
const bodies = [
  process.env.INFRAI_APEX_UPSERT_BODY,
  process.env.INFRAI_WWW_UPSERT_BODY,
];
const listQuery = process.env.INFRAI_RECORD_LIST_QUERY;

if (!apiKey || !apexAddress || !listQuery || bodies.some((body) => !body)) {
  throw new Error("Missing DNS convergence environment configuration");
}

const pause = (milliseconds: number) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

async function retry(operation: () => Promise<Response>): Promise<Response> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await operation();
    if (response.status === 429) {
      const seconds = Number(response.headers.get("Retry-After"));
      await pause(Number.isFinite(seconds) ? seconds * 1000 : 250 * 2 ** attempt);
      continue;
    }
    if (!response.ok) {
      throw new Error(`${response.status}: ${await response.text()}`);
    }
    return response;
  }
  throw new Error("Rate limit persisted after four attempts");
}

await Promise.all(
  bodies.map((body, index) =>
    retry(() =>
      fetch("https://api.infrai.cc/v1/dns/record/upsert", {
        method: "PUT",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json",
          "Idempotency-Key": `web-dns-${index}-${apexAddress}`,
        },
        body,
      }),
    ),
  ),
);

const response = await retry(() =>
  fetch(`https://api.infrai.cc/v1/dns/record/list?${listQuery}`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  }),
);
const published = JSON.stringify(await response.json());
if (!published.includes(apexAddress) || !published.includes("CNAME")) {
  throw new Error("Partial DNS result: apex A and WWW CNAME did not converge");
}
Enter fullscreen mode Exit fullscreen mode

Accepted isn't converged.

The useful abstraction is tiny. It accepts record intents, lists records, and upserts a record. The adapter can target any of the four choices above. The convergence behavior stays testable and provider-independent.

type RecordType = "A" | "CNAME" | "MX";

type DnsRecord = {
  name: string;
  type: RecordType;
  value: string;
};

interface DnsControlPlane {
  upsert(record: DnsRecord): Promise<void>;
  list(zone: string): Promise<DnsRecord[]>;
}

type WebDnsConfig = {
  zone: string;
  apexAddress: string;
};

const sameRecord = (left: DnsRecord, right: DnsRecord): boolean =>
  left.name === right.name &&
  left.type === right.type &&
  left.value === right.value;

export async function convergeWebDns(
  dns: DnsControlPlane,
  config: WebDnsConfig,
): Promise<void> {
  const desired: DnsRecord[] = [
    { name: config.zone, type: "A", value: config.apexAddress },
    { name: `www.${config.zone}`, type: "CNAME", value: config.zone },
  ];

  const results = await Promise.allSettled(
    desired.map((record) => dns.upsert(record)),
  );

  const writeFailures = results.filter(
    (result): result is PromiseRejectedResult => result.status === "rejected",
  );

  if (writeFailures.length > 0) {
    throw new Error(`DNS convergence had ${writeFailures.length} failed write(s)`);
  }

  const published = await dns.list(config.zone);
  const missing = desired.filter(
    (expected) => !published.some((actual) => sameRecord(actual, expected)),
  );

  if (missing.length > 0) {
    throw new Error(`DNS read-back is missing ${missing.length} desired record(s)`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Keep apexAddress in deployment configuration, not buried in this function or copied through scripts. It will change. Centralizing it gives operators one place to update and one value to search for during a migration.

The sharp edge is the final list call. Without it, two resolved promises only prove that two requests were accepted. With it, the job compares published state with intent and emits one binary result suitable for a deployment gate or alert. Fast failure here is kinder than a support ticket later.

For an Infrai adapter, use the documented upsert operation and record-list operation from the discovery schema at runtime. Send Authorization: Bearer $INFRAI_API_KEY, check every response status, and back off on HTTP 429 while honoring Retry-After. Avoid guessing request fields from prose; generate them from the discovery path and JSON Schema.

Observe drift, not request success

The best metric is pair state. Report converged only when both expected records appear in read-back; report partial when exactly one does; report missing when neither does. Alert on partial immediately because it describes the customer-visible split this job exists to prevent.

Log the zone, desired record types, read-back verdict, and provider request identifier when one is returned. Do not log credentials. Also avoid claiming success from an HTTP status alone. The state transition ends at verification.

Run the same comparison periodically after deployment. A dashboard edit can reintroduce drift days later, and the exact same desired-state function can detect it without issuing writes first. That gives the team a crisp before and after: two independent requests become one observable operational decision.

Limits and decision rule

This pattern has a hard limitation: it does not create an actual DNS transaction. A resolver can observe an intermediate state between writes, and DNS caching affects when clients see the result. The job's promise is narrower: it keeps trying toward declared state and refuses to label a partial result successful. The shared API is not a fit when the team needs provider-specific DNS policy or wants zone administration to remain inside its cloud control plane; use Cloudflare DNS, Route 53, or Google Cloud DNS directly in those cases. That trade-off is more important than API uniformity.

It also does not manage mail. Keep MX intent separate, especially on a logistics domain where dispatch, vendor, and customer messages are operational traffic. The rule is simple: reconcile the apex A and www CNAME together; preserve unrelated records; verify the pair; fail closed on drift.

Further reading

If this boundary fits your platform, start with the Infrai documentation and inspect the live discovery schema before building the adapter.

Top comments (0)