DEV Community

ThomasMoore157
ThomasMoore157

Posted on

How to Provision Tenant Subdomains Inside Signup Transactions with CNAME Recovery

Short answer: keep the database transaction small, model DNS as an idempotent saga step, and delete a CNAME only when its ownership token still matches the signup attempt.

The page that fires is usually a mail gateway error, not a DNS error. A new fintech tenant is marked active, but acme.mail.example has no CNAME, so messages fail after the customer has already left the signup screen. The alert is late evidence. The earlier signal is a missing control-plane acknowledgment before activation.

Should I provision a tenant subdomain inside the signup transaction?

No external DNS API can participate in your SQL commit. Treat signup as a state machine: reserved, dns_written, verified, and active; an uncertain timeout becomes unknown, not an optimistic success. The transaction reserves the tenant and stores an attempt token. A worker performs the DNS write, verifies the authoritative object, and then activates the tenant.

Here is the narrow adapter I use. The token makes compensation conditional, so a retry cannot erase a newer record.

type DNS interface {
    UpsertCNAME(ctx context.Context, name, target, token string) error
    GetCNAME(ctx context.Context, name string) (target, token string, err error)
    DeleteIfToken(ctx context.Context, name, token string) error
}

func Provision(ctx context.Context, repo Repo, dns DNS, tenant Tenant) error {
    token := tenant.ID + ":" + tenant.ProvisionAttempt
    if err := repo.MarkReserved(ctx, tenant.ID, token); err != nil {
        return err
    }
    name := tenant.Slug + ".mail.example"
    target := "tenant-edge.example.net"
    if err := dns.UpsertCNAME(ctx, name, target, token); err != nil {
        _ = repo.MarkFailed(ctx, tenant.ID, "dns_write")
        return err
    }
    got, gotToken, err := dns.GetCNAME(ctx, name)
    if err != nil || got != target || gotToken != token {
        _ = dns.DeleteIfToken(ctx, name, token)
        _ = repo.MarkFailed(ctx, tenant.ID, "dns_verify")
        return fmt.Errorf("dns verification failed")
    }
    if err := repo.MarkActive(ctx, tenant.ID, name); err != nil {
        _ = dns.DeleteIfToken(ctx, name, token)
        return err
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The read above verifies the control-plane object you wrote; it does not prove that every recursive resolver has refreshed. Public resolution and mail delivery belong in an asynchronous probe. Making propagation part of signup creates a user-visible timeout with no stronger correctness guarantee.

Where does ownership change the rollback decision?

In a customer-owned zone, your service cannot safely write a record it does not control. Store the desired name and wait for a customer-created CNAME, with a verification job and an expiration path. In a platform-owned suffix, you can write directly, but a bad suffix calculation or role permission can affect every tenant.

Decision Customer-owned zone Platform-owned zone
Authority Customer or delegated operator Platform DNS role
Signup result Pending until verification Saga can verify and activate
Dominant risk Stale or missing customer change Cross-tenant mutation
On-call burden Support handoff and reminders Central incident response

For mail, a CNAME is routing, not authentication. SPF, DKIM, and DMARC remain separate policy and evidence; RFC 7489 defines DMARC reporting and policy semantics.

Record attempt state, write latency, verification outcome, rollback outcome, and a hash of the token. Redact tenant identifiers. Page on a sustained rate of unknown or dns_verify states, not on one slow lookup; the latter is often resolver or control-plane variance.

The threshold has a real cost. Page too eagerly and the team chases propagation noise. Wait too long and mail is accepted by the application while the tenant is still broken. I size workers from the signup SLO and documented request limits, then leave headroom for retries and a dead-letter queue. Unbounded goroutines are not capacity planning.

The buy-versus-build boundary is operational. Amazon Route 53, Cloudflare DNS, and PowerDNS expose different authority, automation, and hosting models; none removes the need for an idempotency token or a reconciliation loop. A managed API reduces authoritative-service maintenance but adds provider rate limits and an outage dependency. Self-hosting keeps the adapter under your change control while making DNS health, signing, and incident response your responsibility. That is the central trade-off, and it is a real limitation: a hosted API is not suitable when regulatory controls require custody of authoritative logs, while self-hosting is not suitable for a small team that cannot staff DNS incidents. Choose the boundary your team can page for at 03:00, and keep the interface above stable so the decision is reversible.

For capacity, start with a five-minute signup SLO, the provider's published request limit, and at least one retry slot per in-flight tenant. Those are planning inputs, not a promise that propagation completes in five minutes.

Signup is complete only after verification and activation succeed. Every other outcome needs evidence, a bounded retry policy, and a compensation action that cannot delete someone else's record.

Further reading

Top comments (0)