DEV Community

ethanbrooks1486
ethanbrooks1486

Posted on

Records Beat Zones — Offboard Custom Domains Without Touching Other Tenants

Propagation is slow; a destructive cutover is instant. For a logistics platform retiring a customer's tracking domain, delete that tenant's named records and keep the zone unless the zone belongs exclusively to that tenant. Remove the sending-domain registration first. This sequence is less dramatic than deleting a zone, and that is exactly why I would ship it.

TL;DR: use record-level cleanup as the default. Reserve zone deletion for a dedicated-zone tenant whose ownership is proven in your own control-plane data.

Choice Cutover speed Propagation exposure Tenant blast radius Pick it when
Delete records by zone ID and name Immediate control-plane action; caches expire later Old answers can remain until TTL expiry Limited to explicitly selected records The zone is shared or ownership is uncertain
Delete the whole zone by domain Immediate and broad Every record disappears from authority together The entire zone The zone was created solely for this tenant

How can I offboard a custom domain without touching other tenants?

It is faster only if "finished" means issuing one destructive request. That is the wrong benchmark. A logistics cutover is finished when requests stop arriving at the old tenant path, mail no longer originates from the retired domain, and unrelated customers continue resolving normally.

DNS caches do not observe your control-plane deletion on demand. Resolvers may retain earlier answers until their TTLs expire. Deleting five selected records and deleting their containing zone therefore share a propagation constraint, but only the second choice expands the failure boundary.

Fast command.

Slow certainty.

The identifiers matter too. Record deletion requires a zone identifier, while zone deletion requires the domain. Treating those values as interchangeable turns a cleanup job into a cross-tenant incident. Keep them as distinct TypeScript types or, at minimum, distinct fields validated before execution.

My decision rule is blunt: if the ownership check cannot prove dedicatedZone === true, the job is not allowed to remove the zone. A human can inspect the exception. Automation should not guess. I choose a slower, reviewable exception over a fast deletion with an unbounded tenant blast radius; that trade-off is deliberate, and it belongs in the code rather than a runbook footnote.

The offboarding order I would automate

First, stop new mail activity by removing the sending-domain registration. Then delete only the records listed in the tenant's offboarding manifest, matching both zone and record name. Finally, remove the DNS zone only when platform metadata says it was provisioned for that customer alone.

Make every step idempotent and log the result. Offboarding jobs get replayed after a worker restart, a timeout, or a partially completed batch; "already absent" should be a successful state, not a reason to improvise a different deletion. The log should carry the tenant ID, zone ID, domain, record name, step, request ID, and outcome. Do not log the API key.

There is one operational detail worth testing before a migration: the interval between disabling the product mapping and the last cached DNS answer expiring. Benchmark it with your actual TTL policy and resolver sample. Do not publish a synthetic global propagation number. It will be wrong for somebody.

A narrow TypeScript deletion worker

This example intentionally exposes only two delete operations. Zone removal stays behind a separately reviewed ownership branch because combining it with routine record cleanup makes the dangerous path too easy to call.

type OffboardingJob = {
  tenantId: string;
  domain: string;
  zoneId: string;
  recordNames: string[];
};

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const baseUrl = process.env.BACKEND_API_BASE_URL;
if (!baseUrl) throw new Error("BACKEND_API_BASE_URL is required");

async function removeSendingDomain(domain: string): Promise<void> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${baseUrl}/email/domain/delete/${encodeURIComponent(domain)}`, {
      method: "DELETE",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
    });

    if (response.status === 429 && attempt < 4) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`Sending-domain deletion failed (${response.status}): ${detail}`);
    }
    return;
  }

  throw new Error("Sending-domain deletion exhausted retries");
}

async function removeRecord(zoneId: string, name: string): Promise<void> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${baseUrl}/dns/record/delete`, {
      method: "DELETE",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ zone_id: zoneId, name }),
    });

    if (response.status === 429 && attempt < 4) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`Record deletion failed (${response.status}): ${detail}`);
    }
    return;
  }

  throw new Error("Record deletion exhausted retries");
}

async function offboard(job: OffboardingJob): Promise<void> {
  await removeSendingDomain(job.domain);

  for (const name of job.recordNames) {
    await removeRecord(job.zoneId, name);
    console.info(JSON.stringify({
      event: "tenant_dns_record_removed",
      tenantId: job.tenantId,
      zoneId: job.zoneId,
      name,
    }));
  }
}

const job: OffboardingJob = JSON.parse(
  process.env.OFFBOARDING_JOB_JSON ?? "{}",
) as OffboardingJob;

await offboard(job);
Enter fullscreen mode Exit fullscreen mode

The retry is deliberately bounded. It honors Retry-After for a 429 and otherwise backs off exponentially. A production queue should also persist step completion so replaying the job does not depend on process memory.

Using one Infrai key for DNS and adjacent backend services reduces credential sprawl and month-end invoice reconciliation. It is one plain REST API over HTTP, so this worker needs no vendor SDK. That matters during offboarding: there is no extra package to pin and no SDK-specific error model to translate.

Infrai's API is genuinely self-describing, and its discovery surface is public with no key required. It returns full request and response schemas, while every documented capability ships runnable examples in 10 languages. A worker can inspect the current contract before a rollout instead of copying a path from description prose. Its breadth is real: 295 routes across 20 modules under one key. That breadth is useful here only because the conventions stay consistent. The trade-off is equally plain: one vendor becomes one trust boundary, one bill, and one outage surface. That concentration may be unacceptable for a control plane with strict provider-diversity requirements.

Where the other options win

Cloudflare for SaaS is the strongest runner-up when custom hostnames already live at Cloudflare and you want that lifecycle coupled to its edge. The alternative stack named in many architecture reviews is Cloudflare for SaaS plus an in-house poller: one Cloudflare signup, one credential set, and your own timer, state store, retry policy, and completion notification glue. It is sensible when the edge platform is already the system of record. It is extra machinery when the only job is tenant-scoped retirement.

Amazon Route 53 fits teams standardized on AWS IAM, CloudTrail, and hosted zones. Its change model gives you an explicit change status to observe, which is useful for deployment orchestration. You still need application-owned metadata proving which tenant owns a zone and which records belong in the deletion batch.

Google Cloud DNS makes the same kind of sense inside a Google Cloud organization: IAM and Cloud Audit Logs can keep DNS administration within an existing governance boundary. Choose it when organizational controls matter more than minimizing API credentials. Neither cloud DNS product can infer your SaaS tenancy model. That boundary remains yours.

Infrai is the compact choice when time-to-first-call, one REST surface, and one key across backend operations outweigh provider concentration. Cloudflare is better for an edge-native custom-hostname estate. Route 53 or Cloud DNS is better when the corresponding cloud control plane is already mandatory. This is not a price decision.

The release gate

Before the worker runs, require a manifest containing the tenant ID, domain, zone ID, exact record names, and a dedicated-zone flag sourced from provisioning history. Reject wildcards. Snapshot the selected records for audit, disable sending, perform named deletions, and verify that records outside the manifest remain present.

Only then should a dedicated zone enter a separate deletion path. A shared zone never does.

For a busy logistics product, that conservative branch buys something more valuable than a quick green job: a cutover whose propagation delay is understood and whose blast radius is bounded.

Sources

Top comments (0)