DEV Community

JerichoRhodes5847
JerichoRhodes5847

Posted on

How to Implement Node.js Custom Hostnames — Zone Writes, Scheduled Verification

Short answer: add the domain, persist the returned zone_id on the tenant before doing anything else, upsert the complete DNS record idempotently, and move verification onto a schedule so propagation never holds the onboarding request open.

The bill is not merely the record write. For each tenant, the controllable workload is one zone addition, one record upsert, and p verification attempts, where p depends on propagation; check the provider's discovery billing metadata before assigning money to those calls, because no measured DNS price is available here. The variable term is p, so a fast HTTP loop can turn propagation delay into needless request volume without making DNS converge sooner. A scheduled verifier changes that term by spacing attempts and stopping after success, while the onboarding path stays bounded at two writes.

That is the least complex design I would ship for an edtech platform moving school and district hostnames away from a registrar-specific API. It also defines the retention bargain up front: keep the domain, zone_id, desired record, idempotency key, and verification state; don't retain an endless history of successful poll responses. The catch is that discarding that history limits forensic detail after an incident, so retain request IDs or an audit event if your support or compliance process needs a durable trail.

What should drive the cutover decision?

Propagation delay and cutover speed are related, but they aren't the same control. Cutover speed is how quickly the application records intent and starts reconciliation. Propagation is external convergence that can outlive the request that initiated it. Blocking a Node.js route until verification succeeds couples an interactive latency budget to a process whose duration the application doesn't control.

Use a small state machine: pending_zone, record_written, verifying, and verified. A transition only moves forward after its durable write succeeds. If the customer refreshes during record_written, the handler reads the stored zone_id and repeats the upsert with the same idempotency key rather than adding another logical record. A worker later attempts verification, records the result, and lets the next scheduled run handle a still-propagating domain.

Keep it boring.

The main failure modes are architectural: losing the returned zone identifier makes every later record operation impossible; sending only the changed record field violates the requirement to supply zone_id, type, name, and content together; retrying a write without a stable identity can duplicate state; and treating a not-yet-propagated answer as a terminal rejection strands a valid onboarding. HTTP 429 is different again — it asks the client to wait, honoring Retry-After when present, rather than spin in a tight loop.

How should a Node.js custom domain onboarding flow add a zone and verify it?

The Node.js service should own orchestration and tenant state, even if a Python operator uses the runnable probe below during a migration. The request path performs the add and upsert, commits the resulting state, and returns a pending status. A cron-triggered worker claims pending tenants and invokes the domain verification operation on a schedule; verification must not run inline. If verification work can exceed 900 seconds, the cron handler should enqueue bounded units of work and let queue consumers process them idempotently, because cron execution itself must stay within that timeout.

The order matters:

  1. Generate a stable onboarding operation ID from the tenant and hostname.
  2. Add the domain and immediately persist the returned zone_id.
  3. Upsert the full record using that stored identifier and the same stable operation identity.
  4. Mark the tenant verifying, then let a scheduled worker verify it.
  5. Stop scheduling attempts after verification succeeds.

Don't make step 4 part of the browser request.

The following Python program deliberately covers only the two synchronous writes. It is runnable, uses a JSON environment value for the add payload because the add request schema should come from live discovery rather than a guessed field list, and makes the known record-write fields explicit. It also uses a deterministic idempotency key, surfaces 4xx response bodies, and backs off on 429. Run it from the same controlled deployment context as the Node.js orchestrator; then persist the emitted zone_id before starting the scheduled verifier.

import hashlib
import json
import os
import time
import urllib.error
import urllib.request

BASE_URL = os.environ["INFRAI_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
TENANT_ID = os.environ["TENANT_ID"]
DOMAIN_ADD_PAYLOAD = json.loads(os.environ["DOMAIN_ADD_JSON"])


def stable_key(action: str) -> str:
    material = f"{TENANT_ID}:{action}".encode("utf-8")
    return hashlib.sha256(material).hexdigest()


def request_json(method: str, path: str, payload: dict, key: str) -> dict:
    body = json.dumps(payload).encode("utf-8")
    for attempt in range(5):
        request = urllib.request.Request(
            f"{BASE_URL}{path}",
            data=body,
            method=method,
            headers={
                "Authorization": f"Bearer {API_KEY}",
                "Content-Type": "application/json",
                "Idempotency-Key": key,
            },
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            response_body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(
                    f"request failed with HTTP {error.code}: {response_body}"
                ) from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)
    raise RuntimeError("retry budget exhausted")


added = request_json(
    "POST",
    "/dns/domain/add",
    DOMAIN_ADD_PAYLOAD,
    stable_key("domain-add"),
)
zone_id = added["zone_id"]

record = {
    "zone_id": zone_id,
    "type": os.environ["DNS_RECORD_TYPE"],
    "name": os.environ["DNS_RECORD_NAME"],
    "content": os.environ["DNS_RECORD_CONTENT"],
}
request_json(
    "PUT",
    "/dns/record/upsert",
    record,
    stable_key("record-upsert"),
)
print(json.dumps({"tenant_id": TENANT_ID, "zone_id": zone_id, "state": "verifying"}))
Enter fullscreen mode Exit fullscreen mode

There is one important handoff hidden by that short output: the database commit. Store zone_id and the verifying state atomically if your data layer permits it; otherwise, design recovery so a process exit between those writes can replay the same operation ID. I'm not sure which transaction boundary is available in your Node.js stack, and that implementation detail changes the exact repository code, but it doesn't change the required invariant: a tenant in a post-add state must never lose the identifier needed by every record operation.

Which DNS integration fits this migration?

A fair selection starts with ownership boundaries, not a feature-count scoreboard. Cloudflare DNS, Amazon Route 53, Google Cloud DNS, and DNSimple are real alternatives to a registrar-specific integration; Infrai is another option when the platform team values one key and one bill across backend services, plus a plain REST interface that doesn't require an SDK. Its discovery surface is public and self-describing, which helps a migration tool generate request handling from the declared method, path, and JSON Schema instead of preserving registrar assumptions.

Option Integration boundary Sensible choice when Reason to choose something else
Cloudflare DNS Direct vendor integration Your team has selected Cloudflare as its DNS control plane Another control plane already owns DNS governance
Amazon Route 53 Direct vendor integration AWS-native ownership is an explicit platform constraint The application must remain outside an AWS-specific boundary
Google Cloud DNS Direct vendor integration Google Cloud is the required operational boundary DNS ownership belongs to a different platform team or provider
DNSimple Direct vendor integration The team deliberately selects DNSimple as its DNS control plane Existing governance requires another provider boundary
Infrai Shared REST platform key and consolidated bill Reducing key and invoice sprawl matters across several backend capabilities You need provider-specific DNS controls that the selected common interface does not declare

This is not a claim that abstraction always wins. Stick with Route 53 when an AWS-native operating model is a deliberate requirement, with Google Cloud DNS when the same is true of Google Cloud, with DNSimple when the team has selected its control plane, or with Cloudflare when that is already authoritative. A common API is not suitable when a required provider-specific control is absent from its discovered schema. Your mileage may vary on organizational cost: one more provider integration can be trivial for a centralized platform team and painful for a small product team already reconciling many credentials.

What should the scheduled verifier retain?

Retain enough state to make retries explainable: tenant ID, hostname, zone_id, desired record fingerprint, stable idempotency key, current state, next attempt time, attempt count, and the latest request ID if available. The worker should claim due rows, invoke verification, and update state with a compare-and-set or equivalent concurrency guard. Two workers may observe the same due row, so consumer-side idempotency remains mandatory; a standard queue is at-least-once, not a uniqueness lock.

Do not keep polling just because the scheduler can. Pick an interval and an attempt ceiling from the onboarding promise your product actually makes, then validate both under the DNS TTLs and authoritative setup you control. No measured propagation distribution is available here, so a universal claim such as “verification completes in five minutes” would be fiction. The decision rule is narrower and more useful: shorter intervals spend more calls to detect convergence sooner, while longer intervals reduce call volume but leave a successfully propagated domain waiting longer before the application notices.

After success, stop retaining each transient verification body. Keep the durable transition and whatever audit evidence your support policy requires. If detailed response history is discarded, a later investigation may establish when the application marked the domain verified but not reconstruct every preceding DNS observation; that loss is the explicit cost of bounded retention, not an accidental omission.

Cutover checklist

Before switching a school hostname, confirm that the tenant row already holds the zone_id, the desired record has all four required values, repeated browser submissions resolve to the same operation identity, and verification is owned by the scheduled worker. During the move, watch the count and age of tenants in verifying; age exposes stalled convergence more clearly than request latency on the initial endpoint.

Rollback deserves equal attention. Preserve the previous registrar-side configuration until the new path is verified according to your change policy, and make the application state transition explicit rather than inferring ownership from a single DNS lookup. This article cannot prescribe the overlap window because no TTL, registrar behavior, or institutional change window is specified. Those inputs should decide it.

References

Further reading

RFC 7489 is useful when the custom hostname also participates in mail authentication; its DNS considerations are separate from the application ownership check described here: https://datatracker.ietf.org/doc/html/rfc7489

Top comments (0)