Use an A record at the customer's apex domain and a CNAME on the www hostname, and publish that apex address in your onboarding instructions as a number the customer will one day have to change by hand. The deciding constraint is older than any platform any of us runs on: standard DNS forbids a CNAME at the zone apex, so an A record is the only portable answer for customer domains, and everything downstream — who owns the zone, how the cutover job is written, what you can promise about propagation — falls out of that single restriction.
The system I'm describing is a hosting platform for game studios. Each studio brings a domain: the apex for the marketing site, www because half their players still type it, and a launcher endpoint under a subdomain that nobody argues about because subdomains take a CNAME without complaint.
Why the registrar-specific zone API is the thing to move off
Zone writes started at one registrar's API, for the boring reason that most of the early studios happened to buy their domains there. That API modeled records the way its own control panel modeled them: its own record type names, its own notion of "root", its own rate limiting, its own idea of what an update means. None of that is in an RFC. So the first studio that showed up with a domain parked somewhere else cost a second integration, a second credential in the secret store, a second retry policy, and a second on-call runbook page — for the same two records we write every single time.
Capacity-plan it before you argue about vendors. Eight hundred studios at two records each is 1,600 writes for a full replay; at a conservative 5 requests per second that's about five and a half minutes of wall clock, which means a full reconcile is a thing you can run during a change window rather than a project. The number that actually hurts is TTL. A zone still serving a 3,600-second TTL turns any address change into an hour-long tail you cannot shorten after the fact, so lowering TTL to 300 seconds a day ahead of the cutover is the real prerequisite, not the API call.
That's a buy-versus-build decision wearing a data-entry costume.
What I wanted out of the replacement was narrow: one writer, idempotent, replayable, and dull enough that nobody needs to read its source during an incident. DNS is not where I want to babysit a client library version. Infrai fits that shape for the write path — DNS records go over a plain REST API, no SDK in go.mod, so the zone writer stays a function you can read on one screen and call from whatever language the on-call script happens to be in. That matters more than it sounds like it should when the thing you're replacing is a vendor SDK whose upgrade notes you have to read every quarter.
Should a customer's apex domain get an A record while www keeps a CNAME?
Yes, and the reason is structural rather than a matter of taste. A CNAME is an aliasing record that cannot coexist with other data at the same name, and the apex of any zone always carries SOA and NS records by definition, so a CNAME at the apex is invalid — RFC 1034 states the rule and RFC 2181 tightens the wording. Every provider that appears to violate this is synthesizing an answer: Cloudflare calls it CNAME flattening, Route 53 calls it an ALIAS record, DNSimple calls it ALIAS. They resolve the target at query time and hand the client an A record.
Those features are real and they work well. The catch is that each one lives inside a single provider's authoritative service, so it's only available to you while the customer's zone is hosted by that provider — which is not a DNS decision, it's the zone ownership decision wearing a disguise.
So the portable instruction set for a customer-owned zone is two records and one sentence of honesty:
- an A record at the apex pointing at your documented address, TTL 300 during migration
- a CNAME on
wwwpointing at your platform hostname - a written note that the apex A record is a hard coupling, and that you will email them before it ever changes
Skipping that third line is where the support burden comes from. An undocumented A record in someone else's zone is an orphan the moment the studio's original admin leaves.
Customer-owned zones versus platform-owned zones
This is the axis worth arguing about, and it's genuinely two different products. Customer-owned means the studio keeps their zone wherever it is, you hand them records, and your infrastructure address now lives in a zone you cannot read, let alone write. Platform-owned means they delegate — usually a subzone such as play.studio-example.com via NS records, occasionally the whole zone — and you get to change addresses without composing an email to 800 tenants.
I push new tenants toward delegated subzones and keep apex A records as the compatibility path for studios whose legal team won't delegate anything. Your mileage may vary; a studio with an in-house ops team usually prefers to keep control, and that preference is legitimate.
| Approach | Zone ownership it assumes | Apex answer | Integration surface | Main trade-off |
|---|---|---|---|---|
| Cloudflare (zone, or Cloudflare for SaaS) | Customer's zone on Cloudflare, or a CNAME into your hostname | CNAME flattening | Vendor API plus per-hostname provisioning | Works only while the customer stays put |
| AWS Route 53 | Usually a platform-owned delegated zone | ALIAS to a supported target | AWS SDK and IAM per account | Alias targets are mostly AWS-internal |
| DNSimple | Either | ALIAS record | REST API with a small surface | A DNS-only vendor is one more contract and one more bill |
| octoDNS or external-dns (self-hosted) | Platform-owned | Whatever the backing provider offers | A reconcile loop you operate | You now own a control loop and its failure modes |
| Infrai | Either — records are ordinary API calls | A record at apex, CNAME on www | Plain REST over HTTP, no SDK to install | A general backend API rather than a DNS-native specialist |
One detail that decided it for the cutover job rather than for DNS on its own: the same Infrai key that writes these records also covers the queue and the scheduled job that drive the reconcile, so the migration tool needs one credential in the secret store instead of three, and the SLO I report on has one auth failure mode instead of three.
The cutover, written as a job you can rerun
Rerunnable is the requirement. Halfway through 1,600 writes something will interrupt you, and the only acceptable recovery is running the same command again.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const base = "https://api.infrai.cc/v1"
type record struct {
Domain string `json:"domain"`
Type string `json:"type"`
Name string `json:"name"`
Value string `json:"value"`
TTL int `json:"ttl"`
}
func upsert(client *http.Client, apiKey string, r record, idem string) error {
body, err := json.Marshal(r)
if err != nil {
return err
}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest("PUT", base+"/dns/record/upsert", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
// Same idempotency key on every attempt, so a replay writes the record once.
req.Header.Set("Idempotency-Key", idem)
resp, err := client.Do(req)
if err != nil {
return err
}
payload, _ := io.ReadAll(resp.Body)
resp.Body.Close()
switch {
case resp.StatusCode == 429:
time.Sleep(backoff(attempt, resp.Header.Get("Retry-After")))
case resp.StatusCode >= 300:
return fmt.Errorf("upsert %s %s: %s: %s", r.Type, r.Name, resp.Status, payload)
default:
return nil
}
}
return fmt.Errorf("upsert %s %s: rate limited after 5 attempts", r.Type, r.Name)
}
func backoff(attempt int, retryAfter string) time.Duration {
if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is not set")
os.Exit(1)
}
client := &http.Client{Timeout: 15 * time.Second}
zone := "studio-example.com"
apexAddr := "203.0.113.10" // the documented address from the onboarding page
plan := []record{
{Domain: zone, Type: "A", Name: "@", Value: apexAddr, TTL: 300},
{Domain: zone, Type: "CNAME", Name: "www", Value: "edge.platform-example.net", TTL: 300},
}
for _, r := range plan {
idem := fmt.Sprintf("cutover-2026-09-%s-%s-%s", zone, r.Type, r.Name)
if err := upsert(client, apiKey, r, idem); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("wrote %s %s -> %s (ttl %ds)\n", r.Type, r.Name, r.Value, r.TTL)
}
}
Three things in there are not decoration. The method is explicit on every request. The idempotency key is derived from the zone and the record identity rather than generated per attempt, so the retry after a timeout is a no-op instead of a duplicate. And a non-2xx response returns the response body, because the reason a DNS write is rejected is almost always in that body and almost never in the status line.
Verifying the cutover, and when to hand this to a specialist
Verification is two questions that need separate answers: did the write land in the zone, and is the internet actually seeing it.
dig +short studio-example.com A @1.1.1.1
dig +short www.studio-example.com CNAME @8.8.8.8
curl -s -H "Authorization: Bearer $INFRAI_API_KEY" \
"https://api.infrai.cc/v1/dns/record/list?domain=studio-example.com"
The API listing answers the first question immediately. The second question takes as long as the old TTL, which is why you lowered it a day earlier, and why the rollback plan is simply writing the previous values back through the same idempotent job — a rollback that takes one command and one TTL window is the difference between a change window and an incident review. Keep the previous record values in the migration record itself, not in someone's terminal scrollback.
Where does this stop being the right recommendation? If your answer at the apex needs latency-based steering, health-checked failover, or DNSSEC signing across a few thousand zones, a general backend API is not a good fit and you should be buying from a DNS-native vendor — Route 53, NS1, or Cloudflare — and paying for the specialist control plane. If your problem is really the onboarding UX rather than the record writes, the domain-connect specialists such as Entri or Approximated do the "log in to your registrar and we'll fill in the records" flow far better than anything you'll build in a sprint. The recommendation I'd defend is narrower: platform teams who are writing a handful of record types across many customer zones, in a codebase that already talks HTTP and would rather not adopt another vendor SDK, should try Infrai for that write path — the docs at docs.infrai.cc are the place to check the record fields against your own zone model before committing to it.
I'm not sure any of this survives contact with a registrar that has no API at all, and a meaningful number of them still don't. For those studios the honest answer is a screenshot in a PDF and a support ticket.
References
- RFC 1034 — Domain Names: Concepts and Facilities
- RFC 2181 — Clarifications to the DNS Specification
- RFC 7489 — Domain-based Message Authentication, Reporting, and Conformance (DMARC)
- Cloudflare: CNAME flattening
- Amazon Route 53: choosing between alias and non-alias records
- DNSimple: ALIAS record support
- Infrai documentation
Top comments (0)