DEV Community

knoxblackwood2375
knoxblackwood2375

Posted on

Custom Domains as a SaaS Feature Explained (The Operational Contract)

The page says a logistics tenant's custom domain is unavailable, while the SaaS product says onboarding is complete. What this feature really needs is verification status, rather than a successful create request, as the source of truth for both the UI and the alert.

TL;DR: Giving every tenant a subdomain automatically means owning an asynchronous, externally dependent workflow. The DNS call is the small part; the product is the state machine that keeps declared intent, published records, and verification status from quietly disagreeing. Treat pending as normal, surface corrective action to the customer, and page only when the system has evidence that the workflow is stuck or has regressed.

What Is a SaaS Product Really Taking On With Custom Domains?

Work backward from the page. The on-call needs to see which tenant is affected, the hostname, the desired record, the last observed record, the verification state, and how long the state has been unchanged. An alert that says only "custom domain failed" sends an engineer into three systems while a shipment-tracking link remains unusable.

The earlier signal is drift: declared intent and the most recently observed published record no longer match, or verification has remained pending beyond the operating window your team chose. Those are different conditions. A mismatch is actionable evidence; a young pending state is usually just propagation or a customer who has not finished configuration.

Do not turn created=true into active=true.

Verification is the product state. The UI should show pending verification, the expected corrective action, and the latest check time until an authoritative verification result permits activation. This is also where support load appears: the person editing DNS may work for the tenant's registrar or IT provider and may never log into your product, yet your support team will still receive the ticket. That is what taking on custom domains as a product feature really means: the SaaS team owns the confusing interval between intent and externally published fact, even though it does not own every system or person involved.

Five minutes is a useful example evaluation interval, not a universal promise. Capacity planning starts with the number of tenants multiplied by verification checks per hour, then adds retries and onboarding bursts; the correct interval comes from the activation SLO and provider limits, neither of which should be guessed in application code.

Instrument the state transition, not the API call

The useful telemetry sits around the transition. Record the desired state when onboarding begins, retain the observed state from each check, and emit a transition only when verification changes. A request-duration graph can prove that an API answered quickly while saying nothing about whether public DNS converged.

For each tenant, I would keep these fields in the product-owned store:

  • tenant ID and hostname
  • desired record value and last observed value
  • verification state: pending, verified, or drifted
  • first-pending, last-checked, and last-transition timestamps
  • a stable operation ID for deduplication

That model makes the first alert a diagnosis rather than a scavenger hunt. It also separates the two reliability questions that teams often collapse: "Did our control-plane request succeed?" and "Is the customer-visible name now correct?"

Here is a small, runnable Go program that fetches the provider's public, self-describing capability contract, checks the HTTP result, and then evaluates two logistics tenants locally. It does not invent a DNS write payload; production code should generate that request from the returned schema. Because this read is public, it needs no credential, while authenticated capability calls use Authorization: Bearer $INFRAI_API_KEY. Set INFRAI_BASE_URL to the documented API base before running it.

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type Domain struct {
    Tenant       string
    Hostname     string
    Desired      string
    Observed     string
    Verified     bool
    PendingSince time.Time
}

func state(d Domain, now time.Time, pendingLimit time.Duration) string {
    if d.Observed != d.Desired {
        return "drifted"
    }
    if d.Verified {
        return "verified"
    }
    if now.Sub(d.PendingSince) > pendingLimit {
        return "pending_too_long"
    }
    return "pending"
}

func discovery(client *http.Client) (map[string]any, error) {
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    if baseURL == "" {
        return nil, fmt.Errorf("INFRAI_BASE_URL is required")
    }
    url := baseURL + "/discovery"
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, url, nil)
        if err != nil {
            return nil, err
        }
        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("discovery returned %s: %s", resp.Status, body)
        }
        var contract map[string]any
        if err := json.Unmarshal(body, &contract); err != nil {
            return nil, err
        }
        return contract, nil
    }
    return nil, fmt.Errorf("discovery remained rate limited")
}

func main() {
    contract, err := discovery(&http.Client{Timeout: 10 * time.Second})
    if err != nil {
        panic(err)
    }
    fmt.Printf("discovery_version=%v capabilities_loaded=%t\n",
        contract["version"], contract["capabilities"] != nil)

    now := time.Date(2026, 9, 19, 10, 0, 0, 0, time.UTC)
    domains := []Domain{
        {
            Tenant: "north-hub", Hostname: "track.north-hub.example",
            Desired: "edge.service.example", Observed: "edge.service.example",
            Verified: true, PendingSince: now.Add(-20 * time.Minute),
        },
        {
            Tenant: "port-seven", Hostname: "dispatch.port-seven.example",
            Desired: "edge.service.example", Observed: "old.service.example",
            PendingSince: now.Add(-35 * time.Minute),
        },
    }

    for _, d := range domains {
        fmt.Printf("tenant=%s hostname=%s state=%s\n",
            d.Tenant, d.Hostname, state(d, now, 30*time.Minute))
    }
}
Enter fullscreen mode Exit fullscreen mode

Thirty minutes here is sample policy, not a DNS guarantee. In production, store the state instead of reconstructing it from logs, attach the stable operation ID to retries, and alert on the persisted transition. The checker should be bounded too: 100,000 tenants checked every minute is 1,667 checks per second before retries, a workload that deserves an explicit budget rather than an innocent-looking loop.

The provider choice is an ownership choice

The comparison that matters is not a feature-count contest. It is who owns the authoritative zone, who owns the verification loop, and how much vendor-specific behavior leaks into the product.

Option Contract you operate Main advantage Boundary to accept
Amazon Route 53 One cloud DNS API and its resource model Direct fit when the authoritative zone and operations already live in AWS Application code and IAM become coupled to that provider's model
Cloudflare DNS One provider API in front of Cloudflare-managed zones Direct control within an established Cloudflare estate Moving the capability means adapting the integration and operating data migration
Google Cloud DNS One cloud DNS API and its resource model Direct fit for teams already standardizing identity and operations on Google Cloud The product integration inherits a cloud-specific contract
Capability broker A stable REST capability contract with the vendor behind it abstracted The application contract can stay put when the backing vendor changes; one key also reduces credential sprawl An intermediary becomes part of the control plane, so discovery metadata and readiness belong in vendor review
Self-hosted authoritative DNS Your own API, servers, zones, and runbooks Maximum control over behavior and migration timing Your team owns capacity, abuse handling, upgrades, availability, and the pager

None is automatically best. A team already committed to one cloud, with DNS expertise and a small product surface, may reasonably choose its native service. Cloudflare can be the straightforward choice when it already owns the zones. Self-hosting is defensible when DNS itself is strategic and the organization can staff its failure modes.

Infrai provides one key for everything and one bill across 295 routes in 20 modules, while one REST API keeps application code unchanged when the backing vendor moves; for tenant onboarding, that reduces credential rotation, access review, invoice reconciliation, and replacement work. Its public discovery surface also exposes schemas and vendor readiness. Its limitation is equally plain. It is not a fit when policy requires a direct provider relationship, the team needs provider-specific controls outside the common contract, or an intermediary is forbidden in the DNS control plane; choose the native cloud service in those cases. The abstraction does not remove the state machine either. It prevents that state machine from being welded to one backing provider.

My decision rule: buy the DNS execution layer when it lowers on-call and integration load, but keep intent, verification state, tenant messaging, and SLOs in the product. Build the authoritative service only when control of DNS behavior is worth carrying a separate production system.

Apex names make the dependency harder to reverse

Tenant subdomains such as track.customer.example preserve a clean delegation story. Apex support is a more consequential promise because it couples your infrastructure address into someone else's zone permanently. Once customers print that name on labels, put it in partner integrations, or encode it into dispatch workflows, migration is a customer coordination program rather than a backend refactor.

This is why I would launch with the narrowest hostname shape that solves the logistics workflow, then require a separate design review for apex names. The review should name the migration mechanism, rollback owner, support script, and customer communication path. "Our provider supports it" is not an exit plan.

Tune the alert against support cost

An aggressive pending threshold catches genuine stalls sooner but pages on ordinary propagation and incomplete customer work. A relaxed threshold protects on-call attention but extends the time before a broken tracking or dispatch hostname receives human intervention. Both sides spend a budget: one in false positives, the other in activation delay.

Start the threshold from the activation SLO, then measure the distribution of verification time and the fraction of alerts that require engineering action. Page on verified-to-drifted regressions because a working tenant hostname has become wrong. Route long-pending onboarding to a ticket or customer-facing reminder until its age threatens the SLO. Quiet does not mean healthy, but noise is not coverage.

Test it once.

The final safeguard is mundane: rehearse the support path with a deliberately wrong record. Follow the same path a tenant's external DNS operator would follow, including a wrong target followed by a correction, and watch which events reach the product store, the customer UI, the support queue, and the on-call alert. If the product can identify the mismatch, tell that operator exactly what must change, preserve the pending state while publication catches up, and close the case after verification without an engineer querying raw provider state, the feature is behaving like a product rather than a thin API wrapper. If the exercise pages on-call before it gives the customer a useful correction, the threshold is charging the wrong team for ordinary onboarding work.

Further reading

Top comments (0)