A signup transaction should create a tenant's desired DNS state, then let a reconciler publish it and repair any drift. Trying to make DNS publication part of the database commit creates a transaction that cannot be atomic: DNS providers, recursive resolvers, and caches sit outside the database.
TL;DR: store the tenant subdomain and its target as durable intent first; publish an idempotent CNAME after commit; compensate only the intent your transaction created; and continuously compare intent with the authoritative DNS view. This keeps a partially failed signup from silently becoming a permanent routing bug.
For a B2B SaaS, acme.example.test is more than a friendly URL. It is a routing boundary, an authorization boundary, and often a contract embedded in links, SSO callbacks, and email. The operation needs an owner, a state machine, and a way to converge.
How should you provision a tenant subdomain inside the signup transaction?
The database transaction should guarantee one thing: the application has a single, durable decision about the tenant's requested hostname. It cannot guarantee that a remote DNS change has reached every resolver, because DNS answers may be cached for their TTL and negative answers have caching rules of their own.
A useful boundary is small:
| Concern | Transaction owns it | Worker owns it |
|---|---|---|
| Tenant row | Unique tenant and requested label | Never invents a tenant |
| Desired record | Name, type, target, generation, status | Reads and applies it |
| Published record | No claim before verification | Creates, verifies, updates, or removes it |
| Resolver visibility | No promise | Observes only after authoritative verification |
The target must be controlled by the application. Accepting an arbitrary target from a signup form turns a convenience feature into an outbound routing primitive. Keep the hostname under one parent zone and construct the CNAME target from configuration.
DNS labels have rules too. RFC 1034 limits labels to 63 octets and a full domain name to 255 octets; hostnames used in service routing should be normalized and checked against the policy before they are stored. The exact allowed character policy is a product decision, but the canonical stored form cannot be an afterthought.
Short fields matter.
A Node.js flow that survives retries
The following example uses an outbox in the same transaction as the tenant and desired record. The DNS client is deliberately generic: replacing it should not change the signup contract. begin, commit, and SQL details vary by database, but the ordering is the part worth keeping.
import { randomUUID } from "node:crypto";
type DesiredRecord = {
id: string;
tenantId: string;
name: string;
type: "CNAME";
target: string;
generation: number;
status: "pending" | "published" | "deleting";
};
type DnsClient = {
getCname(name: string): Promise<string | null>;
upsertCname(input: { name: string; target: string }): Promise<void>;
deleteCname(input: { name: string; target: string }): Promise<void>;
};
function tenantLabel(input: string): string {
const label = input.trim().toLowerCase();
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) {
throw new Error("invalid tenant subdomain");
}
return label;
}
export async function signUpTenant(
db: { transaction<T>(fn: (tx: any) => Promise<T>): Promise<T> },
requestedLabel: string,
) {
const label = tenantLabel(requestedLabel);
const record: DesiredRecord = {
id: randomUUID(),
tenantId: randomUUID(),
name: `${label}.example.test`,
type: "CNAME",
target: "edge.example.test",
generation: 1,
status: "pending",
};
await db.transaction(async (tx) => {
await tx.tenants.insert({ id: record.tenantId, slug: label });
await tx.desiredDnsRecords.insert(record);
await tx.outbox.insert({
id: randomUUID(),
kind: "dns.reconcile",
payload: { recordId: record.id, generation: record.generation },
});
});
return { tenantId: record.tenantId, hostname: record.name };
}
export async function reconcileRecord(
db: any,
dns: DnsClient,
recordId: string,
generation: number,
) {
const record = await db.desiredDnsRecords.findById(recordId);
if (!record || record.generation !== generation) return;
if (record.status === "deleting") {
await dns.deleteCname({ name: record.name, target: record.target });
await db.desiredDnsRecords.remove(record.id);
return;
}
const publishedTarget = await dns.getCname(record.name);
if (publishedTarget !== record.target) {
await dns.upsertCname({ name: record.name, target: record.target });
}
await db.desiredDnsRecords.update(record.id, { status: "published" });
}
The outbox is the important line in this example. A process can die after the database commit and before it calls DNS; a relay can later deliver the durable dns.reconcile event. Delivery may happen twice, so upsertCname must be safe to repeat. A worker should also re-read the record after receiving the event. An old event must not restore a record that a later transaction changed or deleted, which is why the generation travels with the payload.
Do not mark the record published merely because the provider accepted a request. Read the authoritative record through the provider's control-plane API, or query authoritative name servers, and mark it only after the expected CNAME is present. Recursive resolution is a separate observation with cache timing attached. The extra authoritative lookup is a deliberate trade-off: it costs one more control-plane read, but it avoids treating an uncertain write acknowledgement as a completed routing change.
Where does rollback belong when DNS is outside the transaction?
Rollback belongs in the desired-state model, not in a fragile attempt to undo every remote side effect immediately. If signup fails before commit, there is no desired record and the DNS worker has nothing to do. If the commit succeeds but a later onboarding step fails, run a compensating transaction: mark the desired record deleting, increment its generation, and enqueue another reconcile event.
That sequence has a useful property: a delete request only removes the target owned by this record. A broad delete(name) is dangerous because a name may have been reallocated, manually corrected, or changed by a later generation. The generic client in the example takes both name and target for that reason.
There is a deliberate trade-off here. A brief orphaned CNAME can exist while the delete event retries, but the database retains enough evidence to find and remove it. The alternative is worse: pretending the remote delete is transactional and losing the record when the request times out. DNS APIs can return an uncertain result; a retry must inspect state before deciding what to do.
For a single shared zone, use a unique database constraint on the canonical name. For delegated tenant zones, the desired state expands to NS and validation records, and the same rule holds: model every record set you own, including its expected values and generation.
How do you detect drift before customers do?
Events reduce delay; reconciliation supplies correctness. Run a periodic scan over active desired records and compare each one with the authoritative zone. The scan should classify at least four outcomes: missing, expected, wrong target, and ambiguous record set. Emit the record ID, tenant ID, expected target, observed target, generation, and last successful verification time. Those fields turn an alert into an action.
A CNAME has a structural constraint worth enforcing. RFC 1034 says that if a CNAME is present at a node, no other data should be present there. A reconciling worker should therefore refuse to overwrite an unexpected A, AAAA, TXT, or other conflicting record and surface it for review. Quietly replacing a conflict makes unrelated configuration disappear.
The same care applies to cache-related troubleshooting. A missing answer may linger due to negative caching, whose behavior is defined by RFC 2308; it does not prove that the authoritative zone is still missing the record. Record two timestamps: when authoritative DNS matched, and when a resolver observed the new answer. Those are different service indicators.
In a small team, start with one queue consumer, a retry policy with jitter, and a scan that can finish within its interval. Do not start by fanning one DNS request into a large workflow system. The first operational limit is usually the ability to explain one tenant's state from storage and logs, not raw throughput.
Operational checks before enabling tenant domains
Before exposing the signup flow, test duplicate labels, a worker crash after commit, a timeout after a DNS write, an old reconcile event arriving after deletion, and a conflicting record at the same name. Those five cases exercise the gap between intent and publication. In staging, point the target at a harmless endpoint and verify the authoritative record directly before testing through a recursive resolver.
Keep the DNS credential limited to the hosted zone and record types the worker needs. Audit every change with the desired-record ID and generation. Use a bounded retry queue with a visible terminal state; an endlessly retried bad label is noise, while a record that cannot converge needs a human decision.
The decision rule is plain: the database expresses intent; DNS is a converging projection of that intent. Once the system is built around that rule, signup can return after the durable decision, the UI can show provisioning state honestly, and repair work remains deterministic.
Sources
References:
Top comments (0)