Skip to content

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                            → 401

The Resolution Middleware

typescript
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:

sql
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

TypeExampleResolutionTier
Platformapp.syncupsuite.comPLATFORM_DOMAINS env varT0
Partner custombrand.example.comdomain_mappings table lookupT1
Partner subdomainacme.brandsyncup.comSubdomain parse + domain_mappingsT1
CustomerInherits partner domainScoped by partner contextT2

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

typescript
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:

  • verifiedAt is a timestamp, not a boolean. The resolution middleware checks dm.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.
  • isPrimary marks one domain per tenant as canonical. Useful for redirect logic and SEO.
  • uniqueIndex on domain prevents 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:

sql
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:

  1. Insert the mapping with verifiedAt = null
  2. 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:

bash
# In .dev.vars or Doppler
DEFAULT_TENANT_ID=your-uuid-here

The 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:

sql
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.

Next Steps

Released under the MIT License.