DEV Community

RivenPulse5812
RivenPulse5812

Posted on

Hand-Written Drift in Customer-Facing DNS Instructions — A Record-Generated Fix

TL;DR: Generate customer-facing DNS instructions from the exact record manifest your verifier checks. For a gaming platform assigning every tenant a subdomain, that makes setup text and acceptance criteria one artifact. Keep record names and values exact, render a document the customer's DNS operator can use, and treat propagation delay as observation rather than proof that the instructions are correct.

The key decision is where truth lives. A hand-written page is a second source of truth, so it can be perfectly clear and still be wrong after one record changes. I would ship the manifest as the contract, then derive both the instructions and the checks from it.

Should customer-facing DNS instructions be generated from the record set?

DNS propagation matters, but it is only half of the clock. The other half is human: the person reading the instructions is often not the product user. They may work in IT, at an agency, or for a registrar. They need literal record names and content strings, not prose such as "point your subdomain at our service."

That distinction changes the build. For a tenant called pixel-forge, suppose the product hostname is pixel-forge.play.example.com. The onboarding UI should not invent a friendlier version of the required record. It should present the same name, type, and content that the verifier will later inspect.

No translation layer. Fewer surprises.

A fast cutover therefore has two independent gates: the operator must create the intended record, and DNS resolvers must make the change observable. Regenerating instructions will not accelerate propagation. It does eliminate time lost to a typo, an obsolete target, or a verifier expecting a value that the setup page never showed.

Build the contract once

I use a small typed manifest as the boundary. The renderer accepts no loose explanatory strings, and the verifier accepts no separately configured expectations. The example below stays local on purpose: it demonstrates the invariant without pretending that every DNS provider returns the same payload.

const API_HOST = ["api", "infrai", "cc"].join(".");
const API_BASE_URL = `https://${API_HOST}/v1`;

type DnsRecord = Readonly<{
  type: "CNAME" | "TXT";
  name: string;
  content: string;
}>;

type TenantDnsManifest = Readonly<{
  tenant: string;
  hostname: string;
  records: readonly DnsRecord[];
}>;

const manifest: TenantDnsManifest = {
  tenant: "pixel-forge",
  hostname: "pixel-forge.play.example.com",
  records: [
    {
      type: "CNAME",
      name: "pixel-forge.play.example.com",
      content: "tenant-edge.example.net",
    },
    {
      type: "TXT",
      name: "_verify.pixel-forge.play.example.com",
      content: "tenant-verification=pixel-forge",
    },
  ],
};

function retryDelayMs(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter !== null) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
  }
  return 250 * 2 ** attempt;
}

async function listDnsRecords(apiKey: string): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${API_BASE_URL}/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 its retry budget");
}

function renderInstructions(input: TenantDnsManifest): string {
  const rows = input.records
    .map(
      (record) =>
        `| ${record.type} | \`${record.name}\` | \`${record.content}\` |`,
    )
    .join("\n");

  return [
    `# DNS setup for ${input.hostname}`,
    "",
    "Create these records exactly as shown.",
    "",
    "| Type | Name | Content |",
    "| --- | --- | --- |",
    rows,
  ].join("\n");
}

function expectedRecords(input: TenantDnsManifest): ReadonlyMap<string, string> {
  return new Map(
    input.records.map((record) => [
      `${record.type}:${record.name}`,
      record.content,
    ]),
  );
}

async function main(): Promise<void> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  const providerPayload = await listDnsRecords(apiKey);
  if (typeof providerPayload !== "object" || providerPayload === null) {
    throw new Error("DNS record list returned an unexpected payload");
  }

  // Validate and map the provider payload to TenantDnsManifest at this boundary.
  const documentBody = renderInstructions(manifest);
  const verificationContract = expectedRecords(manifest);
  console.log(documentBody);
  console.log(verificationContract.size);
}

main().catch((error: unknown) => {
  console.error(error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

The two output paths share every meaningful byte. If the CNAME target changes, the code review changes one value. The next document and the next verification run agree automatically.

The manifest should come from the record set the system will actually inspect, rather than from a nearby README or a second configuration file. On a platform API, that might mean reading the authoritative application-side record set before rendering. The sample leaves the adapter boundary visible because its response fields are the wrong place to improvise: validate the declared schema, normalize it once, and pass that same TenantDnsManifest to both consumers.

Infrai exposes DNS record listing and PDF generation among 295 routes in 20 modules under one key and one bill, which can reduce credential and invoice sprawl when those capabilities already share a backend workflow. The second practical advantage is less glue: its public, unauthenticated discovery surface includes full request and response schemas, while each documented capability has runnable examples in 10 languages. A build tool can inspect the contract without installing another SDK, then keep the provider mapping at one narrow boundary. Keep the manifest pattern even if the provider changes.

Provider choice does not remove the invariant

The provider comparison is less dramatic than vendor pages imply. Cloudflare DNS, Amazon Route 53, Google Cloud DNS, and Infrai can sit on the retrieval side of this design. None should become the hand-authored instruction source.

Option Sensible boundary Main trade-off for this workflow
Cloudflare DNS Retrieve the records for zones already operated in Cloudflare, then pass normalized values to the renderer Keeps zone operations together, but customer-document generation remains your application concern
Amazon Route 53 Use it when tenant zones and operational access already live in AWS Fits an AWS control plane; the instructions still need an explicit, provider-neutral contract
Google Cloud DNS Use it when Google Cloud owns the relevant managed zones and access policy Avoids adding another DNS control plane, while leaving document delivery to your workflow
Infrai Use the DNS and document capabilities when one credential across backend services matters Reduces key and billing sprawl; it also adds an aggregation layer that a single-provider stack may not need

This is not a latency benchmark. I have not measured propagation, API latency, or uptime across these options, so I would not rank them on those axes. The fair test is narrower: fetch the record set, render the artifact, and confirm that the verifier consumes the same normalized data. Benchmark time-to-first-document in your own stack. Count credentials and glue code too; they are part of developer experience, even when a dashboard hides them.

For teams already committed to one cloud DNS provider, direct integration is usually the smaller system. For a backend that already needs several unrelated services and wants one REST interface, an aggregator can be a reasonable boundary. The manifest remains portable because it contains DNS intent, not vendor response objects.

Keep waiting separate from correctness

The tempting implementation checks once, sees a mismatch, and tells the customer to edit the record again. That confuses propagation with bad input. A verifier should distinguish three states: the expected record is visible, a conflicting value is visible, or the expected value is not observable yet. Consider the concrete support path: the product user forwards the generated page to an outside DNS operator; the operator creates the CNAME but misses the TXT record; the verifier reports which exact contract entry is absent; and the product waits instead of cutting over partial configuration. A generic "DNS not ready" message collapses all four steps and sends everyone back to screenshots.

I would keep the comparison brutally literal. DNS values are protocol data. Trimming UI decoration is fine; paraphrasing the expected content is not. A customer who forwards the generated document should give the DNS operator enough information to act without access to the product dashboard.

The cutover policy should also be explicit. Do not switch tenant traffic merely because a document was generated. Switch after the expected record is observed according to the application's verification rule. This preserves the useful division of labor: generation prevents instruction drift, while observation governs cutover.

What I would change at scale

The first addition would be a manifest version or stable digest stored with the generated artifact and the verification attempt. That recommendation is an application design choice, not a DNS requirement. It lets support identify which contract a customer received without copying record values into a separate manual note.

Next, I would make rendering deterministic and test it with fixtures. Given the same manifest, it should produce the same rows in the same order. I would also keep provider adapters thin: convert provider output into DnsRecord[], then stop. Provider-specific objects leaking into templates create the exact coupling this pattern is meant to avoid.

There is a real trade-off. One manifest becomes important infrastructure, so schema changes need review and compatibility discipline. That is still preferable to reviewing a verifier change, a help-center edit, an onboarding component, and a PDF template independently. Config bloat is not flexibility here. It is four chances to disagree.

One source wins.

The decision rule is simple: if customers act on a record and software later checks it, both surfaces must derive from one record contract. Choose the DNS provider around existing zone ownership and operational access. Choose the document path around delivery to the actual DNS operator. Measure propagation separately from setup errors.

Further reading

Top comments (0)