Building Multi-Tenant SaaS
on Cloudflare
Tenant isolation, data partitioning, custom domains, rate limiting, and billing integrationโall on Workers and D1.
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
Pattern 1: Tenant Resolution
Every request must identify which tenant it belongs to. We support three methods:
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:
-- 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 = ?
// 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
}
}
Pattern 3: Rate Limiting per Tenant
Different plans get different limits. Durable Objects provide per-tenant rate limiting:
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:
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:
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
Building a Multi-Tenant SaaS?
We architect scalable, secure multi-tenant systems on Cloudflare.
โ Get Architecture Help