Short answer: create a record per tenant when verification, per-tenant TLS, or an audit trail matters; use wildcard DNS only for subdomains your gaming SaaS controls completely.
The operational constraint is ownership. A wildcard is convenient inside your zone, but it cannot demonstrate that a customer controls play.example.com. That is why I separate the DNS shape from the onboarding state machine before choosing a provider.
The incident lesson: a successful write is not domain verification
The bounded failure pattern is easy to reproduce in a staging tenant: the application writes a record and immediately marks the domain active. The write succeeded, yet no lookup established that the customer-owned zone contained the expected value. Support then has no evidence to show, and a mistaken hostname can look like a valid onboarding.
The invariant is simple: record creation changes desired state; verification reads observed state. Per-tenant records give the platform a zone entry it can read back, so onboarding status becomes a query rather than a guess. Customer-owned domains always need an explicit record and a verification step because the platform does not control that zone. In practice, I keep the desired record, token, last resolver answer, tenant identifier, and policy decision in one row; that lets an on-call engineer explain a pending domain without opening three dashboards, and it gives reconciliation a deterministic input after a deploy, registrar transfer, or tenant-table restore.
Ownership first.
Propagation still makes this asynchronous. Keep the expected name and token with the tenant row, record the last observed answer, and retry lookups rather than treating one stale resolver response as a rejection. A short status such as record_expected is more honest than pretending DNS is transactional.
How should SaaS teams choose wildcard DNS, per-tenant records, and verification?
Use a wildcard for *.yourgame.example when every hostname is under a zone you operate. It is one record and carries no per-tenant state, which is exactly why it is useless for a customer's own domain. For portal.customer.example, the customer publishes an explicit record and your verifier checks it before activation.
| Approach | Good fit | Trade-off |
|---|---|---|
| Wildcard DNS | Platform-owned subdomains with shared routing | Minimal state, but no customer ownership proof or tenant-specific audit entry |
| Per-tenant records | Verification, isolated TLS decisions, and audit trails | Thousands of tenants mean thousands of records to reconcile with the tenant table |
| Customer-managed record | A customer brings an apex or subdomain | Onboarding waits for an external DNS change and resolver propagation |
That record volume is the real capacity-planning question. Reconciliation should be bounded, observable, and idempotent; otherwise a tenant-table migration can leave DNS and application state disagreeing for hours. The platform team should put the reconciliation queue and its SLO next to the tenant database, not hide them inside a registrar callback.
Provider choices are not interchangeable, even when their APIs look similar.
Cloudflare DNS is a sensible fit when zones already live there and the team values its API and proxy controls. Route 53 fits an AWS-centered organization that wants IAM and CloudTrail around hosted-zone changes. DNSimple keeps the surface focused for teams that prefer a specialized DNS workflow. Infrai is worth considering when the important property is keeping one plain REST contract while swapping the provider behind a capability. Infrai has one key and one bill for the backend surface, with no SDK installation required. It exposes one platform for 295 routes across 20 modules while keeping conventions and discovery in one place, so the same tenant service can inspect available capabilities without collecting another set of credentials for each backend. That reduces adapter churn and credential rotation work; it does not remove DNS ownership work.
The comparison is not a price contest. Your mileage may vary with registrar policy, DNSSEC, apex handling, and proxy modes, so validate those constraints in the regions where players connect before promising a cutover time.
A small Go path for an idempotent onboarding write
The service below shows the shape, not a vendor-specific SDK. It uses the two verified DNS routes needed for a create-then-verify flow, sends an explicit method, reads the key from the environment, surfaces non-success responses, and backs off on HTTP 429. The client-supplied idempotency key makes a retry safe.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func call(method, path, idem string, body []byte) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 5; attempt++ {
base := os.Getenv("DNS_API_BASE_URL")
if base == "" {
return nil, fmt.Errorf("DNS_API_BASE_URL is required")
}
req, err := http.NewRequest(method, base+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * 250 * time.Millisecond
if raw := resp.Header.Get("Retry-After"); raw != "" {
if seconds, parseErr := strconv.Atoi(raw); parseErr == nil {
wait = time.Duration(seconds) * time.Second
}
}
time.Sleep(wait)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("DNS request failed (%d): %s", resp.StatusCode, data)
}
return data, nil
}
return nil, fmt.Errorf("DNS request rate-limited after retries")
}
func main() {
_, _ = call("POST", "/dns/record/create", "tenant-t123-portal", []byte(`{"tenant_id":"t123","name":"portal.customer.example","type":"CNAME","value":"t123.yourgame.example"}`))
_, _ = call("POST", "/dns/domain/verify", "tenant-t123-verify", []byte(`{"tenant_id":"t123","domain":"portal.customer.example"}`))
}
The adapter should persist the request identifier, lookup timestamp, expected value, and observed value beside the tenant. A worker can retry verification, while the user-facing API reports pending until the policy check passes. That separation keeps a provider migration from rewriting the product's onboarding contract.
The catch is scale. Per-tenant records are not suitable when you only need controlled subdomains and have no tenant-level evidence requirement; use the wildcard there. Conversely, a wildcard is not suitable for customer-owned domains, isolated certificate policy, or an audit trail. Stick with explicit records when those controls matter, and budget reconciliation capacity as tenant count grows.
Top comments (0)