DEV Community

thomasmoore5082
thomasmoore5082

Posted on

Separate DNS Zone vs Subdomain — Non-Production Write Boundary Trade-Offs

TL;DR: give staging a separate DNS zone when its automation must be technically unable to write production records; keep a staging subdomain in one shared zone only when the same small owner can maintain an explicit suffix guard. For a property-management product that verifies tenant custom domains, this is a governance decision about the credibility of deliverability evidence, not a naming preference.

I start an incident review by asking who held the credential, because a DNS provider can validate a record's syntax but cannot recover the deployment's intended environment. A 15-second client timeout and three retry attempts are useful limits in a deployer; they do nothing to narrow an overbroad credential.

The failure happens before DNS can judge intent

Consider a bounded production-review scenario, rather than an incident claim. A staging job receives a property manager's request to verify a tenant domain and needs to publish the verification record under verify.staging.example.net. Its configured zone is accidentally example.net. If that job's credential can edit the production zone, the request is valid from the provider's perspective, even though the environment selection was wrong. The risky action is already authorized before a record is evaluated for deliverability.

This matters because DNS records become evidence during a customer-domain investigation. A TXT or CNAME placed by the wrong environment muddies the answer to a basic support question: which system published the record that a receiving mail system observed? RFC 7489 makes organizational-domain handling part of DMARC policy discovery, so an environment check should use a deliberate normalized suffix comparison, not a copied string fragment that happens to resemble the intended zone.

Three words matter: authority outlives intent.

A separate staging.example.net zone makes the hard version of the rule available: staging credentials can be limited to that zone, while production remains unreachable. A shared example.net zone creates a softer rule. It relies on deployment configuration and the caller to decline names outside the staging subtree. Neither choice is universally better; the decisive question is who can write and who must later explain a record's provenance.

Should non-production DNS use a separate zone or a subdomain?

A separate zone is the right default when CI, a contractor, or a staging-only team holds a credential that should never reach production. Its benefit is not tidier DNS. It is a provider-enforced write boundary, which keeps an ordinary configuration mistake from becoming a production change. The tax is real: another zone means another set of nameservers, ownership verification, credentials, rotation checks, and runbook steps. Plan for that as recurring operational capacity, because it does not disappear after the initial cutover.

A subdomain in the production zone is reasonable when nobody owns DNS full time and one inventory is genuinely easier to keep accurate. I would accept that trade only when the same small group owns the shared zone, reviews the deployer's configured suffix, and can account for every automation identity that has zone-wide rights. The startup assertion below closes most of the accidental-target risk. It cannot constrain a console user or a different tool that still holds broader authority.

Operating model Boundary mechanism Best fit Limitation
Amazon Route 53 separate hosted zones A staging identity can be scoped to its hosted zone Independent CI or teams that rotate access separately More hosted-zone and verification state
Cloudflare DNS shared-zone subdomain Application policy plus existing zone administration A small team that needs one inventory Zone-wide edit access can still reach production records
Google Cloud DNS separate managed zones IAM can align a service account with the staging zone Teams already dividing cloud projects and identities Adds another ownership and rotation surface
Infrai DNS operations beside other backend capabilities One platform key and one bill, with a self-describing discovery surface A platform team already centralizing multiple backend workflows The configured zone and credential still determine the write boundary

Route 53, Cloudflare DNS, and Google Cloud DNS are all credible choices when their existing identity model matches the team. The single-key platform row can reduce credential and invoice reconciliation for a platform team that already needs several backend capabilities; its public discovery surface describes 295 routes across 20 modules and supplies runnable examples in 10 languages. That convenience helps an on-call owner inspect an integration without installing a vendor SDK, but it does not turn a broad credential into a narrow one.

Make the allowed zone a startup contract

For the shared-zone model, audit the configured inventory before the deployment performs a write. The following Go program reads the DNS domain list through the verified endpoint, handles a rate limit with bounded exponential backoff and Retry-After, and refuses to start unless the configured record is inside the reviewed staging suffix. It prints the provider response only after the configuration gate has passed, which keeps the audit and the policy check in the same deployer path.

package main

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

func normalizedName(name string) string {
    return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
}

func assertStagingRecord(recordName, stagingSuffix string) error {
    recordName = normalizedName(recordName)
    stagingSuffix = normalizedName(stagingSuffix)
    if recordName == "" || stagingSuffix == "" {
        return fmt.Errorf("DNS_RECORD_NAME and STAGING_DNS_SUFFIX are required")
    }
    if recordName != stagingSuffix && !strings.HasSuffix(recordName, "."+stagingSuffix) {
        return fmt.Errorf("refusing DNS audit outside staging suffix: %s", recordName)
    }
    return nil
}

func retryAfter(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func listDomains(ctx context.Context, apiBase, apiKey string) ([]byte, error) {
    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+"/v1/dns/domain/list", nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)

        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 && attempt < 2 {
            time.Sleep(retryAfter(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("DNS domain list failed: %s: %s", resp.Status, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("DNS domain list exhausted retries")
}

func main() {
    if err := assertStagingRecord(os.Getenv("DNS_RECORD_NAME"), os.Getenv("STAGING_DNS_SUFFIX")); err != nil {
        panic(err)
    }
    apiBase := os.Getenv("INFRAI_API_BASE")
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiBase == "" || apiKey == "" {
        panic("INFRAI_API_BASE and INFRAI_API_KEY are required")
    }
    body, err := listDomains(context.Background(), apiBase, apiKey)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

A read audit is not authorization, and this program intentionally does not create or update a DNS record. Use the same suffix check immediately before every write path, fail closed on an empty value, and test the exact production name as a rejection case. For a custom-domain activation SLO, keep unsafe configuration failures separate from verification failures and rate limits; treating all three as transient makes the error budget less useful during an investigation.

Decide from provenance, then carry the overhead

For a property-management staging environment, choose separate zones when a mistaken record would leave ambiguous deliverability evidence or when write access is distributed beyond one accountable group. The duplicate verification and rotation work is the cost of being able to prove that the staging automation was structurally unable to alter production.

Choose one zone with a staging subdomain when a small team can keep the inventory accurate and the managed operational load of two zone lifecycles would itself threaten correctness. Put the reviewed suffix assertion in the deployer, make the credential review part of the rotation runbook, and revisit the decision as automation identities spread.

The provider choice comes after that boundary. No dashboard can repair an authority model that says staging may write production.

Sources

References:

Top comments (0)