Multi-Domain Architecture
Tenant resolution happens at the edge. A single Cloudflare Worker maps incoming domains to tenants without routing configuration per tenant.
How Requests Become Tenants
Every request goes through three resolution steps in order. The first match wins.
Request arrives at Worker
├── 1. Is the domain in PLATFORM_DOMAINS? → T0 (platform admin)
├── 2. Is the domain in domain_mappings table? → T1 (partner)
└── 3. Is DEFAULT_TENANT_ID set? → Fallback (dev mode)
└── None matched → 401The Resolution Middleware
async function resolveTenant(
request: Request, db: PoolClient, env: Env
): Promise<TenantContext | null> {
const host = request.headers.get('Host') ?? '';
const domain = host.split(':')[0].toLowerCase();
// 1. Platform domains (T0)
const platformDomains = (env.PLATFORM_DOMAINS ?? '')
.split(',').map(d => d.trim().toLowerCase());
if (platformDomains.includes(domain)) {
const result = await db.query<TenantContext>(
`SELECT id AS "tenantId", tier, name, slug, status,
isolation_mode AS "isolationMode"
FROM platform.tenants WHERE tier = 0 LIMIT 1`
);
if (result.rows[0])
return { ...result.rows[0], resolvedDomain: domain };
}
// 2. Domain mappings (verified + active only)
const domainResult = await db.query(
`SELECT t.id AS "tenantId", t.tier, t.name, t.slug, t.status,
t.isolation_mode AS "isolationMode"
FROM platform.domain_mappings dm
JOIN platform.tenants t ON t.id = dm.tenant_id
WHERE dm.domain = $1
AND dm.verified_at IS NOT NULL
AND t.status = 'active'
LIMIT 1`,
[domain]
);
if (domainResult.rows[0])
return { ...domainResult.rows[0], resolvedDomain: domain } as TenantContext;
// 3. Fallback to DEFAULT_TENANT_ID (single-tenant / dev)
if (env.DEFAULT_TENANT_ID) { /* ... */ }
return null;
}After resolution, the Worker sets the RLS context so all subsequent queries are scoped to that tenant:
SELECT platform.set_tenant_context($1);Every RLS policy reads from this context. Application code never needs to add WHERE tenant_id = ? manually.
Domain Types
| Type | Example | Resolution | Tier |
|---|---|---|---|
| Platform | app.syncupsuite.com | PLATFORM_DOMAINS env var | T0 |
| Partner custom | brand.example.com | domain_mappings table lookup | T1 |
| Partner subdomain | acme.brandsyncup.com | Subdomain parse + domain_mappings | T1 |
| Customer | Inherits partner domain | Scoped by partner context | T2 |
T2 customers do not get their own domains. They access the platform through their partner's domain and are scoped by the partner's tenant context plus their own customer ID.
The domain_mappings Schema
export const domainMappings = platformSchema.table(
"domain_mappings",
{
id: uuid("id").defaultRandom().primaryKey(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
domain: varchar("domain", { length: 255 }).notNull(),
isPrimary: boolean("is_primary").notNull().default(false),
verifiedAt: timestamp("verified_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull().defaultNow(),
},
(table) => [
uniqueIndex("domain_mappings_domain_unique").on(table.domain),
index("domain_mappings_tenant_idx").on(table.tenantId),
]
);Key details:
verifiedAtis a timestamp, not a boolean. The resolution middleware checksdm.verified_at IS NOT NULL. Using a timestamp instead of a boolean gives you an audit trail -- you know when each domain was verified, not just that it was.isPrimarymarks one domain per tenant as canonical. Useful for redirect logic and SEO.uniqueIndexondomainprevents two tenants from claiming the same domain.- Cascade delete means removing a tenant removes its domain mappings.
The platform Schema Namespace
All multi-tenant infrastructure tables live in the platform schema, separate from application tables:
CREATE SCHEMA IF NOT EXISTS platform;This keeps tenant resolution, domain mappings, and governance tables isolated from product-specific schemas. The platform schema is owned by T0 -- no T1 or T2 can modify it directly.
Tables in the platform schema: tenants, domain_mappings, tenant_config, token_overrides.
Domain Verification
Adding a custom domain is a two-step process:
- Insert the mapping with
verifiedAt = null - Verify ownership (DNS TXT record or HTTP challenge), then set
verifiedAt = NOW()
Unverified domains are ignored by the resolution middleware. This prevents a tenant from claiming a domain they do not own.
Single-Tenant Dev Mode
Set DEFAULT_TENANT_ID in your environment to skip domain resolution entirely:
# In .dev.vars or Doppler
DEFAULT_TENANT_ID=your-uuid-hereThe Worker falls through steps 1 and 2, hits the fallback, and loads the specified tenant. This is how local development works -- you do not need custom domain setup on localhost.
Adding a New Tenant Domain
No deployment. No infrastructure change. One database insert and one DNS record:
INSERT INTO platform.domain_mappings (tenant_id, domain, is_primary)
VALUES ('tenant-uuid', 'brand.example.com', true);Then point the domain's DNS to your Cloudflare Worker. Once verified, the next request from that domain resolves to the tenant.