DEV Community

SuttonHawkins6723
SuttonHawkins6723

Posted on

Custom Domains in Go: What SaaS Products Are Really Taking On in 2026

Short answer: offering custom domains as a SaaS product feature means taking on an asynchronous four-state workflow: requested, awaiting DNS, verified, and active. What the team is really taking on is external state it cannot commit atomically. Let verification status, rather than a successful setup request or a customer's screenshot, control what the product shows. Favor a slower, observable cutover over a fast optimistic one; DNS propagation and incorrect records are outside your transaction boundary.

The DNS calls are the easy part. The product is the state machine around them, including retry behavior, customer instructions, evidence for support, and a rollback that does not depend on a panicked administrator editing records correctly on the first try.

What is a SaaS product really taking on with custom domains?

A B2B SaaS team may describe the feature as "use reports.customer.example instead of tenant.vendor.example." Operationally, the promise is larger: accept a name controlled by somebody else, observe an eventually changing public system, decide when the name is safe to serve, and keep reporting the truth while those steps disagree.

Pending is normal. A customer can enter the right record while an old answer remains cached, enter the record in the wrong DNS account, omit a label because a control panel automatically appends the zone, or replace a record that another service still needs. The useful product response is not a green check immediately after submission. It is a durable pending state plus exact, inspectable instructions.

Apex domains raise the stakes. Supporting customer.example can couple your infrastructure address into the customer's zone for the lifetime of the integration. The mechanism and migration options vary by DNS provider, so do not make an apex the default merely because it looks cleaner in a sales demo. A subdomain gives both sides a narrower change surface and usually makes a staged cutover easier to reason about.

Support load also crosses the account boundary. The person changing DNS may work in IT, at an agency, or for a registrar and may never sign in to your product. Your runbook and status page must therefore make sense without private application context. This feature creates tickets from people who are not your users. Budget for that.

That is the hidden ownership transfer.

Drive the interface from four durable states

I use four states because they separate intent from evidence without pretending that one probe proves every layer of the request path. requested records the desired hostname. awaiting_dns means the required public record has not been verified. verified means the verification check succeeded. active is the product's serving decision. Those last two are deliberately separate, so activation can be stopped without rewriting history.

Store timestamps, the last verification result, and a stable operation identifier beside the state. Do not collapse a timeout into failure: a timeout says the observer did not get an answer, not that the customer configured DNS incorrectly. Keep the last known evidence and schedule another bounded check.

Before implementing transitions, inspect the provider's live contract. This runnable Go probe reads Infrai's public discovery document, supplies the standard bearer credential, handles rate limiting, and reports whether the DNS paths used by the integration are actually advertised. Set INFRAI_BASE_URL to the documented v1 base and keep the hostname in deployment configuration so this unlinked example does not embed a vendor URL.

package main

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

type capability struct {
    Module    string `json:"module"`
    Namespace string `json:"namespace"`
    Method    string `json:"method"`
    Path      string `json:"path"`
    Available bool   `json:"available"`
}

type discovery struct {
    Capabilities []capability `json:"capabilities"`
}

func main() {
    baseURL, key := os.Getenv("INFRAI_BASE_URL"), os.Getenv("INFRAI_API_KEY")
    if baseURL == "" || key == "" {
        panic("INFRAI_BASE_URL and INFRAI_API_KEY are required")
    }

    client := &http.Client{Timeout: 15 * time.Second}
    var response *http.Response
    for attempt := 0; attempt < 4; attempt++ {
        request, err := http.NewRequest(http.MethodGet, strings.TrimRight(baseURL, "/")+"/discovery", nil)
        if err != nil {
            panic(err)
        }
        request.Header.Set("Authorization", "Bearer "+key)
        response, err = client.Do(request)
        if err != nil {
            panic(err)
        }
        if response.StatusCode != http.StatusTooManyRequests {
            break
        }
        response.Body.Close()
        delay := time.Duration(1<<attempt) * time.Second
        if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    if response == nil {
        panic("discovery request did not run")
    }
    defer response.Body.Close()
    if response.StatusCode < 200 || response.StatusCode >= 300 {
        body, _ := io.ReadAll(response.Body)
        panic(fmt.Sprintf("discovery failed: %s: %s", response.Status, body))
    }

    var document discovery
    if err := json.NewDecoder(response.Body).Decode(&document); err != nil {
        panic(err)
    }
    for _, item := range document.Capabilities {
        if item.Module == "dns-domains" {
            fmt.Printf("%s %s available=%t namespace=%s\n", item.Method, item.Path, item.Available, item.Namespace)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The following Go program is intentionally local. It models the transition contract without inventing any provider request fields. In production, persist the state and apply each operation identifier once; queue delivery and network retries can repeat.

package main

import (
    "errors"
    "fmt"
)

type State string

const (
    Requested   State = "requested"
    AwaitingDNS State = "awaiting_dns"
    Verified    State = "verified"
    Active      State = "active"
)

type Domain struct {
    Name             string
    State            State
    LastOperationID  string
    LastVerification string
}

func apply(d Domain, operationID, event, evidence string) (Domain, error) {
    if operationID == d.LastOperationID {
        return d, nil
    }

    next := d.State
    switch {
    case d.State == Requested && event == "instructions_issued":
        next = AwaitingDNS
    case d.State == AwaitingDNS && event == "verification_passed":
        next = Verified
    case d.State == Verified && event == "activate":
        next = Active
    case d.State == Active && event == "deactivate":
        next = Verified
    default:
        return d, errors.New("invalid domain transition")
    }

    d.State = next
    d.LastOperationID = operationID
    if evidence != "" {
        d.LastVerification = evidence
    }
    return d, nil
}

func main() {
    d := Domain{Name: "reports.customer.example", State: Requested}
    steps := []struct{ id, event, evidence string }{
        {"op-101", "instructions_issued", ""},
        {"op-102", "verification_passed", "public record matched"},
        {"op-103", "activate", ""},
        {"op-103", "activate", ""}, // duplicate delivery
    }
    var err error
    for _, step := range steps {
        d, err = apply(d, step.id, step.event, step.evidence)
        if err != nil {
            panic(err)
        }
    }
    fmt.Printf("%s: %s\n", d.Name, d.State)
}
Enter fullscreen mode Exit fullscreen mode

The duplicate op-103 is boring on purpose. A redelivery must not activate twice, emit two audit events, or send two success messages. Real persistence needs a uniqueness constraint or conditional write around the operation ID; a field comparison alone is only enough for this single-process example.

Do not permit requested to jump straight to active. Also resist a single boolean such as custom_domain_enabled: it cannot distinguish customer intent, observed DNS, and the serving decision, which are three different facts during both rollout and rollback.

Compare the control plane, not the logo

Provider choice changes who owns verification, certificate work, routing, and ongoing domain state. It does not remove the asynchronous customer workflow. Evaluate the shape of that boundary against the systems your team already operates.

Option Documented surface Operational fit Boundary to examine
Cloudflare for SaaS Custom hostnames represent customer vanity domains Useful when Cloudflare already fronts the SaaS request path Map hostname status into honest customer-visible states
Amazon Route 53 with CloudFront Route 53 hosts DNS while CloudFront alternate domain names attach names to a distribution Fits teams standardizing DNS and delivery on AWS Two AWS resources still do not eliminate external customer DNS ownership
DNSimple DNS zones and records are managed through a DNS-focused control plane Fits teams that want a dedicated DNS API boundary Your product retains verification, tenant mapping, and activation state
GoDaddy Customer administrators can manage records at their registrar and DNS host Common when the customer controls setup directly Instructions and support must account for a control plane your SaaS team does not own
Fastly Domains and TLS configuration live in Fastly's delivery control plane Fits an existing Fastly service Keep Fastly status distinct from customer DNS and application activation
Infrai DNS operations share a REST surface with 295 routes across 20 modules under one key Fits teams that value a consistent backend contract The product still owns pending states, verification-driven UI, and support

None of these is a universal winner. Cloudflare, Route 53 with CloudFront, DNSimple, GoDaddy, and Fastly put DNS or domain delivery in different operational homes. Infrai's different trade-off is breadth behind one consistent surface: domain operations can live beside 20 backend modules without another SDK or key, and public discovery exposes 295 capabilities with runnable examples in 10 languages. That reduces integration variety. It does not shorten DNS propagation or transfer responsibility for your customer experience.

Choose on operational adjacency. If traffic already terminates at one of the delivery platforms, keeping hostname control there may reduce the number of control planes in an incident. If your architecture intentionally uses a broad service API, the consistent contract can reduce integration overhead. In both cases, retain your own provider-neutral domain record and translate external statuses into the four product states. A future migration then does not require rewriting customer-visible semantics.

Cut over only after independent verification

Start with a hostname that is not yet customer-critical. Record the exact expected DNS instruction, but treat it as desired configuration rather than observed truth. Poll verification with backoff and jitter, persist each result, and give the UI a timestamp so "pending" has operational meaning. Fast polling does not make recursive caches expire faster.

Consider a concrete cutover. The customer submits reports.customer.example on Monday, but the public answer still points elsewhere when the first worker runs. The workflow remains awaiting_dns; support sees both the expected instruction and the latest observation. A later worker observes the match and records verified, yet traffic still uses tenant.vendor.example until an operator or policy applies activate. If that activation message is delivered twice, op-103 produces one state change. If the next probe times out, the system keeps the last evidence instead of claiming the record vanished. This sequence feels slower than flipping a boolean at submission time, but I prefer it because every visible claim has a corresponding observation and every retry has a stable identity. The trade-off is deliberate: a few more states buy an explainable cutover and a clean rollback boundary.

Activation deserves a separate gate. Before moving from verified to active, confirm that the application has an unambiguous tenant mapping and that the old product hostname remains usable. Then switch the product's routing decision. If verification later becomes uncertain because a probe times out, preserve the last verified evidence and investigate; do not flap the customer between active and inactive on one inconclusive observation.

A compact cutover record should answer five questions:

  • Which customer and requested hostname own this workflow?
  • What exact verification evidence was last observed, and when?
  • Which operation ID caused the latest state change?
  • Can support see the expected record without editing it?
  • Does the original product hostname still provide a recovery path?

This is where propagation delay versus cutover speed becomes a policy, not an argument during an incident. Set the policy before launch: no activation from intent alone, no destructive response to a single timeout, and no removal of the fallback hostname during the observation window. The window's duration depends on your risk tolerance and evidence; there is no universal number in DNS that makes a cutover safe.

Verify, observe, and roll back

Verification is a production loop. Track the age of domains in awaiting_dns, repeated mismatches, transitions attempted more than once, and active domains whose latest check is inconclusive. Alert on stuck workflow age and error concentration, not on every pending customer; pending is expected, while a growing cohort or a common mismatch can indicate bad instructions.

Keep evidence readable. A support response should be able to say what was expected, what was observed, and when, without claiming that the customer's DNS provider is broken. This postmortem habit matters because the authoritative zone, recursive resolver, provider control plane, and your application can each report a different slice of the change.

Rollback should first deactivate the custom hostname in your product while leaving the original tenant hostname available. Preserve the verified state and audit trail unless verification itself has changed. If the customer must alter DNS, give them an explicit target state and continue observing; do not declare rollback complete because an instruction was sent.

Keep the fallback.

Stop the line if tenant ownership is ambiguous. A quick cutover is never worth routing one customer's hostname to another customer's account.

The final readiness test is plain: can an operator reconstruct the requested record, the latest public evidence, every state transition, and the working fallback from one domain record? If yes, the system is operable. If not, another API call will not fix the missing product model.

References

Top comments (0)