DEV Community

TateFletcher6754
TateFletcher6754

Posted on

Healthtech DNS Zones: Record Operations Need Stable Identifiers During Migration

A healthtech migration has one constraint that changes the whole design: some DNS zones belong to customers, while others belong to the platform. TL;DR: treat a zone as the unit of DNS authority, save its stable identifier as soon as the domain is added, and scope every record operation to that identifier. The domain name is a display value that can be repointed. The identifier is the API handle.

That distinction matters most at the DNS-to-email boundary. SPF and DKIM records are not loose strings in a global record bucket. They live inside one zone. If the mail configuration changes, the system must know exactly which authority owns the records it should inspect or update.

Why can't a record operation use the domain name?

A domain name looks unique to a human. It is still the wrong database key. It can be repointed, transferred, or represented in more than one operational context during a migration. A zone identifier stays attached to the API resource that owns the records.

Picture the model in words: a customer account points to a zone; the zone has one stable ID; records hang below that ID. There is no global record namespace to search. Listing records therefore needs the zone ID, and deletion needs it for the same reason.

This also makes the blast radius legible. Deleting a record removes one child within the selected zone. Deleting the zone removes the authority container and everything scoped beneath it. Those are different operations, and the identifier forces the caller to say which container it means.

Before migration, teams often keep a row like clinic.example -> registrar account A and rediscover the provider's internal zone on every job. After migration, keep clinic.example -> zone_42 -> customer-owned in the control plane. zone_42 is illustrative application data, not a claimed vendor response value. The durable idea is the mapping.

Short names lie. Scope does not.

That is the trap.

Model ownership before moving records

For a healthtech platform, ownership should be explicit data rather than an inference from the domain suffix. A customer-owned zone means the customer retains authority and the platform publishes only the records it has been permitted to manage. A platform-owned zone means the platform controls the whole zone and can coordinate DNS and mail changes as one lifecycle.

Here is a small TypeScript model for the migration inventory. It deliberately separates the human-readable name from the provider handle.

type ZoneOwner = "customer" | "platform";

type ManagedZone = {
  tenantId: string;
  domain: string;
  zoneId: string;
  owner: ZoneOwner;
  mailDomain: string;
};

const zones: ManagedZone[] = [
  {
    tenantId: "clinic-017",
    domain: "messages.clinic.example",
    zoneId: "zone_42",
    owner: "customer",
    mailDomain: "messages.clinic.example",
  },
  {
    tenantId: "care-network-204",
    domain: "notify.care.example",
    zoneId: "zone_91",
    owner: "platform",
    mailDomain: "notify.care.example",
  },
];

function requireZoneId(zone: ManagedZone): string {
  if (!zone.zoneId) throw new Error(`Missing zone ID for ${zone.domain}`);
  return zone.zoneId;
}
Enter fullscreen mode Exit fullscreen mode

Persist that mapping when the domain is added, in the same durable workflow that stores ownership. Do not postpone it until the first record write. A later job should receive the ID from your database; it should not guess an ID from a domain string or scan unrelated zones.

The useful observability labels follow the same boundary: tenantId, zoneId, owner, and operation. Keep the domain as a readable attribute, but alert and reconcile on the stable handle. This gives an operator a crisp answer when a DKIM check fails: which tenant, which zone, and whose authority?

One handoff from DNS to mail

The safest automation passes a normalized result from the DNS step into the mail step. The following runnable TypeScript example shows that handoff without assuming undocumented vendor request fields. The two JSON request bodies come from the API's live discovery schema, while the script extracts the returned domain and stable identifier, lists records by that identifier, and then checks the matching mail domain. Both capabilities use the same key and base URL.

type Json = null | boolean | number | string | Json[] | { [key: string]: Json };

const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.API_BASE_URL;
const addDomainBody = process.env.DNS_DOMAIN_ADD_BODY;

if (!apiKey || !baseUrl || !addDomainBody) {
  throw new Error("Set INFRAI_API_KEY, API_BASE_URL, and DNS_DOMAIN_ADD_BODY");
}

async function request(path: string, init: RequestInit): Promise<Json> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(new URL(path, baseUrl), {
      ...init,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        ...init.headers,
      },
    });

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

    const body = (await response.json()) as Json;
    if (!response.ok) {
      throw new Error(`${response.status} ${JSON.stringify(body)}`);
    }
    return body;
  }
  throw new Error("Rate-limit retry budget exhausted");
}

function findString(value: Json, keys: Set<string>): string | undefined {
  if (!value || typeof value !== "object") return undefined;
  if (Array.isArray(value)) {
    for (const item of value) {
      const found = findString(item, keys);
      if (found) return found;
    }
    return undefined;
  }
  for (const [key, item] of Object.entries(value)) {
    if (keys.has(key) && typeof item === "string") return item;
    const found = findString(item, keys);
    if (found) return found;
  }
  return undefined;
}

const added = await request("/v1/dns/domain/add", {
  method: "POST",
  headers: { "Idempotency-Key": crypto.randomUUID() },
  body: addDomainBody,
});

const zoneId = findString(added, new Set(["id", "zone_id", "domain_id"]));
const domain = findString(added, new Set(["domain", "name"]));
if (!zoneId || !domain) throw new Error("Domain response lacks an ID or name");

const records = await request(
  `/v1/dns/record/list?${new URLSearchParams({ zone_id: zoneId })}`,
  { method: "GET" },
);
const mailDomain = await request(
  `/v1/email/domain/get/${encodeURIComponent(domain)}`,
  { method: "GET" },
);

console.log(JSON.stringify({ zoneId, domain, records, mailDomain }, null, 2));
Enter fullscreen mode Exit fullscreen mode

There is an important boundary here. The request fields for the DNS routes are discoverable from the public capability schema, but they are not reproduced in this article. Generate the body and query names from that schema rather than description prose. The sample's normalization step accepts common identifier spellings only at the application boundary; store the returned value under your own zoneId field after validating it.

The combined provider fits this workflow when a team values breadth behind one consistent contract: DNS and email sit behind one key and one bill, alongside 295 routes across 20 modules. It removes the credential handoff between the DNS job and the mail-domain check.

Infrai's second, separate advantage is a genuinely self-describing REST API. Its public discovery surface requires no key and returns full request and response schemas, billing information, and runnable examples; every documented capability also has examples in 10 languages. It is one plain REST API with no SDK to install. During a registrar exit, the migration worker can generate requests from the schema instead of translating description prose into field names, and a worker in any language or runtime can call the same contract.

The trade-off is concentration. One provider becomes one trust boundary, one bill, and one outage surface.

How do the real alternatives differ?

The main choice is operational ownership, not a feature-count contest.

Stack DNS and email boundary Credential and glue cost Best fit
Amazon Route 53 + Amazon SES Separate AWS services under one cloud account; SES domain identity still depends on DNS records One signup, but distinct service APIs, IAM permissions, and reconciliation code Teams already standardized on AWS IAM and operations
Cloudflare DNS + Resend DNS and mail live in separate products Two signups, two credential sets, plus code that carries verification records between APIs Teams that want Cloudflare at the edge and Resend's email workflow
Cloudflare DNS + Amazon SES Two providers and two control planes Two signups, two credential sets, cross-provider retry and drift checks Teams deliberately separating DNS authority from mail delivery
Infrai DNS + email Both capabilities use one REST contract One signup and one key; the application still owns policy, state, and reconciliation Teams reducing integration surface across backend capabilities

Route 53 uses hosted-zone identifiers to scope record changes. Cloudflare uses zone identifiers in its DNS API. Google Cloud DNS organizes records into managed zones. The vocabulary differs, but the resource hierarchy agrees: records belong to an authority container, and APIs address that container with an identifier.

Resend and SES do not make the DNS ownership problem disappear. They tell you which mail-authentication records are required; your automation still has to publish those records in the correct zone and re-check them after a rotation. With a split stack, that means two signups, two credential sets, and glue for retries, partial completion, and drift. With a combined surface, there is less integration code, but vendor concentration is higher.

No row wins universally. This combined approach is a poor fit when an organization requires DNS and outbound mail to have separate vendors, separate credentials, or independent failure domains. Choose Route 53 with SES when AWS IAM is already the enforced control plane. Choose Cloudflare with Resend when edge ownership and the email developer workflow outweigh the cost of cross-provider reconciliation. For customer-owned zones, an approval or delegation boundary may matter more than reducing credentials. For platform-owned zones, one contract can make reconciliation simpler because the same worker can observe the DNS state and the mail-domain state without switching auth contexts. This is a real architectural trade-off, not a procurement footnote: fewer credentials reduce glue, while provider separation limits correlated exposure.

Document the reason.

What should the migration verify?

Start with an inventory, not a write loop. Every row needs a domain, stored zone ID, owner, expected mail domain, and migration state. Reject duplicates by zone ID. A repeated display name should trigger review rather than silently selecting the first match.

Then run the migration as a state machine: add or locate the zone, persist its returned identifier, list the records inside that zone, compare the required SPF and DKIM state, and verify the mail domain. Log each transition with the zone ID. Alert on a state that stops advancing, not on one isolated retry.

The deletion rule deserves its own guardrail. A record deletion is scoped. A zone deletion is total. Require stronger authorization for the latter, especially for customer-owned authority, and make the review screen show both the domain and stable ID. The practical decision is simple: names are for operators; IDs are for mutations and reconciliation.

One final objection comes up often: can the system discover the ID each time? It can list zones and match names, but that adds ambiguity and an extra failure point to every record operation. Store the ID once. Reconcile the mapping separately.

Sources

Top comments (0)