DEV Community

KenjiTanaka6849
KenjiTanaka6849

Posted on

How to Provision Tenant Subdomains — Transactional CNAME Upserts and Rollback

Short answer: commit the tenant and a DNS provisioning job in one signup transaction, then let an idempotent worker upsert the CNAME after commit and record evidence of the result. If signup must be reversed, enqueue a conditional delete instead of pretending that a database rollback can undo an external DNS call.

That split is the whole design. Before: signup opens a database transaction, calls DNS, waits, and hopes two systems commit together. After: signup records desired state; a worker reconciles actual state; cleanup checks ownership before deleting anything. The first model has a hidden partial-failure window. The second makes that window visible and recoverable.

Fast signup matters. Evidence matters more.

Why isn't the DNS call part of the database transaction?

A database transaction can atomically commit rows managed by that database. A DNS control plane is a separate system, so holding the transaction open while calling it doesn't create cross-system atomicity. It creates two awkward outcomes: the CNAME can be written while the tenant row rolls back, or the tenant can commit while the DNS request never completes. A retry can then collide with state left by the first attempt.

Put the intent in an outbox row instead. The tenant row and the outbox row commit together. A worker reads the outbox and moves the subdomain toward the requested state. The useful mental diagram is: signup request -> database transaction -> tenant + DNS intent, followed by worker -> DNS adapter -> evidence row. There is no magic distributed commit — just a durable handoff with explicit states.

Use a unique normalized hostname as the database key. Also attach a generation number to each intent. Generation 7 supersedes generation 6, which gives the worker a clean answer when delayed work arrives out of order. Don't infer ownership from the hostname alone; persist the tenant ID and the exact target that the application intended.

How should a Node.js signup transaction provision a tenant subdomain and rollback CNAME state?

Start with small ports around the database and DNS control plane. The adapter can talk to any authoritative DNS implementation; the transaction logic shouldn't know which one. A team may have a Node.js signup service, but this Go example deliberately keeps the contract language-neutral: the important boundary is the transaction callback, not an SDK.

package provisioning

import (
    "context"
    "fmt"
    "regexp"
    "strings"
)

type DNSState string

const (
    Pending     DNSState = "pending"
    Provisioned DNSState = "provisioned"
    Deleting    DNSState = "deleting"
)

type TenantDNS struct {
    TenantID  string
    Hostname  string
    Target    string
    Generation int
    State     DNSState
}

type DNSJob struct {
    ID, TenantID, Hostname, Target string
    Generation                     int
    Action                         string
}

type Tx interface {
    InsertTenant(context.Context, string, string) error
    InsertDNSState(context.Context, TenantDNS) error
    InsertDNSJob(context.Context, DNSJob) error
}

type Database interface {
    Transaction(context.Context, func(Tx) error) error
}

var validSlug = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)

func SignUpTenant(ctx context.Context, db Database, tenantID, requestedSlug, jobID string) (TenantDNS, error) {
    slug := strings.ToLower(strings.TrimSpace(requestedSlug))
    if !validSlug.MatchString(slug) {
        return TenantDNS{}, fmt.Errorf("invalid_tenant_slug")
    }

    desired := TenantDNS{
        TenantID: tenantID, Hostname: slug + ".tenants.example",
        Target: "edge.tenants.example", Generation: 1, State: Pending,
    }
    err := db.Transaction(ctx, func(tx Tx) error {
        if err := tx.InsertTenant(ctx, tenantID, slug); err != nil {
            return err
        }
        if err := tx.InsertDNSState(ctx, desired); err != nil {
            return err
        }
        return tx.InsertDNSJob(ctx, DNSJob{
            ID: jobID, TenantID: tenantID, Hostname: desired.Hostname,
            Target: desired.Target, Generation: desired.Generation, Action: "upsert-cname",
        })
    })
    return desired, err
}
Enter fullscreen mode Exit fullscreen mode

The schema must enforce uniqueness for the tenant ID and normalized hostname. Treat a uniqueness conflict as a normal signup result, not as permission to overwrite another tenant's record. Return a stable application error such as subdomain_taken; do not leak database-specific messages to the caller.

This is the boundary that counts.

Make the worker idempotent and observable

An upsert job may be delivered more than once. I've been paged by duplicate deliveries, so I assume redelivery before I look at throughput or elegance. The DNS adapter therefore exposes an upsert operation, while the database completion update is conditional on the same tenant, hostname, target, and generation. A stale job may repeat the requested upsert, but it must not mark a newer desired state as complete.

type DNSControlPlane interface {
    UpsertCNAME(context.Context, string, string) error
    DeleteCNAMEIfTargetMatches(context.Context, string, string) (string, error)
}

type Repository interface {
    Job(context.Context, string) (DNSJob, bool, error)
    Desired(context.Context, string) (TenantDNS, bool, error)
    MarkProvisioned(context.Context, TenantDNS) (bool, error)
    MarkJobDone(context.Context, string) error
}

func RunUpsert(ctx context.Context, repo Repository, dns DNSControlPlane, jobID string) error {
    job, found, err := repo.Job(ctx, jobID)
    if err != nil || !found || job.Action != "upsert-cname" {
        return err
    }
    desired, found, err := repo.Desired(ctx, job.TenantID)
    if err != nil {
        return err
    }
    if !found || desired.State != Pending || desired.Hostname != job.Hostname ||
        desired.Target != job.Target || desired.Generation != job.Generation {
        return repo.MarkJobDone(ctx, job.ID)
    }
    if err := dns.UpsertCNAME(ctx, job.Hostname, job.Target); err != nil {
        return err
    }
    accepted, err := repo.MarkProvisioned(ctx, desired)
    if err != nil {
        return err
    }
    if !accepted {
        return repo.MarkJobDone(ctx, job.ID)
    }
    return repo.MarkJobDone(ctx, job.ID)
}
Enter fullscreen mode Exit fullscreen mode

The evidence record is more useful than a bare success: true. Keep the requested hostname, expected target, generation, attempt timestamp, duration, and outcome. Then publish operational metrics from those records: pending-job age, attempts by outcome, and time from signup commit to provisioned state. Alert on age, not merely queue length, because ten fresh jobs and ten jobs stuck for an hour are very different situations.

There are two layers of evidence. A successful control-plane upsert proves that the desired change was accepted. A later lookup through the resolver path your tenants actually use proves that the name is observable there. Keep those timestamps distinct. I'm not sure one fixed verification interval fits every DNS environment; your retry schedule should be chosen from observed propagation behavior and the freshness requirements of the product, then documented as an explicit readiness policy.

For developer tools, make pending visible to the application rather than serving the tenant hostname prematurely. The UI can poll your own tenant status endpoint, while logs carry tenantId, hostname, generation, and jobId as structured fields. One identifier per hop. Debugging gets much faster.

Roll back with a compensating action, not a blind delete

Suppose the product cancels signup after the CNAME upsert has completed. The rollback transaction should change the desired state to deleting and enqueue a delete job with the target and generation it owns. The worker then deletes only if the current CNAME still points to that expected target. If another workflow has changed the target, the correct result is target-mismatch, not deletion.

func RunDelete(ctx context.Context, repo Repository, dns DNSControlPlane, jobID string) error {
    job, found, err := repo.Job(ctx, jobID)
    if err != nil || !found || job.Action != "delete-cname" {
        return err
    }
    desired, found, err := repo.Desired(ctx, job.TenantID)
    if err != nil {
        return err
    }
    if !found || desired.State != Deleting || desired.Hostname != job.Hostname ||
        desired.Target != job.Target || desired.Generation != job.Generation {
        return repo.MarkJobDone(ctx, job.ID)
    }
    outcome, err := dns.DeleteCNAMEIfTargetMatches(ctx, job.Hostname, job.Target)
    if err != nil {
        return err
    }
    if outcome != "deleted" && outcome != "already-absent" && outcome != "target-mismatch" {
        return fmt.Errorf("unexpected_delete_outcome: %s", outcome)
    }
    return repo.MarkJobDone(ctx, job.ID)
}
Enter fullscreen mode Exit fullscreen mode

The short version: upsert is repeatable; delete is guarded.

The main limitation is operational complexity. This approach introduces an outbox dispatcher, a worker, intermediate states, retry policy, and reconciliation. The trade-off is not suitable when signup must return only after the hostname is externally observable; in that contract, keep the asynchronous machinery but expose a separate readiness wait with a firm timeout. For a tiny internal tool where delayed provisioning is harmless and operators can repair the occasional mismatch, choose a simpler post-commit job. Your mileage may vary, but calling DNS inside a long database transaction still doesn't make the two systems atomic.

What should the test and deliverability checklist prove?

Test the transitions, not just the happy-path function:

  • Run the same upsert job twice and assert one final desired state.
  • Deliver generation 6 after generation 7 and assert that it cannot mark 7 complete.
  • Cancel after provisioning and assert that deletion requires the recorded target.
  • Replace the target before cleanup and assert target-mismatch.
  • Simulate a process exit after the DNS upsert but before markProvisioned, then verify that redelivery converges.

Mail adds a separate concern. If tenant subdomains participate in sending email, DNS readiness alone is not deliverability evidence. DMARC evaluates identifier alignment and published policy, and RFC 7489 defines an sp tag for policy requested for subdomains. Record which mail identity is used, test the applicable policy, and keep aggregate reporting in the operational review. A working CNAME does not prove that mail sent with a tenant subdomain will align.

The release decision is crisp: a tenant becomes ready only when the stored desired generation matches the completed control-plane operation and the required observation check. Rollback is complete only when the guarded cleanup outcome is recorded. Everything else remains pending and visible.

References

Top comments (0)