Use a plan-then-apply reconciler to manage internal DNS hostnames from infrastructure code, and make the deployment fail before e-commerce onboarding completes when the published record differs from the intended ownership proof. A Node.js service can invoke the same boundary during deploy, but the important decision is architectural: calculate a deterministic diff, publish only the approved changes, then read the records back before marking the merchant domain verified.
Short answer: treat DNS ownership as converged state, not as a successful write request.
That distinction matters in an onboarding flow. The application wants shop-1042.example.test to carry a one-time proof value, while the resolver-visible record may be absent, stale, or owned by a different deployment. A write acknowledgement establishes intent. The subsequent observation establishes what the system can actually use.
Decision record and invariants
The decision is to put a small reconciliation step between the infrastructure manifest and the onboarding state machine. It accepts desired records plus observed records, produces a diff, and applies that diff only after policy checks. Verification is a separate read. This boundary works with a Node.js deploy hook, a CI job, or a dedicated controller because none of those callers needs to know how a DNS provider represents a zone internally.
Three invariants carry most of the safety burden. First, a hostname has one onboarding owner in the desired manifest. Second, the proof value is opaque: logs may identify its digest, but must not print the value itself. Third, verified can follow an observed match only; it can't follow apply merely returning without an exception. The last rule looks fussy until retries enter the picture — and retries always enter the picture.
The failure boundary is deliberately narrow. A malformed manifest, an ownership collision, or an unexpected existing value stops the deployment before mutation. A mismatch after publication keeps onboarding pending and emits an observable result for another retry. It does not silently replace a record that the manifest doesn't own. Compliance and deliverability work teaches the same habit: the control plane's intent and the receiver's observation are different evidence.
Keep the proof lifecycle separate from email authentication policy. DMARC publishes policy in DNS and describes identifier alignment and reporting for mail handling; an e-commerce ownership token answers a different question. Reusing a _dmarc record as an onboarding marker would mix two control planes with different operators and review paths.
How should infrastructure code manage internal DNS hostnames during a Node.js deploy?
Make the deploy call one stable reconciliation contract: plan(desired, observed) followed by apply(approved_changes) and verify(expected). In a Node.js repository, that contract can sit behind an npm script or deployment task. The example below is Python because the language is incidental to the boundary; the same inputs and outcomes should survive a later runtime change without rewriting the ownership rules.
The desired document should be boring. Give every entry a stable logical ID, a fully qualified hostname, a record kind, an opaque value reference, and the onboarding entity that owns it. Do not derive ownership from array position or execution order. A code review then shows the meaningful change: which merchant gets which name, and whether a proof is being created, rotated, or removed.
Here is the critical path. DnsStore is an adapter implemented by the chosen authoritative DNS system; there is no assumed commercial route or SDK. Notice that planning has no side effects, while apply rejects an observed value it was not told to replace.
from dataclasses import dataclass
from enum import Enum
from hashlib import sha256
from typing import Protocol
class Action(Enum):
CREATE = "create"
REPLACE = "replace"
NOOP = "noop"
@dataclass(frozen=True)
class ProofRecord:
logical_id: str
hostname: str
value: str
merchant_id: str
@dataclass(frozen=True)
class Change:
action: Action
desired: ProofRecord
observed_value: str | None
class DnsStore(Protocol):
def read_txt(self, hostname: str) -> str | None: ...
def put_txt(
self,
hostname: str,
value: str,
expected_previous: str | None,
) -> None: ...
def plan(record: ProofRecord, observed: str | None) -> Change:
if observed is None:
return Change(Action.CREATE, record, None)
if observed == record.value:
return Change(Action.NOOP, record, observed)
return Change(Action.REPLACE, record, observed)
def apply_and_verify(store: DnsStore, change: Change) -> bool:
if change.action is not Action.NOOP:
store.put_txt(
hostname=change.desired.hostname,
value=change.desired.value,
expected_previous=change.observed_value,
)
published = store.read_txt(change.desired.hostname)
return published == change.desired.value
def audit_label(record: ProofRecord) -> str:
digest = sha256(record.value.encode("utf-8")).hexdigest()[:12]
return f"{record.logical_id}:{record.merchant_id}:{digest}"
There is a subtle edge here. REPLACE does not mean “overwrite whatever exists.” The adapter receives expected_previous, so it can reject a mutation when the value changed between plan and apply. That compare-before-write condition prevents two deployments from confidently claiming the same hostname based on different observations. If the backend cannot offer an atomic conditional update, the controller must serialize changes for a hostname and re-read immediately before writing; I'm not sure a generic retry policy can preserve ownership without one of those controls, because the missing fact is which writer won the race.
For the e-commerce example, suppose release A plans a token for merchant m-1042, then release B rotates it before A applies. A blind update lets A restore the stale proof and possibly advance the wrong onboarding attempt. Under this contract, A presents the earlier observed value, the adapter declines the stale mutation, and the deployment replans from current state. Record logical_id, hostname, action, deployment ID, merchant ID, and the token digest. Don't log the token. One long diagnostic event is more useful than six scattered “DNS updated” lines because it preserves the decision and its evidence together.
Compare the control-loop options
The deployment location changes the failure semantics more than the DNS API does. Choose it by asking who owns retries, how long onboarding may remain pending, and whether a DNS mismatch should block the rest of a release.
| Option | Diff visibility | Failure boundary | Suitable use | Main limitation |
|---|---|---|---|---|
| Plan and apply inside the deploy | Diff appears with the release | A mismatch can stop that release | A small set of names tightly coupled to application configuration | DNS timing extends deployment time and couples onboarding to release health |
| Dedicated reconciliation worker | Diff is a controller artifact | Onboarding can remain pending while the app deploys | Many merchants, independent retries, or frequent token rotation | Requires queueing, idempotency, and a separate operational owner |
| Manual change with ticket evidence | Human review is explicit | Failure remains outside the deployment system | Rare, high-scrutiny changes with a clear operator | Slow feedback and weak drift detection unless another job reads records |
For a modest e-commerce service, deploy-time reconciliation is defensible when an unverified domain must never become active and the number of records is small. The catch is the coupling: a DNS observation delay can hold up unrelated application changes. Move reconciliation into a worker when onboarding volume or retry cadence differs from release cadence. Stick with manual review when policy requires an operator to authorize every domain and automation cannot encode that approval.
No option removes the need for a diff. The diff should classify create, replace, noop, and ideally delete, but deletion deserves a stricter policy than creation. An absent desired entry might mean intentional retirement, a branch that lacks the complete manifest, or a parsing failure. Require an explicit tombstone and ownership match before deletion rather than interpreting omission as permission.
Deployment checks that catch drift
Test the planner as a pure function first. Cover an absent record, an exact match, a conflicting value, duplicate desired hostnames, and a stale apply. Then run adapter contract tests against an isolated zone under your control. The contract test is about behavior — read, conditional write, read-back — rather than a provider's response shape. At deployment time, persist both the plan and the result. A useful event contains the desired revision, observed revision or digest, chosen action, verification outcome, and correlation ID. Metrics should separate planning conflicts from verification mismatches; combining them into one failure count hides whether engineers are fighting concurrent writers or waiting for observation to converge. Alerts belong on age, not merely count: ten fresh pending proofs may be routine, while one proof stuck across several deployment attempts needs attention. Be conservative with retries. Re-read before each mutation, apply only from a fresh plan, and make noop cheap. Set retry limits from the authoritative system and resolver path you actually operate; your mileage may vary, and a universal number would be fiction. Once the limit is reached, leave the merchant pending and surface the mismatch rather than guessing that publication probably succeeded. Rollback also needs an ownership check because application rollback does not automatically imply DNS rollback; a newer onboarding attempt may already own the name. Record the previous value as evidence, not as unconditional permission to restore it. This is where the stable logical ID earns its keep.
Stop there.
Rejected option and its valid use case
The rejected design is “write the TXT record during the request that creates the merchant, then mark ownership complete.” It shortens the happy path, but it collapses intent, mutation, and verification into one request. A timeout leaves the caller unable to distinguish “nothing changed” from “the change happened but the response was lost,” and a retry can race a newer token. Worse, the HTTP request now owns a potentially variable external observation window.
It still has a valid use case: an internal development environment where names are disposable, there is one writer, and onboarding state has no compliance or customer-facing consequence. Even there, return pending until read-back matches. For production merchant onboarding, the plan/apply/verify boundary is the clearer audit trail and the safer place to enforce ownership.
DNS automation is finished when intent and observation agree, not when a write call ends.
Top comments (0)