Building a multi-tenant CMS on Cloudflare Workers presents unique architectural challenges and opportunities. Unlike traditional server-based solutions, Workers' edge-native environment requires careful consideration of data isolation, authentication flows, and caching strategies to deliver both security and performance at scale.
This guide covers the complete architecture for a production-ready multi-tenant CMS, including implementation patterns that leverage Cloudflare Workers, D1, and the edge cache effectively.
Core Architecture Overview
A multi-tenant CMS on Cloudflare Workers follows a shared-infrastructure, isolated-data model. Each tenant shares the same application code and infrastructure while maintaining complete data separation through database-level isolation and tenant-aware routing.
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Edge Cache │ │ Cloudflare │ │ D1 Database │
│ (per-tenant) │────│ Workers │────│ (row-level │
│ │ │ │ │ isolation) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│
┌──────────────────┐
│ KV Store │
│ (session data) │
└──────────────────┘The architecture leverages three primary isolation mechanisms:
- Database isolation: Row-level security in D1 with tenant_id constraints
- Cache isolation: Tenant-scoped cache keys
- Session isolation: Tenant-aware authentication and authorization
Database Design for Multi-Tenancy
D1's SQLite foundation supports robust multi-tenancy through a shared database with tenant isolation. The key is implementing a consistent tenant_id pattern across all tables.
Schema Design Patterns
Every table must include a tenant_id column with appropriate constraints:
-- Core tenant table
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
domain TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL,
config TEXT -- JSON configuration
);
-- Content tables with tenant isolation
CREATE TABLE content (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
status TEXT DEFAULT 'draft',
created_at INTEGER NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
INDEX idx_content_tenant (tenant_id)
);
-- Users with tenant association
CREATE TABLE users (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'editor',
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
UNIQUE(tenant_id, email)
);Query Patterns for Data Isolation
All queries must include tenant_id in WHERE clauses. Implement this through a database service layer:
class TenantAwareDB {
constructor(db, tenantId) {
this.db = db;
this.tenantId = tenantId;
}
async getContent(id) {
return await this.db
.prepare('SELECT * FROM content WHERE id = ? AND tenant_id = ?')
.bind(id, this.tenantId)
.first();
}
async createContent(data) {
const id = generateId();
await this.db
.prepare('INSERT INTO content (id, tenant_id, title, body) VALUES (?, ?, ?, ?)')
.bind(id, this.tenantId, data.title, data.body)
.run();
return id;
}
}Authentication and Authorization
Multi-tenant authentication requires resolving the tenant context before validating user credentials. This involves domain-based tenant identification and tenant-scoped user lookups.
Tenant Resolution Strategy
Implement tenant resolution through multiple strategies depending on your requirements:
async function resolveTenant(request) {
const url = new URL(request.url);
// Strategy 1: Custom domain
let tenant = await getTenantByDomain(url.hostname);
if (tenant) return tenant;
// Strategy 2: Subdomain
const subdomain = url.hostname.split('.')[0];
tenant = await getTenantBySubdomain(subdomain);
if (tenant) return tenant;
// Strategy 3: Path prefix
const pathSegments = url.pathname.split('/');
if (pathSegments[1]) {
tenant = await getTenantBySlug(pathSegments[1]);
if (tenant) return tenant;
}
throw new Error('Tenant not found');
}JWT-Based Authentication
Use JWT tokens with tenant-scoped claims for stateless authentication:
async function authenticateRequest(request, tenant) {
const token = extractBearerToken(request);
if (!token) return null;
try {
const payload = await verifyJWT(token);
// Verify tenant matches
if (payload.tenant_id !== tenant.id) {
throw new Error('Token tenant mismatch');
}
// Verify user exists in tenant
const user = await getUserByTenant(payload.user_id, tenant.id);
if (!user) throw new Error('User not found');
return { user, tenant };
} catch (error) {
return null;
}
}Role-Based Access Control
Implement RBAC with tenant-scoped permissions:
const PERMISSIONS = {
'admin': ['content:read', 'content:write', 'content:delete', 'users:manage'],
'editor': ['content:read', 'content:write'],
'viewer': ['content:read']
};
function hasPermission(user, permission) {
const userPermissions = PERMISSIONS[user.role] || [];
return userPermissions.includes(permission);
}
async function requirePermission(request, permission) {
const auth = await authenticateRequest(request);
if (!auth || !hasPermission(auth.user, permission)) {
throw new Response('Forbidden', { status: 403 });
}
return auth;
}Per-Tenant Caching Strategy
Effective caching in a multi-tenant environment requires tenant-aware cache keys and invalidation strategies. Cloudflare's edge cache and KV store both support this through key namespacing.
Cache Key Design
Structure cache keys with tenant prefixes to ensure complete isolation:
class TenantCache {
constructor(tenantId) {
this.tenantId = tenantId;
}
generateKey(key) {
return `tenant:${this.tenantId}:${key}`;
}
async get(key) {
const cacheKey = this.generateKey(key);
// Try edge cache first
let cached = await caches.default.match(cacheKey);
if (cached) return cached;
// Fallback to KV
const kvValue = await KV_CACHE.get(cacheKey);
return kvValue ? JSON.parse(kvValue) : null;
}
async set(key, value, ttl = 300) {
const cacheKey = this.generateKey(key);
// Store in both edge cache and KV
const response = new Response(JSON.stringify(value), {
headers: {
'Cache-Control': `public, max-age=${ttl}`,
'Content-Type': 'application/json'
}
});
await caches.default.put(cacheKey, response.clone());
await KV_CACHE.put(cacheKey, JSON.stringify(value), {
expirationTtl: ttl
});
}
}Cache Invalidation Patterns
Implement selective cache invalidation for content updates:
async function invalidateTenantCache(tenantId, patterns = []) {
const cache = new TenantCache(tenantId);
// Invalidate specific patterns
for (const pattern of patterns) {
const keys = await generateCacheKeys(tenantId, pattern);
await Promise.all([
...keys.map(key => caches.default.delete(key)),
...keys.map(key => KV_CACHE.delete(key))
]);
}
// Update cache version to invalidate related cached responses
await cache.set('version', Date.now(), 86400);
}Request Routing and Middleware
Implement a middleware pattern that handles tenant resolution, authentication, and request routing:
async function handleRequest(request, env) {
try {
// Resolve tenant first
const tenant = await resolveTenant(request);
const tenantDb = new TenantAwareDB(env.DB, tenant.id);
const tenantCache = new TenantCache(tenant.id);
// Create request context
const context = {
tenant,
db: tenantDb,
cache: tenantCache,
request
};
// Route to appropriate handler
const url = new URL(request.url);
const path = url.pathname;
if (path.startsWith('/api/')) {
return await handleApiRequest(context);
} else if (path.startsWith('/admin')) {
return await handleAdminRequest(context);
} else {
return await handlePublicRequest(context);
}
} catch (error) {
return new Response(error.message, { status: 400 });
}
}Performance Optimizations
Connection Pooling and Query Optimization
D1 connections should be reused within request contexts and queries optimized for tenant-aware indexing:
// Optimize queries with proper indexing
CREATE INDEX idx_content_tenant_status ON content(tenant_id, status);
CREATE INDEX idx_content_tenant_created ON content(tenant_id, created_at DESC);
// Use prepared statements for common queries
class QueryCache {
constructor(db) {
this.statements = new Map();
this.db = db;
}
prepare(sql) {
if (!this.statements.has(sql)) {
this.statements.set(sql, this.db.prepare(sql));
}
return this.statements.get(sql);
}
}Response Caching Strategy
Cache tenant-specific responses with appropriate headers:
function createCachedResponse(data, tenantId, ttl = 300) {
return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': `public, max-age=${ttl}`,
'Vary': 'Host, Authorization',
'X-Tenant-ID': tenantId
}
});
}Security Considerations
Multi-tenant architectures require additional security measures beyond single-tenant applications:
- Data leakage prevention: Always validate tenant_id in queries
- Cross-tenant attacks: Implement strict input validation and parameterized queries
- Tenant enumeration: Avoid exposing tenant information in error messages
- Resource isolation: Implement per-tenant rate limiting
// Rate limiting per tenant
async function rateLimitCheck(tenantId, action) {
const key = `ratelimit:${tenantId}:${action}`;
const current = await KV_STORE.get(key) || 0;
if (current >= RATE_LIMITS[action]) {
throw new Response('Rate limit exceeded', { status: 429 });
}
await KV_STORE.put(key, current + 1, { expirationTtl: 3600 });
}Deployment and Scaling Patterns
Deploy the multi-tenant CMS using Cloudflare Workers' global distribution with environment-specific configurations:
// wrangler.toml configuration
[env.production]
vars = { ENVIRONMENT = "production" }
[env.staging]
vars = { ENVIRONMENT = "staging" }
[[env.production.d1_databases]]
binding = "DB"
database_name = "cms-production"
database_id = "your-production-db-id"
[[env.production.kv_namespaces]]
binding = "KV_CACHE"
id = "your-production-kv-id"This architecture scales automatically with Cloudflare's edge network while maintaining data consistency and security across all tenant operations. The combination of D1's SQLite foundation, Workers' edge execution, and proper caching strategies delivers both performance and isolation required for production SaaS applications.