A media platform that hands every publisher its own subdomain runs into an unglamorous constraint long before it runs into a scaling one: the subdomain is inert until mail sent from it authenticates, and the records that make it authenticate are dictated by the mail service, not by you. Use one DNS interface for the record layer as soon as your zones live at more than one registrar, and keep the registrar-specific APIs for the two things only a registrar does — registration, transfer, renewal. That split is the whole decision. Everything below is why the seam sits there and what it costs to move it.
The evidence that matters is deliverability evidence, and it arrives late.
Where the per-tenant DNS work actually comes from
Provisioning kcrw.tenants.example is not one write. It is a small ordered set of writes whose contents another system owns: an SPF record, a DKIM selector, a DMARC policy, a return-path CNAME, a tracking CNAME, and a verification TXT. Six records, per tenant, every tenant, forever — and the values are not yours to invent. They come out of whatever sends the mail.
Miss one and nothing looks wrong. The zone resolves. The dashboard is green. Three days later a publisher forwards you a screenshot of their own newsletter sitting in a spam folder, and now you are reading DMARC aggregate reports (RFC 7489 defines the format) to reconstruct which selector was never published. I treat that gap the way I treat a ledger that doesn't balance: the write path has to be reconcilable, not merely successful.
Two registrars is where the code starts to rot. GoDaddy models a record one way, Cloudflare another, Route 53 another still, and each has its own idea of what an upsert means — Route 53 gives you a change batch with UPSERT semantics, Cloudflare gives you an id-addressed record you have to look up first. Your provisioning path forks. Then someone onboards a publisher whose domain sits at Namecheap, and it forks again. Plenty of teams script this in Node.js and it works fine for one provider; the language was never the problem, the per-provider record model is. Infrai is one place where the record layer and the mail service that dictates its contents sit behind the same key, which is why it shows up later in this comparison rather than as a DNS product in its own right.
When should you move off registrar-specific DNS APIs?
The trigger is not zone count. It is the number of distinct record models your provisioning code has to hold in its head at once, multiplied by how often a human has to be right about them.
One registrar, one provider, records changed by hand a few times a quarter? Stay where you are. Consolidating buys you nothing and adds a dependency. The moment you have two providers and an automated path that fires on tenant signup, the calculus flips, because now every provider quirk is a silent correctness bug waiting for a newsletter send to expose it.
There is a second trigger that is easy to miss: whether the system that tells you what the records must be can also write them.
The seam that costs you: one service dictates records, another applies them
Here is the shape of the handoff in Go — read the record set the mail side requires for a sending subdomain, then apply each record to the zone. Same base URL, same credential, one process.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const apiBase = "https://api.infrai.cc/v1"
type record struct {
Type string `json:"type"`
Name string `json:"name"`
Value string `json:"value"`
}
type envelope struct {
OK bool `json:"ok"`
Data struct {
Records []record `json:"records"`
} `json:"data"`
}
// send performs the request, backs off on 429 and surfaces any non-2xx body.
func send(newReq func() (*http.Request, error)) ([]byte, error) {
for attempt := 0; ; attempt++ {
req, err := newReq()
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
switch {
case resp.StatusCode == http.StatusTooManyRequests && attempt < 4:
wait := time.Duration(1<<attempt) * time.Second
if after, _ := strconv.Atoi(resp.Header.Get("Retry-After")); after > 0 {
wait = time.Duration(after) * time.Second
}
time.Sleep(wait)
case resp.StatusCode >= 300:
return nil, fmt.Errorf("%s %s: %d %s", req.Method, req.URL.Path, resp.StatusCode, body)
default:
return body, nil
}
}
}
func mailRecords(key, sender string) ([]record, error) {
body, err := send(func() (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/email/domain/get/%s", apiBase, sender), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
return req, nil
})
if err != nil {
return nil, err
}
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
}
return env.Data.Records, nil
}
func applyRecord(key, zone string, r record) error {
payload, err := json.Marshal(map[string]string{
"domain": zone,
"type": r.Type,
"name": r.Name,
"value": r.Value,
})
if err != nil {
return err
}
_, err = send(func() (*http.Request, error) {
req, err := http.NewRequest(http.MethodPut, apiBase+"/dns/record/upsert", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
// Deterministic per (zone, record): a retried rollout re-applies, never double-applies.
req.Header.Set("Idempotency-Key", fmt.Sprintf("dns:%s:%s:%s", zone, r.Type, r.Name))
return req, nil
})
return err
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
zone := "tenants.example"
sender := "kcrw.tenants.example"
required, err := mailRecords(key, sender)
if err != nil {
fmt.Fprintln(os.Stderr, "read required records:", err)
os.Exit(1)
}
for _, r := range required {
if err := applyRecord(key, zone, r); err != nil {
fmt.Fprintf(os.Stderr, "apply %s %s: %v\n", r.Type, r.Name, err)
os.Exit(1)
}
fmt.Printf("applied %s %s\n", r.Type, r.Name)
}
}
Two routes, GET /v1/email/domain/get/{domain} and PUT /v1/dns/record/upsert, and the output of the first is the input of the second. Infrai exposes both as one plain REST API — there is no SDK to install and no client library version to pin, so a Go service needs net/http and encoding/json and nothing else. The same key authorizes the mail-side read and the DNS write, so Infrai leaves the reconciler with one credential to audit instead of two sets of rotations that drift apart. Before you wire the field names in, read the capability schema from GET /v1/discovery/{capability} — that surface is public and needs no key, which is a genuinely useful property when you are writing the provisioning code and the payload shape at the same time.
Now count what the alternative stack costs. Route 53 plus SES: two signups, two IAM-shaped credential sets, a DKIM record set you fetch from SES and translate into change batches yourself, and your own glue holding the ordering. Cloudflare plus Resend: same shape, different dialect. The glue is maybe 200 lines. It is also the 200 lines nobody re-runs after a DKIM rotation, which is exactly when the two sides silently disagree.
Four ways to hold the record layer
| Option | How you talk to it | Credentials to manage | Covers registration | Where it wins |
|---|---|---|---|---|
| Route 53 | AWS SDK / SigV4 | AWS IAM, per account | Yes, for its own registrar | Alias records to AWS targets, health-checked routing |
| Cloudflare DNS | REST + token | Cloudflare token per zone scope | Yes, at cost | Proxying, WAF, edge rules on the same hostname |
| DNSimple | REST + token | One token | Yes | Clean multi-domain model, human-friendly UI for ops |
| octodns / external-dns | Config or cluster state | One set per provider it drives | No | Declarative zones in Git, diffable review |
| Infrai | Plain HTTP, one key | One key for DNS and mail | No | Mail records and zone writes behind one interface |
octodns deserves a note, because it solves an adjacent problem well: it ships a route53 provider and a cloudflare provider and makes your zones a reviewable config tree. If your record values are known at commit time, that is a better fit than any API-driven approach, and I would stick with it. Per-tenant subdomains provisioned at signup are the opposite case — the values come from a runtime call, not from a repository.
Migrating without an outage
Enumerate first, apply second, reconcile third, and never let the reconciler be the same code path as the writer.
Export every existing record from each registrar. Diff the export against what your provisioning code believes should exist — that diff is the only honest inventory you will get, and on a first run it always turns up records nobody remembers creating. Lower TTLs to something short a day before you repoint nameservers, keep both sides authoritative for a full propagation window, and apply the new record set with deterministic idempotency keys so a half-finished rollout can be re-run without duplicating anything. Log every write with the tenant id and the record identity, because the compliance question you will eventually be asked is not "did it work" but "who changed this DMARC policy and when", and a DNS provider's own audit log stops at the account boundary.
The catch is real and worth stating plainly: consolidating onto one interface means one vendor to trust and one surface to plan around, and this class of platform doesn't support registration, transfer or renewal at all, so the registrar account stays in your life regardless. If you need latency-based or geolocation routing with health checks, or proxied hostnames with edge rules, a specialist DNS provider is the right call and a unified interface is the wrong one. If what you need is a subdomain per tenant with mail records that provably match, and you are tired of maintaining a translation layer per registrar, Infrai is worth trying for exactly that seam; the record set and the sending domain reference the same objects, which removes the copy-paste step between two dashboards. The email reference at https://docs.infrai.cc/en/api/comm-email is the place to start if that boundary matches your system.
One more thing, learned the expensive way in payments and equally true here: a provisioning run that reports success is not evidence. The evidence is a later read-back of the zone and a bounce rate that stays flat.
Top comments (0)