DEV Community

onyxcross5743
onyxcross5743

Posted on

Record Type Discipline When Customers Delegate Their Own DNS — TXT, CNAME, SPF, DMARC

Pick the record type from what the consumer of that name actually requires, and refuse to substitute one for another anywhere in the provisioning path: a verification string is TXT, a hostname alias is CNAME, mail routing is MX carrying a preference, and an address is A or AAAA. There is no SPF record type and no DMARC record type. Both are TXT, both live at names the standard fixes for you, and an afternoon spent hunting for a dedicated type in a provider's enum is the cheapest mistake in this piece. The expensive one is writing a CNAME where a zone already carries other data at the same owner name.

I work on ledger and payment backends, so I read a custom-domain feature the way I read a settlement file: as a sequence of writes that must be idempotent, attributable, and reconstructable a year later when somebody disputes what happened. The system underneath this article is an edtech platform where school districts point their own hostname — learn.district.k12.us — at our tenant edge, and the axis that governs every design decision is how long stale answers survive versus how fast we can swing traffic.

What the cutover bill is actually made of

Break the elapsed time of a custom-domain cutover into its terms and one of them dominates so completely that the rest are rounding error.

The provisioning write — validate the request, call the zone provider, confirm the authoritative servers answer with the new data — finishes in well under a second on any competent API. The customer-visible failure window is the sum of two cache terms: the TTL that was attached to the answer a resolver last cached, and, if the new name is briefly absent, the negative cache lifetime derived from the zone's SOA record. RFC 2308 sets that second term as the smaller of the SOA MINIMUM field and the SOA record's own TTL, and recommends capping it at three hours.

With a default TTL of 3600 seconds on the record being replaced, the write is roughly 0.03% of the bill and cache expiry is the other 99.97%. Every optimisation aimed at the API call is therefore noise.

The word "propagation" is doing real damage in this conversation, because it suggests a push. Nothing propagates. Caches expire, independently, on schedules that started whenever each resolver last asked, which is why a district's IT director sees the new site immediately from their phone and the old one from the staff-room desktop for another fifty minutes. That gap isn't a bug, and there's no API that can shorten it retroactively.

Should the customer's hostname be a CNAME or an A record, and where do TXT, SPF and DMARC actually live?

The choice is not stylistic. Each record type answers a different question asked by a different consumer, and the constraints attached to them are not interchangeable.

Record type The consumer that forces it The constraint that bites
A / AAAA A resolver that needs an address literal Pins the tenant to an IP you must renumber by hand
CNAME A resolver that should follow your edge's own naming Cannot coexist with any other data at the same owner name
MX A sending mail server choosing a destination Needs a preference value; the target must be a hostname, not an alias or an address
TXT Anything reading a policy string — verification, SPF, DMARC, ACME No structure at all; the consumer defines the grammar, and the name is fixed by the spec

CNAME is the correct answer for learn.district.k12.us because it lets us renumber the edge without touching a zone we do not control, and because re-pointing a tenant later costs the district nothing. The catch is that CNAME is exclusive by definition: RFC 1912 §2.4 states plainly that a CNAME is not allowed to coexist with any other data at that owner name. At a delegated subdomain that's usually harmless. At the apex it is a zone-destroying edit, and this is the failure I would design the whole guard rail around — a district administrator, asked to "point your domain at us", reads that literally and adds a CNAME at district.k12.us itself, where the zone already carries SOA, NS, an MX pair, and the SPF TXT record their mail vendor requires. A conforming resolver now treats the CNAME as authoritative for every query at that name, the MX lookup returns the alias instead of a mail exchanger, and inbound mail for the district stops. Not degrades.

Stops. And the people who notice first are parents, not engineers.

SPF and DMARC belong in the same discipline. RFC 7208 §3.1 requires SPF policy to be published as a DNS TXT record — the dedicated type 99 RR was tried and abandoned — and RFC 7489 puts DMARC policy in a TXT record at the underscore-prefixed name _dmarc.district.k12.us. Those underscore names are a registry, not a convention; RFC 8552 exists precisely because _dmarc, _acme-challenge and their siblings kept colliding with ordinary hostnames. If your provisioning code asks the customer for "your DMARC record" and then writes it at the apex, alignment silently fails and the district's mail starts landing in quarantine with no error anywhere in your logs.

The one change that moves the dominant term

Since cache expiry is 99.97% of the bill, the only intervention that matters is lowering the TTL before the cutover rather than during it.

Publish the target record with a TTL of 60 seconds, wait at least one full old-TTL period — 3600 seconds, if that's what was previously advertised — so that every cached copy of the high TTL has expired, and only then swing the value. The blind window collapses from an hour to a minute. The cost is an hour of lead time you must schedule with the customer, plus query volume at the authoritative servers that rises in proportion to the TTL ratio, up to roughly sixty times for that one name while the low-TTL window is open. For a few thousand delegated hostnames that's a rounding error on a DNS bill. For a consumer property it wouldn't be.

That scheduling requirement is the real trade-off on the propagation-versus-cutover axis, and in edtech it has a seasonal shape: a district will not approve a zone edit during the first week of term, so the lead time is not really 3600 seconds, it's however long their change-advisory process takes. Our provisioning API therefore models the TTL reduction and the value swap as two separate, separately-approved intents, with the earliest legal swap time computed rather than guessed.

// StaleWindow is the worst case for how long a resolver may keep answering
// with the pre-cutover value: the TTL that was published when it last asked,
// plus the negative cache lifetime if the new name is momentarily absent.
// RFC 2308 section 5 defines the negative term as min(SOA MINIMUM, SOA TTL).
func StaleWindow(publishedTTL, soaMinimum, soaTTL uint32) time.Duration {
    negative := soaMinimum
    if soaTTL < negative {
        negative = soaTTL
    }
    return time.Duration(publishedTTL+negative) * time.Second
}

// EarliestSwap returns the first instant at which lowering the TTL is known to
// have taken effect everywhere. Callers must not shorten it on a customer's
// request; the arithmetic is the contract.
func EarliestSwap(loweredAt time.Time, previousTTL uint32) time.Time {
    return loweredAt.Add(time.Duration(previousTTL) * time.Second)
}
Enter fullscreen mode Exit fullscreen mode

Enforcing the type at the write path

A record type is a claim about semantics, and claims belong in the type system, not in a free-text field that a support engineer fills in at 23:00. We persist the intent before we call the provider, key the provider call on an idempotency key so a retry after a timeout cannot produce a second write, and reject structurally impossible requests before they ever leave the process.

type RecordType string

const (
    TypeA     RecordType = "A"
    TypeAAAA  RecordType = "AAAA"
    TypeCNAME RecordType = "CNAME"
    TypeMX    RecordType = "MX"
    TypeTXT   RecordType = "TXT"
)

// Intent is what the tenant asked for. It is written to an append-only table
// before the provider call and is never mutated afterwards; the provider's
// answer is recorded as a separate row referencing this key.
type Intent struct {
    IdempotencyKey string
    Owner          string // fully qualified, trailing dot
    Type           RecordType
    Value          string
    Preference     uint16 // MX only
    TTL            uint32
    RequestedBy    string
    RequestedAt    time.Time
}

var errAliasNotType = errors.New("SPF and DMARC are policy strings carried in TXT, not record types")

// Validate rejects the type errors that survive code review but not production.
// siblings lists the types already present at in.Owner.
func (in Intent) Validate(zoneApex string, siblings []RecordType) error {
    switch in.Type {
    case TypeCNAME:
        if in.Owner == zoneApex {
            return fmt.Errorf("CNAME at apex %s would shadow SOA, NS and MX", zoneApex)
        }
        if len(siblings) > 0 {
            return fmt.Errorf("CNAME cannot coexist with %v at %s (RFC 1912 2.4)", siblings, in.Owner)
        }
    case TypeMX:
        if net.ParseIP(strings.TrimSuffix(in.Value, ".")) != nil {
            return fmt.Errorf("MX target %q is an address; RFC 2181 10.3 requires a hostname", in.Value)
        }
    case TypeTXT:
        if strings.HasPrefix(in.Value, "v=DMARC1") && !strings.HasPrefix(in.Owner, "_dmarc.") {
            return fmt.Errorf("DMARC policy must be published at _dmarc.%s (RFC 7489)", zoneApex)
        }
    case "SPF", "DMARC":
        return errAliasNotType
    }
    if in.TTL < 60 {
        return fmt.Errorf("TTL %d is below the floor agreed with the customer", in.TTL)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Verification is a separate concern from writing, and it's where most teams stop too early. Asking your own provider whether the record exists proves only that you called your own API. Ask the authoritative servers without recursion, then ask two unrelated public resolvers, and record all three answers against the intent key.

dig +norecurse @ns1.district-registrar.example learn.district.k12.us CNAME
dig +short @9.9.9.9 learn.district.k12.us CNAME
dig +short @8.8.8.8 _dmarc.district.k12.us TXT
Enter fullscreen mode Exit fullscreen mode

Declarative zone management — octoDNS, DNSControl, the DNS resources in Terraform providers — turns the record set into a reviewable artifact with a diff, which matters more for a team than any individual API's ergonomics. The apex problem is one place where implementations legitimately diverge: a CNAME at the apex is forbidden by the protocol, so Cloudflare's CNAME flattening resolves the alias at the authoritative server and returns the resulting addresses, which is a vendor-specific answer to a constraint the standard itself does not relax. Portable zone files can't express it. That's a real boundary, not a criticism.

What we deliberately stop keeping, and what that costs

Retention is where the audit story either holds or collapses, and it's the part of the design I argue about most.

We keep the intent ledger — who requested which type at which name with which value, the idempotency key, the operator identity, the authoritative confirmation — for the life of the tenant relationship plus the term our education customers' records-retention obligations imply, because FERPA-governed relationships routinely outlive the contract that created them. That table is small, append-only, and boring. It's also the only thing that answers "who changed our MX" with evidence rather than recollection.

What we stop keeping is the resolver-observed data: the multi-resolver probe snapshots age out after 30 days, and we don't retain per-query logs at all.

The cost of that choice is specific, and I'd rather state it than pretend it away. Six weeks after a cutover, when a district reports that a fraction of their staff saw the old site for most of a morning, we can prove what we wrote and when the authoritative servers confirmed it. We cannot prove what any particular resolver returned, because we threw that away. If you need that evidence — regulated messaging, or a contract with a hard availability term attached to a named hostname — stick with longer probe retention and accept the storage bill; 30 days is a judgement call that fits our disputes, not a universal answer. Resolver behaviour in the long tail is genuinely hard to characterise, and your mileage may vary with customers whose networks run their own forwarders.

The decision rule that falls out of all this is short. Choose the record type from the consumer, never from convenience; put the alias at a delegated subdomain rather than the apex unless you're prepared to depend on a provider extension; treat the TTL reduction as a scheduled change with its own approval; and keep the write intent forever even when you stop keeping the observations. Typed correctly, a delegated domain is a boring piece of infrastructure. Typed carelessly, it takes the customer's mail with it.

Further reading

Top comments (0)