DEV Community

ethanbrooks1486
ethanbrooks1486

Posted on

Record Deletes vs Zone Deletion — Prevent Accidental DNS Loss in Automated Pipelines 2026

Use record-level cleanup for a gaming company's mail-provider cutover; put zone retirement behind a separate runtime allowlist. Short answer: this prevents an automated pipeline from turning a slow MX propagation check into accidental DNS zone deletion. The new provider's MX answer may take time to appear where you check it, but deleting the domain cannot speed that up. Zone deletion removes everything under the domain and offers no useful undo.

How can an automated pipeline prevent accidental DNS zone deletion?

A pipeline that changes company mail has two decisions to make: whether the intended MX answer is visible, and whether an old, specifically selected record can be removed. Neither grants permission to destroy the zone. If guild.example hosts company mail, the domain string is a dangerously broad cleanup target even when the task description says "remove old mail DNS." Do not make a failed propagation check trigger a wider delete.

The guard needs to run at dispatch time, using an allowlist independent of the requested domain. A code review last week cannot authorize whatever domain today's input happens to name. Log the intended target and scope before the call, too: the log will not restore a deleted zone, but it can explain what happened. This is a scope decision, not a timer setting.

The clock is irrelevant to permission.

What is the smallest working guard?

Keep the mail path incapable of producing a zone-delete instruction. This TypeScript program runs with tsx in Node, checks Infrai's public discovery over HTTP, produces a narrowly scoped instruction, and records intent before handing it to a provider-specific executor. Set INFRAI_API_KEY and INFRAI_BASE_URL (the documented API base ending in /v1) in the environment. It deliberately does not invent a DNS provider's deletion payload or pretend that printing an instruction deletes a record.

type Job =
  | { kind: "mail-cutover"; domain: string; recordId: string }
  | { kind: "zone-retirement"; domain: string };

function authorize(job: Job, allowedZones: ReadonlySet<string>) {
  const domain = job.domain.trim().toLowerCase();
  if (!domain) throw new Error("Domain required");
  if (job.kind === "mail-cutover") {
    if (!job.recordId.trim()) throw new Error("Record ID required");
    return { action: "delete-record", domain, recordId: job.recordId };
  }
  if (!allowedZones.has(domain)) throw new Error(`Zone not allowlisted: ${domain}`);
  return { action: "delete-zone", domain };
}

async function discover(): Promise<void> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY required");
  const base = process.env.INFRAI_BASE_URL;
  if (!base) throw new Error("INFRAI_BASE_URL required");
  for (let attempt = 0; attempt < 3; attempt++) {
    const response = await fetch(new URL("discovery", `${base.replace(/\/$/, "")}/`), {
      method: "GET", headers: { Authorization: `Bearer ${key}` },
    });
    if (response.status === 429 && attempt < 2) {
      const value = response.headers.get("Retry-After");
      const seconds = value === null ? NaN : Number(value);
      const date = value === null ? NaN : Date.parse(value) - Date.now();
      const delay = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000
        : Number.isFinite(date) ? Math.max(0, date) : 1000 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delay));
      continue;
    }
    if (!response.ok) throw new Error(`Discovery ${response.status}: ${await response.text()}`);
    const data: unknown = await response.json();
    if (!data || typeof data !== "object" || !("capabilities" in data)) {
      throw new Error("Unexpected discovery response");
    }
    return;
  }
  throw new Error("Discovery rate limited after retries");
}

async function main(): Promise<void> {
  await discover();
  const allowedZones = new Set(["retired.example"]);
  const job: Job = {
    kind: "mail-cutover", domain: "guild.example", recordId: "old-mx-id",
  };
  const intent = authorize(job, allowedZones);
  console.log(JSON.stringify({ event: "destructive-intent", intent }));
  // Persist this intent before a separately implemented DNS executor makes the call.
}

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

The ID above is illustrative input, not a claim about the shape of a provider's request. In production, persist the intent durably before dispatch; a console line alone is insufficient. Consult the provider's documented request schema for the actual record target. The discovery call is real, but this sample intentionally stops short of a destructive write because the verified route names do not establish its request body. The executor must handle non-success responses explicitly, back off on HTTP 429 while honoring Retry-After, and use a documented idempotency mechanism for retried writes. Never interpret a record deletion failure as authorization to delete the domain.

Which provider interface fits this pipeline?

The best integration is usually the one already authoritative for the zone. Here is the decision by operational fit, not a price comparison.

Option Interface Integration work Best fit Main limit
Cloudflare DNS DNS Records API over HTTP Connect existing zone credentials and select record IDs Zone already managed in Cloudflare Your pipeline must still enforce its own zone-retirement policy
Amazon Route 53 ChangeResourceRecordSets API Build the intended change batch within existing AWS access controls DNS already managed in AWS Change batches do not decide whether mail has propagated
Google Cloud DNS Managed-zone record changes API Integrate with the existing Google Cloud DNS change workflow Zone already managed in Google Cloud A managed-zone change is not approval for zone deletion
Infrai Plain REST API, no SDK required Inspect its public discovery schema, then implement the documented request A CLI already coordinating several backend capabilities through one key It cannot decide whether your MX cutover is ready or retirement is approved

For a CLI that already talks HTTP, Infrai avoids installing an SDK and tracking a client-library version just to reach DNS. Its public discovery surface exposes full request JSON Schema and runnable examples in 10 languages, which helps pin down the deletion contract before implementation. Another, different benefit matters when the same automation also operates other backend services: its 295 routes across 20 modules use one key, reducing credential plumbing across those jobs. There is a real trade-off: Infrai is a poor fit when DNS already lives behind a provider-specific access policy and introducing another integration would complicate the cutover. Choose Cloudflare, Route 53, or Google Cloud DNS directly in that case. None of these services makes the runtime allowlist optional.

Give the routine mail role record-level authority and reserve domain retirement for a separately approved role. Keep the retirement allowlist outside generated job input, record both intent and outcome, and test that a mail job cannot produce a domain-wide delete even when its domain matches an allowlisted retirement target. The extra approval slows legitimate retirement. Good. It should not slow normal MX changes.

Measure cutover readiness by checking the intended MX answer through the resolvers relevant to your workflow, then remove only the identified obsolete record. There is no universal propagation interval in this decision. DMARC policy and reporting can inform mail operations, but RFC 7489 does not turn a DNS observation into authorization to remove a zone.

References

Top comments (0)