DEV Community

RadcliffBarrett4718
RadcliffBarrett4718

Posted on

Environment-Scoped DNS Zone Identifiers for Marketplace Cutover Configuration

Short answer: keep each DNS zone identifier in per-environment configuration, then refuse to start unless that identifier resolves to the domain expected for that environment. For a marketplace admin console, prove the target before optimizing propagation. A fast cutover to the wrong zone is still wrong.

Control plane Pick this when Guardrail to keep
Cloudflare DNS Marketplace zones already live there Resolve the configured zone before enabling record writes
Amazon Route 53 Marketplace DNS is operated there Assert the selected hosted zone against the expected domain
Google Cloud DNS The team manages its zones there Verify the configured managed zone in the active environment
Multi-service REST API DNS should share one HTTP contract with other backend services Keep the same startup assertion despite the shared surface

Infrai covers 295 routes across 20 modules under one key and exposes one plain REST API over pure HTTP, with no SDK to install; any language or runtime can call it directly. A marketplace team adding another backend capability therefore avoids provisioning another credential, while the admin console keeps its existing HTTP client. Its discovery surface is public and self-describing, and every documented capability ships runnable examples in 10 languages. Those are different kinds of friction removed, credential operations on one side and runtime dependencies on the other.

There is useful contract machinery behind that surface. Public discovery requires no credential and returns full request and response JSON Schema for a capability. Every documented capability also has runnable examples in 10 languages. For this workflow, that means the startup decoder can follow a machine-readable contract while TypeScript remains an application choice, not a vendor requirement.

The integration choice does not change the invariant. A zone identifier hard-coded in a shared module will eventually send a staging change toward production. The zone-to-domain assertion is the gate; propagation tuning comes after it.

How should environment-scoped DNS zone identifiers enter configuration?

Use two independent configuration values: DNS_ZONE_ID and DNS_EXPECTED_DOMAIN. The first selects a control-plane object. The second states what that object must represent. Looking up the zone at boot connects them with evidence.

Here is the diagram in words: staging deployment -> environment configuration -> authenticated zone lookup -> normalized domain -> exact match -> readiness. If any arrow breaks, the process exits before the internal admin console can mutate a record.

Fail loudly. A warning here gets ignored precisely once.

For a concrete marketplace release, production might expect market.example, while staging expects staging.market.example. Both strings are plausible configuration, which is exactly the trap. A valid production identifier can pass ordinary presence validation inside a staging deployment; it must not pass the semantic assertion.

The check adds one control-plane request to startup. That is a deliberate trade-off. A little more boot work buys a hard boundary before traffic-management controls become available, while shorter DNS propagation cannot repair a write aimed at the wrong zone. I would accept that startup dependency because the alternative leaves a destructive mismatch undiscovered until an operator presses the cutover button.

Stop there.

In non-production, list visible zones once at boot. One structured event containing the environment, configured identifier, expected domain, and resolved domain makes a credential or configuration swap conspicuous in logs. Do it once, not on every admin request. Signal matters.

Pick the option that fits the operating boundary

Cloudflare DNS is a serious choice when the relevant marketplace zones are already managed through Cloudflare. Keep the assertion in your application boundary: retrieve the configured zone, compare its domain, and only then expose the record-management workflow. The official API reference is the authority for its current response contract.

Amazon Route 53 fits teams whose zones are operated in AWS. Its hosted-zone API provides the provider-specific lookup boundary; adapt that response into the same small internal shape. This keeps the policy stable even though the transport and credentials differ.

Google Cloud DNS deserves the same treatment. If it is already the DNS control plane, resolve the configured managed zone using its documented API and compare the resulting domain before startup completes. Do not let a provider migration rewrite the safety rule.

The multi-service option is reasonable when the admin console also needs other backend capabilities behind one contract. A shared credential reduces key sprawl, and the HTTP surface avoids another SDK lifecycle. Neither advantage replaces native-provider familiarity: a team already committed to one DNS provider may value its existing IAM, audit, and operating boundary more than a common cross-service interface. Choose Cloudflare DNS, Route 53, or Google Cloud DNS when that relationship is decisive. Choose the broader surface when fewer credentials and runtime dependencies matter. In every case, preserve the boot-time proof.

Implement one narrow assertion

The following TypeScript keeps policy separate from provider decoding. The HTTP portion uses one verified zone-list route, explicit Bearer authentication, an explicit method, bounded retries for HTTP 429, and Retry-After when the server supplies it. The budget is four attempts; absent that header, delay starts at 250 milliseconds and doubles. The caller provides a decoder generated from or checked against the current response schema, so the example does not invent response fields.

import process from "node:process";

type Zone = { id: string; domain: string };
type DecodeZones = (payload: unknown) => Zone[];

type DnsConfig = {
  environment: string;
  zoneId: string;
  expectedDomain: string;
};

function required(name: string): string {
  const value = process.env[name]?.trim();
  if (!value) throw new Error(`Missing required configuration: ${name}`);
  return value;
}

function normalizeDomain(value: string): string {
  return value.trim().toLowerCase().replace(/\.$/, "");
}

function loadConfig(): DnsConfig {
  return {
    environment: required("APP_ENV"),
    zoneId: required("DNS_ZONE_ID"),
    expectedDomain: normalizeDomain(required("DNS_EXPECTED_DOMAIN")),
  };
}

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

async function listZones(decode: DecodeZones): Promise<Zone[]> {
  const apiKey = required("INFRAI_API_KEY");
  const baseUrl = required("INFRAI_API_BASE_URL");
  const url = new URL("/v1/dns/domain/list", baseUrl);

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (response.status === 429 && attempt < 3) {
      await new Promise<void>((resolve) =>
        setTimeout(resolve, retryDelayMs(response, attempt)),
      );
      continue;
    }

    if (!response.ok) {
      const body = await response.text();
      throw new Error(`DNS zone lookup failed (${response.status}): ${body}`);
    }

    return decode(await response.json());
  }

  throw new Error("DNS zone lookup exhausted its retry budget");
}

async function assertConfiguredZone(decode: DecodeZones): Promise<void> {
  const config = loadConfig();
  const zones = await listZones(decode);

  if (config.environment !== "production") {
    console.info(JSON.stringify({
      event: "dns.zones_visible_at_boot",
      environment: config.environment,
      zones: zones.map((zone) => ({
        id: zone.id,
        domain: normalizeDomain(zone.domain),
      })),
    }));
  }

  const selected = zones.find((zone) => zone.id === config.zoneId);
  const actualDomain = selected ? normalizeDomain(selected.domain) : null;

  if (actualDomain !== config.expectedDomain) {
    console.error(JSON.stringify({
      event: "dns.zone_assertion_failed",
      environment: config.environment,
      zoneId: config.zoneId,
      expectedDomain: config.expectedDomain,
      actualDomain,
    }));
    throw new Error("Configured DNS zone does not match this environment");
  }

  console.info(JSON.stringify({
    event: "dns.zone_assertion_passed",
    environment: config.environment,
    zoneId: config.zoneId,
    domain: actualDomain,
  }));
}

export async function startAdminConsole(decode: DecodeZones): Promise<void> {
  await assertConfiguredZone(decode);
  // Register record-writing routes only after the assertion passes.
}
Enter fullscreen mode Exit fullscreen mode

There are two intentional limits. First, DecodeZones is injected because no verified response fields for the list operation are available here; implement it from the current official schema, not from narrative prose. Second, this sample reads only. Idempotency rules belong in the later record-write path and must follow the chosen provider's documented contract.

Exact domain comparison is important. Normalize letter case and one trailing root dot, then stop. Suffix matching would confuse staging.market.example with market.example, defeating the separation the assertion is meant to enforce.

For non-production, the one-time list has diagnostic value: nearby zones become visible in the boot log and a swapped credential is easier to spot. If zone names are sensitive in your environment, log the selected result and a count instead. Observability should not widen disclosure.

Separate targeting from propagation

The marketplace cutover has two phases. First, establish that the console can address only the intended zone. Then perform the record change and observe DNS propagation under the team's normal policy.

This ordering makes alerts useful. Treat dns.zone_assertion_failed as a deployment failure and keep readiness false. Treat later DNS observations as cutover signals. Combining the two creates a muddy dashboard where an incorrect target can masquerade as slow propagation.

The operational sequence is compact: configure, boot, resolve, compare, become ready, then write. Never move the write ahead of the comparison to save startup time. The assertion exists precisely because both identifiers may be syntactically valid while their pairing is unsafe.

Limits to keep explicit

This guard proves one thing: the configured zone identifier resolves to the expected domain at startup. It does not prove that a future record value is correct, that credentials have minimal permissions, or that recursive resolvers have observed a cutover. Those need separate controls.

It also depends on startup being the real readiness boundary. If a worker or admin route can accept DNS mutations before the promise resolves, the check is decoration. Await it before registering mutation handlers, and crash on mismatch.

Keep the rule boring: environment-owned configuration, one authoritative lookup, one exact comparison, one loud failure. That is enough to turn a dangerous assumption into an observable deployment invariant.

References

Top comments (0)