Architecture SaaS

Building Multi-Tenant SaaS
on Cloudflare

Tenant isolation, data partitioning, custom domains, rate limiting, and billing integrationโ€”all on Workers and D1.

๐Ÿ“– 16 min read January 24, 2026

Multi-tenant SaaS is hard. Each customer expects isolation, custom domains, their own rate limits, and usage-based billing. Traditional cloud makes this expensive. Edge computing makes it elegant.

This is how we architect multi-tenant applications on Cloudflareโ€”serving hundreds of tenants from a single codebase with complete data isolation.

The Architecture

Multi-Tenant Domain Routing
๐Ÿข
Acme Corp
acme.app.com
๐Ÿช
Beta LLC
beta.app.com
๐Ÿญ
Gamma Inc
gamma.app.com

Pattern 1: Tenant Resolution

Every request must identify which tenant it belongs to. We support three methods:

tenant-resolver.ts
interface Tenant { id: string; name: string; plan: 'free' | 'pro' | 'enterprise'; customDomain?: string; rateLimit: number; } export async function resolveTenant( request: Request, env: Env ): Promise<Tenant | null> { const url = new URL(request.url); const hostname = url.hostname; // Method 1: Subdomain (acme.app.com) if (hostname.endsWith('.app.com')) { const subdomain = hostname.split('.')[0]; return await getTenantBySubdomain(subdomain, env); } // Method 2: Custom domain (acme.com) const tenantByDomain = await getTenantByCustomDomain(hostname, env); if (tenantByDomain) return tenantByDomain; // Method 3: API key header const apiKey = request.headers.get('X-API-Key'); if (apiKey) { return await getTenantByApiKey(apiKey, env); } return null; } async function getTenantBySubdomain( subdomain: string, env: Env ): Promise<Tenant | null> { // Check cache first const cached = await env.KV.get(`tenant:subdomain:${subdomain}`, 'json'); if (cached) return cached as Tenant; // Query D1 const result = await env.DB.prepare( `SELECT * FROM tenants WHERE subdomain = ?` ).bind(subdomain).first(); if (result) { // Cache for 5 minutes await env.KV.put( `tenant:subdomain:${subdomain}`, JSON.stringify(result), { expirationTtl: 300 } ); } return result as Tenant | null; }

Pattern 2: Data Isolation

Never let tenants see each other's data. We use row-level isolation with tenant_id on every table:

schema.sql
-- Every table has tenant_id CREATE TABLE leads ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, email TEXT NOT NULL, name TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, -- Index for tenant queries FOREIGN KEY (tenant_id) REFERENCES tenants(id) ); -- Composite index for fast tenant lookups CREATE INDEX idx_leads_tenant ON leads(tenant_id, created_at DESC); -- Never query without tenant_id! -- BAD: SELECT * FROM leads WHERE email = ? -- GOOD: SELECT * FROM leads WHERE tenant_id = ? AND email = ?
tenant-scoped-queries.ts
// Wrapper that enforces tenant isolation class TenantDB { constructor( private db: D1Database, private tenantId: string ) {} async query<T>( sql: string, params: any[] = [] ): Promise<T[]> { // Automatically inject tenant_id const scopedSql = sql.includes('WHERE') ? sql.replace('WHERE', 'WHERE tenant_id = ? AND') : sql + ' WHERE tenant_id = ?'; return await this.db .prepare(scopedSql) .bind(this.tenantId, ...params) .all() .then(r => r.results as T[]); } async insert(table: string, data: Record<string, any>) { // Always include tenant_id const withTenant = { ...data, tenant_id: this.tenantId }; // ... insert logic } }
Critical: Never Trust Client Input
Tenant ID comes from server-side resolution, never from request body or query params. A malicious user could easily spoof another tenant's ID if you accept it from the client.

Pattern 3: Rate Limiting per Tenant

Different plans get different limits. Durable Objects provide per-tenant rate limiting:

rate-limiter.ts
export class TenantRateLimiter { private requests: number = 0; private windowStart: number = Date.now(); async checkLimit(tenant: Tenant): Promise<{ allowed: boolean; remaining: number; resetAt: number; }> { const now = Date.now(); const windowMs = 60000; // 1 minute window // Reset window if expired if (now - this.windowStart > windowMs) { this.requests = 0; this.windowStart = now; } // Plan-based limits const limits = { free: 100, pro: 1000, enterprise: 10000 }; const limit = limits[tenant.plan]; const allowed = this.requests < limit; if (allowed) this.requests++; return { allowed, remaining: Math.max(0, limit - this.requests), resetAt: this.windowStart + windowMs }; } }
Plan Rate Limit Storage Custom Domain
Free 100/min 100MB โŒ
Pro 1,000/min 10GB โœ…
Enterprise 10,000/min Unlimited โœ… + SSL

Pattern 4: Custom Domains

Enterprise tenants get their own domain. Cloudflare for SaaS makes this easy:

custom-domains.ts
async function provisionCustomDomain( tenantId: string, domain: string, env: Env ) { // 1. Add to Cloudflare for SaaS const response = await fetch( `https://api.cloudflare.com/client/v4/zones/${env.ZONE_ID}/custom_hostnames`, { method: 'POST', headers: { 'Authorization': `Bearer ${env.CF_API_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ hostname: domain, ssl: { method: 'http', type: 'dv', settings: { min_tls_version: '1.2' } } }) } ); const result = await response.json(); // 2. Store mapping in database await env.DB.prepare( `UPDATE tenants SET custom_domain = ? WHERE id = ?` ).bind(domain, tenantId).run(); // 3. Cache the mapping await env.KV.put(`domain:${domain}`, tenantId); return result; }

Pattern 5: Usage Tracking & Billing

Track usage per tenant for metered billing:

usage-tracking.ts
async function trackUsage( tenant: Tenant, metric: 'api_calls' | 'storage_mb' | 'ai_tokens', amount: number, env: Env ) { const month = new Date().toISOString().slice(0, 7); // 2026-01 const key = `usage:${tenant.id}:${month}:${metric}`; // Increment counter (use Durable Objects for accuracy) const counter = env.COUNTERS.get( env.COUNTERS.idFromName(key) ); await counter.fetch('http://counter/increment', { method: 'POST', body: JSON.stringify({ amount }) }); } // Sync to Stripe monthly async function syncBilling(tenant: Tenant, env: Env) { const month = new Date().toISOString().slice(0, 7); const usage = await getMonthlyUsage(tenant.id, month, env); // Report to Stripe await stripe.subscriptionItems.createUsageRecord( tenant.stripeSubscriptionItemId, { quantity: usage.api_calls, timestamp: 'now', action: 'set' } ); }

Request Flow Summary

  • Step 1: Request arrives โ†’ Resolve tenant from domain/subdomain/API key
  • Step 2: Check rate limit using Durable Object for that tenant
  • Step 3: Create TenantDB wrapper with tenant_id for all queries
  • Step 4: Process request with automatic tenant scoping
  • Step 5: Track usage metrics for billing
  • Step 6: Return response with rate limit headers

Multi-tenancy isn't a featureโ€”it's an architecture. Every layer must enforce isolation: routing, database, caching, rate limiting, and billing. Miss one layer and you have a security incident.

Related Articles

API Gateway Patterns at the Edge
Read more โ†’
KV, D1, R2, Durable Objects: Choosing Storage
Read more โ†’
The Real Cost of Serverless: 12-Month Analysis
Read more โ†’

Building a Multi-Tenant SaaS?

We architect scalable, secure multi-tenant systems on Cloudflare.

โ†’ Get Architecture Help
๐ŸŒ™