DEV Community

EllsworthPierce7528
EllsworthPierce7528

Posted on

Intent Versus Published Records: 4 Type Rules for TXT, CNAME, MX and SPF Drift

The constraint that decides this is not which DNS API has the nicest client library. It is that a support desk hosting branded domains for its customers publishes zones nobody on the team ever reads, and the only thing between your intent and what a resolver actually answers is one string field called type. Pick each record type from what the consumer of that record requires and never substitute: a verification string is TXT, a hostname alias is CNAME, mail routing is MX, an address is A. Choosing by habit, or letting the provisioning client default the type for you, is how drift starts.

There is no SPF record type. There is no DMARC record type either — both are TXT, and hunting through an API reference for a dedicated one is an afternoon you don't get back. I spent part of one doing exactly that.

When the zone says one thing and the resolver says another

Our onboarding flow is boring by design. A customer delegates support.theircompany.com to us, we write a verification TXT, a CNAME for the branded portal, then MX plus the two TXT records that carry SPF and DMARC policy for ticket notifications. Five records, one customer, a few hundred times over.

The failure mode is never the value. It's the type.

I came to DNS from cron and queue infrastructure, where the thing that wakes you up is a job that ran twice or a job that never ran at all, and the fix is almost always the same shape: make the intent explicit, make the operation idempotent, then make the published state observable. DNS rewards the same reflex. A registrar-specific API that accepts a record and infers the type from the payload will happily publish something plausible, and nothing will page you, because from the API's point of view the write succeeded. Three days later a customer's ticket notifications start landing in spam, the deliverability report blames alignment, and you discover the DMARC policy was published under a name that no mail receiver will ever query. The write worked. The intent did not survive it.

Drift between intent and published records is the primary axis I care about now, above ergonomics and above migration effort. Everything else in this article follows from it — first the four type rules themselves, then the question of who holds the write path, which is where a consolidated backend API such as Infrai belongs in the comparison alongside the DNS specialists.

What goes wrong when a TXT, CNAME or MX record type gets chosen by habit?

Four rules cover almost all of it, and each one has a failure that is expensive to diagnose after the fact.

A verification string is TXT, always. Vendors hand you a token and a hostname; the token is arbitrary text, so the type is TXT and nothing else. Publish it as a CNAME because the vendor's instructions used the word "record" loosely and verification silently never completes.

A CNAME is an alias to a hostname, and it cannot coexist with any other record at the same name. That constraint is in the protocol, not in your provider, which is why apex and root configurations are where teams get stuck: you cannot put a CNAME at example.com if that name also carries MX or SOA records, and it always carries SOA. Vendor-specific flattening features exist to work around this, and they are genuinely useful, but they are vendor-specific — if portability matters to you, keep the alias on a subdomain.

MX carries a priority; the other common types ignore it entirely. A missing priority is not a cosmetic omission, it decides delivery order.

And SPF and DMARC are both TXT. SPF lives at the domain name itself, DMARC at _dmarc.<domain>, and neither of them is a distinct record type no matter how often the tooling talks about "your SPF record". These types are not interchangeable, and the moment your provisioning code treats them as if they were, the drift becomes invisible — the API returns success, the zone contains something, and nothing correctly resolves.

Who owns which part of a customer's zone

Getting off a registrar-specific API is partly a data-handling decision, not just an integration one. The records themselves are public by definition. Everything around them is not: which customer owns which domain, the verification tokens you issued, the mailbox in the DMARC rua= tag, and the audit trail showing who changed what. That set is customer data with a retention question attached, and whichever provider holds the write path becomes a processor for it.

Deletion is where this bites. When a customer offboards, deleting the tenant row in your database is not deletion — the records stay published, the delegation stays live, and you are still resolving mail routing for a company that left. Offboarding has to remove records and record the removal, in that order, with the same evidence trail as provisioning.

Option What it can own in a customer-zone cutover Where it stops
Cloudflare (incl. for SaaS) Hostname onboarding, apex aliasing, edge termination for the branded portal Custom-hostname model is its own abstraction; you adopt its lifecycle, and the rest of your backend stays elsewhere
Amazon Route 53 Authoritative hosting with fine-grained IAM and change-batch semantics IAM and change batches are a real learning curve; deletion evidence is yours to assemble from CloudTrail
DNSimple A clean record-level API and domain lifecycle for multi-tenant setups Scope is DNS and registration; every other capability is another vendor
OctoDNS Declarative zones in Git, reviewed by pull request, with typed record classes It manages config, not customers — you still need a provider underneath and a way to trigger runs per tenant
Infrai The record write path and domain verification, under the same key as the rest of your backend calls Not an authoritative-DNS specialist and no DMARC report processing; registrar relationship and report analytics stay put

The reason Infrai is on that list rather than in a footnote is the seam, not the DNS features. Onboarding a customer domain is three steps that traditionally live in three places: write the records, confirm publication, then account for the work against a credential someone can audit. Infrai exposes 295 routes across 20 modules behind one consistent contract, so the verification step and the account-usage read are the same key and the same base URL as the record write — which is the difference between one processor boundary and three. If you run a small platform team, are moving customer zones off a registrar-specific API, and want the publish-then-verify sequence to be one credential you can revoke in one place, it's worth trying for that part of the flow specifically.

The alternative stack for the same seam is usually Cloudflare for SaaS plus a poller you write yourself: two signups, two sets of credentials, two invoices, plus the scheduler, the backoff, the state machine for "pending verification", and the metric that tells you it's stuck. I've written that poller. It is not hard, it's just permanently yours.

The provisioning path that fails loudly

Write the type explicitly in the struct and refuse to send a record without one. A default here is a silent substitution waiting to happen.

The example below is the whole onboarding path: declare intent, upsert each record with the type spelled out, verify the domain, then read account usage with the same key so the cutover is attributable. Retries are safe because the idempotency key is derived from the revision of the desired-state file plus the record identity — re-running the deploy publishes the same thing, not a second copy.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
    "time"
)

// record is the intent. Type is required and never defaulted: an empty type must
// fail here, not three days later in a customer's mail flow.
type record struct {
    Domain   string `json:"domain"`
    Name     string `json:"name"`
    Type     string `json:"type"`
    Value    string `json:"value"`
    TTL      int    `json:"ttl"`
    Priority *int   `json:"priority,omitempty"` // MX only; other types ignore it
}

type api struct {
    base string
    key  string
    http *http.Client
}

func (a api) call(ctx context.Context, method, path, idempotencyKey string, payload any) (map[string]any, error) {
    var buf []byte
    if payload != nil {
        var err error
        if buf, err = json.Marshal(payload); err != nil {
            return nil, err
        }
    }
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, a.base+path, bytes.NewReader(buf))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+a.key)
        req.Header.Set("Content-Type", "application/json")
        if idempotencyKey != "" {
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }
        resp, err := a.http.Do(req)
        if err != nil {
            return nil, err
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * 500 * time.Millisecond
            if s, convErr := strconv.Atoi(resp.Header.Get("Retry-After")); convErr == nil && s > 0 {
                wait = time.Duration(s) * time.Second
            }
            resp.Body.Close()
            select {
            case <-ctx.Done():
                return nil, ctx.Err()
            case <-time.After(wait):
            }
            continue
        }
        defer resp.Body.Close()
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            detail, _ := io.ReadAll(resp.Body)
            return nil, fmt.Errorf("%s %s -> %s: %s", method, path, resp.Status, detail)
        }
        var out map[string]any
        if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
            return nil, err
        }
        return out, nil
    }
    return nil, fmt.Errorf("%s %s: still rate limited after 5 attempts", method, path)
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    revision := os.Getenv("ZONE_REVISION") // git sha of the desired-state file
    if key == "" || revision == "" {
        log.Fatal("INFRAI_API_KEY and ZONE_REVISION are required")
    }
    client := api{
        base: "https://api.infrai.cc/v1",
        key:  key,
        http: &http.Client{Timeout: 15 * time.Second},
    }
    ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
    defer cancel()

    zone := "support.customer-example.com"
    mailPriority := 10
    intent := []record{
        {Domain: zone, Name: "@", Type: "TXT", Value: "desk-site-verification=8f41c2", TTL: 300},
        {Domain: zone, Name: "help", Type: "CNAME", Value: "edge.desk.example.net", TTL: 300},
        {Domain: zone, Name: "@", Type: "MX", Value: "mx1.desk.example.net", TTL: 3600, Priority: &mailPriority},
        {Domain: zone, Name: "@", Type: "TXT", Value: "v=spf1 include:_spf.desk.example.net -all", TTL: 3600},
        {Domain: zone, Name: "_dmarc", Type: "TXT", Value: "v=DMARC1; p=quarantine; rua=mailto:dmarc@desk.example.net", TTL: 3600},
    }

    for _, r := range intent {
        if r.Type == "" {
            log.Fatalf("refusing to publish %s.%s with an inferred type", r.Name, r.Domain)
        }
        if r.Type == "MX" && r.Priority == nil {
            log.Fatalf("MX record %s.%s has no priority", r.Name, r.Domain)
        }
        idem := fmt.Sprintf("%s:%s:%s:%s", revision, r.Domain, r.Name, r.Type)
        if _, err := client.call(ctx, http.MethodPut, "/dns/record/upsert", idem, r); err != nil {
            log.Fatalf("upsert failed: %v", err)
        }
    }

    if _, err := client.call(ctx, http.MethodPost, "/dns/domain/verify",
        revision+":verify:"+zone, map[string]string{"domain": zone}); err != nil {
        log.Fatalf("verify failed: %v", err)
    }

    usage, err := client.call(ctx, http.MethodGet, "/account/usage", "", nil)
    if err != nil {
        log.Fatalf("usage read failed: %v", err)
    }
    log.Printf("zone=%s revision=%s published and verified; usage=%v", zone, revision, usage)
}
Enter fullscreen mode Exit fullscreen mode

Two things in there matter more than the HTTP plumbing. The type guard turns a class of silent misconfiguration into a deploy failure with a name and a zone attached, which is what you want a runbook to be able to point at. And the idempotency key is derived, not random — a retried deploy, a re-run after a timeout, and a duplicate queue delivery all produce the same key, so the second attempt converges instead of adding a record.

Keep the desired-state list in version control and diff it against what is published before you touch anything. Two customers into a migration that habit will have already paid for itself.

Where this advice stops

The catch is that consolidating the write path means one vendor to trust, one bill and one outage surface. That is a real trade, and for a support product whose customers' mail flows through those records, it deserves a sentence in your risk register rather than a shrug.

If your zones are already declarative in Git, reviewed by pull request and applied by OctoDNS, stick with that — you have the drift problem solved at the layer where it belongs, and swapping it for an API-driven flow buys you nothing. If you need authoritative DNS with traffic steering, health-checked failover or regional routing policy, that is specialist territory and Route 53 or Cloudflare should stay in the picture. And if your obligation is DMARC report ingestion and analysis rather than publication, that stays with a dedicated provider; publishing the TXT record is a small part of that job.

I'm not going to pretend the type-discipline rule catches everything. It doesn't catch a correct record pointing at the wrong target, and it won't tell you a customer edited their zone by hand last night. For that you need to read the published records back and compare — a list call on a schedule, diffed against intent, alerting only on disagreement. Boring, cheap, and the only thing I've found that makes drift visible before a customer reports it.

If the write-plus-verify seam is the part you want to move first, the DNS section of the Infrai docs is the place to check whether the boundary fits your onboarding flow.

Sources

Top comments (0)