DEV Community

XerxesCross2735
XerxesCross2735

Posted on

Adding DNS Zones and Records for Reliable Custom Domain Verification

Short answer: for property-management software, keep the default tenant address in a platform-owned zone, but onboard a customer-owned domain with a unique DNS challenge, an idempotent record-writing job, and verification against public DNS before routing any traffic. A successful control-plane write is not proof that a resolver can see the record. Treat those as separate states.

That split keeps a notebook experiment honest when it becomes a production feature. cedar.example.net might be issued immediately from a platform-owned zone, while residents.cedar-apartments.com remains under the property manager's control. The first path optimizes automation; the second preserves customer ownership and requires a verification loop.

Ownership comes first.

How should custom domain onboarding add a zone, write a record, and verify it?

Start with the ownership decision, not the DNS API. If the platform owns the parent zone, create a tenant label beneath it and write the routing record through your DNS adapter. If the customer owns the zone, give them a unique TXT challenge and the routing target they must publish. Asking a customer to transfer an entire zone merely to attach one hostname gives the application more authority than it needs.

The data flow is small enough to say in one breath: accept and normalize the requested hostname, prove control of it, ensure the routing record has the expected value, query public DNS, and only then bind the hostname to the tenant at the edge. Store the expected values before making any external change so a retry reads durable intent rather than inventing a new challenge.

Don't compress those actions into one active boolean. A useful state model is requested, challenge_pending, record_pending, verifying, active, and action_required. Each transition records what was expected, what was observed, and when another check may run. This makes an eval harness straightforward: feed the state machine a sequence of DNS observations and assert the next state without touching a real zone.

The important boundary is authority. In a platform-owned zone, your worker may call ensure_zone() and ensure_record(). In a customer-owned zone, those same methods should produce instructions or integrate with authority the customer explicitly granted; they must not imply that your application can mutate somebody else's DNS.

A Python workflow that survives retries

The example below keeps vendor calls behind a narrow adapter and makes the orchestration testable. It uses property IDs and hostnames that expose the real tenancy concern, not generic foo data. The adapter's ensure methods are intentionally idempotent: retrying a job after a process restart should converge on the same zone and records.

Retries must converge.

from dataclasses import dataclass
from enum import Enum
from typing import Protocol


class Ownership(str, Enum):
    PLATFORM = "platform"
    CUSTOMER = "customer"


@dataclass(frozen=True)
class DomainRequest:
    property_id: str
    hostname: str
    ownership: Ownership
    challenge: str
    routing_target: str


@dataclass(frozen=True)
class DnsObservation:
    challenge_values: set[str]
    routing_values: set[str]


class DnsControl(Protocol):
    def ensure_zone(self, zone_name: str) -> None: ...

    def ensure_record(
        self, zone_name: str, name: str, record_type: str, value: str
    ) -> None: ...


class PublicResolver(Protocol):
    def observe(self, hostname: str) -> DnsObservation: ...


def parent_zone(hostname: str) -> str:
    labels = hostname.rstrip(".").lower().split(".")
    if len(labels) < 3:
        raise ValueError("Use a hostname below a registrable domain")
    return ".".join(labels[-2:])


def reconcile(
    request: DomainRequest,
    dns: DnsControl,
    resolver: PublicResolver,
) -> str:
    hostname = request.hostname.rstrip(".").lower()
    zone = parent_zone(hostname)
    challenge_name = f"_tenant-verify.{hostname}"

    if request.ownership is Ownership.PLATFORM:
        dns.ensure_zone(zone)
        dns.ensure_record(zone, challenge_name, "TXT", request.challenge)
        dns.ensure_record(zone, hostname, "CNAME", request.routing_target)
    else:
        return "action_required"

    observed = resolver.observe(hostname)
    challenge_ok = request.challenge in observed.challenge_values
    route_ok = request.routing_target in observed.routing_values
    return "active" if challenge_ok and route_ok else "verifying"
Enter fullscreen mode Exit fullscreen mode

There is one deliberate simplification here: parent_zone() is suitable for controlled example data, not arbitrary public suffixes. Production normalization needs a maintained public-suffix data source; otherwise a name under a multi-label suffix can be split at the wrong boundary. Internationalized names also need one canonical representation before uniqueness checks. Keep the original display value separately, because operators need to recognize what the customer typed.

For customer-owned DNS, the reconciliation worker shouldn't return active immediately after showing instructions. It waits until the resolver reports both the exact challenge and routing target. The token must be scoped to the property and hostname, stored as a secret-quality value, and replaced when an abandoned request is restarted. This is also where prompt-cost awareness helps the architecture: domain verification is a deterministic protocol, so putting an agent or model in this control loop adds cost and nondeterminism without improving the decision.

The catch is that a generic adapter hides provider-specific concurrency rules. Preserve the provider's change identifier and normalize duplicate-write responses into your own idempotent result, but don't discard raw metadata needed for an audit. A notebook can get away with "call, sleep, query." A worker cannot.

Verification is an observation problem

DNS control and DNS observation answer different questions. The control side says a desired change was accepted. The observation side says resolvers can retrieve the desired value. Activation depends on observation.

Observation decides activation.

Query from more than one resolver vantage point when a false positive would expose the wrong tenant. Cache behavior means two honest resolvers can disagree for a while, so store the complete answer set and the observation time rather than overwriting one string. I'm not sure a universal retry window exists for every customer-managed zone; the defensible value comes from your product's measured onboarding distribution and support policy. Start with bounded exponential backoff, cap it, and move long-running requests to action_required instead of polling forever. Never route by DNS alone. The edge binding must map one normalized hostname to exactly one property, and the database needs a uniqueness constraint that makes competing claims fail closed. Re-check ownership before a dormant binding is restored. If verification later disappears, don't instantly detach a live property on one empty answer; require repeated observations under a documented policy, because transient cache and delegation changes are operational signals, not proof that a customer intentionally relinquished the name. Keep mail policy out of the ownership proof. A domain's DMARC TXT record describes email authentication policy and reporting; RFC 7489 does not make it an application tenancy token. Reusing an existing mail record would couple unrelated systems and could let an old configuration satisfy a new claim. Use a dedicated challenge label. This is the failure mode worth testing hardest: property A begins onboarding residents.cedar-apartments.com, the job is retried, and property B submits the same normalized hostname before the first verification completes. The expected result is not "last write wins." Both workers may read valid public DNS, yet only the request holding the durable hostname claim may activate. Put that race in a deterministic test with two requests and two interleaved transitions. Then run the same fixture against the real adapter in a staging zone. Fast unit evals protect the state machine; a smaller integration suite catches record-shape and resolver mistakes.

Choosing customer-owned or platform-owned zones

Decision Platform-owned zone Customer-owned zone
Best fit Default tenant URLs and previews Branded resident portals
Who writes DNS Platform automation Customer or delegated automation
Onboarding dependency Internal control-plane work External publication and observation
Exit path Tenant changes its assigned URL Customer retains DNS authority
Main operational risk Label collision inside the platform Stale, conflicting, or delayed records

Platform-owned names are the sensible default when a tenant needs a working portal now and branding is secondary. They are also easier to exercise in ephemeral environments because the team controls every record. They are not suitable when the property manager requires authority over the public name, wants the name to survive a platform migration, or must coordinate DNS through an internal security team. Use customer-owned DNS then, and accept that onboarding completion depends on an external actor.

Customer-owned zones have their own boundary. If a customer cannot publish the requested challenge and route, stick with the assigned platform hostname rather than weakening verification. Full zone delegation can automate more record types, but it expands the authority and incident surface; delegation is justified only when the product truly manages that zone as a service. For a single resident-portal hostname, a narrow record request is usually easier to review and revoke.

Cost belongs in the decision model, though not as the headline. Track control-plane writes, verification queries, certificate work, edge bindings, and support retries per successful activation. The useful metric is cost per activated tenant domain, accompanied by completion latency and manual-intervention rate. Raw API price misses the expensive part when unclear instructions generate tickets.

Operating the workflow after launch

Before release, run the state machine against duplicate submissions, uppercase and trailing-dot variants, wrong TXT values, multiple TXT answers, changed routing targets, delayed observations, and two tenants claiming one hostname. Test cleanup too: removing a binding should not delete unrelated customer records, and reusing a hostname should require a fresh proof. Log the property ID, normalized hostname, state transition, control-plane change ID, observed answer set, and next-check time, while keeping challenge values out of general logs.

Then watch the funnel. Separate time waiting for customer action from time waiting for DNS observation, or the latency chart will blame your worker for an instruction problem. Alert on a growing verification queue, repeated transition failures, and hostname-claim conflicts. Sample completed requests in a staging resolver check so the integration path is continuously exercised.

Keep the runbook concrete. Support should be able to identify the expected record name and type, compare the public answer without editing it, explain whether the customer or platform owns the next action, and restart a bounded verification job. Engineers should be able to rotate a challenge, revoke an edge binding, and trace a request without opening a DNS provider console. That's the notebook-to-production threshold: retries are boring, ownership is explicit, and activation is backed by an observable proof rather than an optimistic API response.

References

Top comments (0)