Multi-Tenant SaaS Architecture on Cloudflare Workers: Complete Guide
Quick Answer: Yes, Cloudflare Workers excels at multi-tenant SaaS. Use Durable Objects for per-tenant state, D1 for shared database with row-level isolation, and request routing by tenant identifier. Cost: ~$0.50-$5/month per 100K requests, vs $50+ on Lambda.
πβ In This Guide
Why Cloudflare Workers for Multi-Tenant SaaS?
Cloudflare Workers provides ideal characteristics for multi-tenant applications:
| Aspect | Workers Advantage | Lambda Alternative |
|---|---|---|
| Cold Starts | 0ms (instant) | 100-2000ms |
| Scaling | Automatic, instant (all regions) | Delayed (10-30s) |
| Cost | $0.50 per 1M requests | $0.20 per 1M (+ 4x on data transfer) |
| Latency | 10-50ms global | 50-200ms regional |
| State Management | Durable Objects (per-tenant) | ElastiCache/DynamoDB (shared) |
Tenant Isolation Strategies
Strategy 1: Row-Level Isolation (Recommended for SaaS)
All tenants use the same database and tables. Data is isolated by a tenant_id column.
β When to Use
- Multiple tenants with similar schemas
- Easy multi-tenancy within application
- Lower infrastructure costs
- Easier cross-tenant analytics (admin dashboard)
β Γ―ΒΈΒ Trade-offs
- Requires careful query filtering
- Risk of data leakage if filters missed
- All tenants affected by schema changes
Strategy 2: Schema-Level Isolation
Each tenant gets their own database schema or separate database instance.
β When to Use
- Enterprise SaaS (high security requirements)
- Different schemas per tenant
- Regulatory isolation (HIPAA, SOC2)
- Tenant-specific customizations
β Γ―ΒΈΒ Trade-offs
- Higher infrastructure costs
- More complex deployments
- Database connection overhead
Recommended Architecture
βββββββββββββββββββββββββββββββββββββββββββ
β Cloudflare Edge (Global) β
β βββββββββββββββββββββββββββββββββββββ β
β β Worker (Tenant Router) β β
β β β’ auth.saas.example.com β β
β β β’ api.saas.example.com β β β β β’ tenant-name.saas.example.com β β
β βββββββββββββββββββββββββββββββββββββ β
βββββββββββ¬βββββββββββββββββββββββββββββββ
β Extract tenant_id
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β Durable Objects (Per-Tenant State) β
β β’ Sessions β
β β’ Rate limiting β
β β’ Cache β
βββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β D1 Database (Shared, Row-Level Isolation)β
β β’ users (tenant_id, name, email) β
β β’ data (tenant_id, content) β
β β’ All tables indexed on tenant_id β
βββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β External Services (Per-Tenant) β
β β’ Analytics Engine β
β β’ KV Store (user preferences) β
β β’ R2 (tenant files) β
βββββββββββββββββββββββββββββββββββββββββββ
Implementation Steps
Step 1: Extract Tenant ID from Request
Identify tenant from subdomain, header, or path:
// From subdomain
const tenant = new URL(request.url).hostname
.split('.')[0]; // tenant.example.com β "tenant"
// From header
const tenant = request.headers.get('X-Tenant-ID');
// From path
const tenant = new URL(request.url)
.pathname.split('/')[1]; // /tenant123/data β "tenant123"
Step 2: Use Durable Object for Per-Tenant State
Store tenant-specific data close to the edge:
// Get per-tenant Durable Object
const durableObject = env.TENANT_STATE.get(
env.TENANT_STATE.idFromName(tenant)
);
// Store session data
await durableObject.fetch(new Request('http://tenant/session', {
method: 'POST',
body: JSON.stringify(sessionData)
}));
Step 3: Query Database with Tenant Filtering
Always filter by tenant_id:
// Fetch user data for tenant
const user = await db
.select('*')
.from('users')
.where('tenant_id', '=', tenant)
.where('id', '=', userId)
.single();
Step 4: Implement Rate Limiting Per Tenant
Use Durable Objects to track tenant usage:
// Check rate limit for tenant
const rateLimitKey = `${tenant}:${Math.floor(Date.now() / 60000)}`;
const count = await durableObject.incrementCounter(rateLimitKey);
if (count > REQUESTS_PER_MINUTE) {
return new Response('Rate limited', { status: 429 });
}
Complete Implementation Example
Here's a production-ready multi-tenant SaaS API:
import { Clodo } from '@clodo/framework';
interface Env {
DB: D1Database;
TENANT_STATE: DurableObjectNamespace;
}
const app = new Clodo();
// Middleware: Extract and validate tenant
app.use(async (req, next) => {
// Extract tenant from subdomain
const url = new URL(req.url);
const tenant = url.hostname.split('.')[0];
if (!tenant || tenant === 'www') {
return new Response('Invalid tenant', { status: 400 });
}
// Attach to request context
req.tenant = tenant;
req.durableObject = req.env.TENANT_STATE.get(
req.env.TENANT_STATE.idFromName(tenant)
);
return next();
});
// GET /api/users - List tenant's users
app.get('/api/users', async (req) => {
const users = await req.db
.select('id', 'name', 'email', 'created_at')
.from('users')
.where('tenant_id', '=', req.tenant)
.all();
return { users };
});
// POST /api/users - Create user
app.post('/api/users', async (req) => {
const { name, email } = await req.json();
// Validate
if (!name || !email) {
return { error: 'Name and email required' }, { status: 400 };
}
// Create user with tenant isolation
const user = await req.db
.insert('users')
.values({
tenant_id: req.tenant,
name,
email,
created_at: new Date()
})
.returning('*');
return { user }, { status: 201 };
});
// GET /api/users/:id - Get specific user
app.get('/api/users/:id', async (req) => {
const { id } = req.params;
const user = await req.db
.select('*')
.from('users')
.where('tenant_id', '=', req.tenant)
.where('id', '=', id)
.single();
if (!user) {
return { error: 'User not found' }, { status: 404 };
}
return { user };
});
// GET /api/stats - Per-tenant analytics
app.get('/api/stats', async (req) => {
const stats = await req.db
.query.raw(`
SELECT
COUNT(*) as total_users,
COUNT(CASE WHEN created_at > datetime('now', '-30 days')
THEN 1 END) as new_users_30d
FROM users
WHERE tenant_id = ?
`, [req.tenant]);
return { stats: stats[0] };
});
export default app;
Request Routing Methods
| Method | Example | Pros | Cons |
|---|---|---|---|
| Subdomain | acme.saas.com |
β Native, simple | β Wildcard DNS |
| Custom Domain | acme.com |
β Professional, branded | β DNS per tenant |
| URL Path | /acme/api/users |
β Simple DNS | β URL uglier |
| Header | X-Tenant-ID: acme |
β API-friendly | β Less intuitive |
Database Schema Design
Always include tenant_id on every table:
-- Users table with tenant isolation
CREATE TABLE users (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
email TEXT NOT NULL,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tenant_id, email),
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);
-- Create index for fast tenant queries
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
-- Data table with tenant isolation
CREATE TABLE data (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
user_id TEXT NOT NULL,
content TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(tenant_id) REFERENCES tenants(id),
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE INDEX idx_data_tenant_id ON data(tenant_id);
-- Tenants table (system-level)
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
subscription_tier TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
AND tenant_id = ?. Missing this is a security vulnerability.
Cost Comparison: Workers vs Lambda
| Scale | Cloudflare Workers | AWS Lambda | Monthly Savings |
|---|---|---|---|
| 100K requests/month | $0.50 | $8.50 | $8/month |
| 1M requests/month | $5 | $25 | $20/month |
| 10M requests/month | $50 | $150 | $100/month |
| 100M requests/month | $500 | $1,500 | $1,000/month |
Plus benefits: 0ms cold starts, global distribution, Durable Objects for free per-tenant state.
Production Considerations
Security
- β Always filter by tenant_id in queries
- β Validate tenant ownership before updates/deletes
- β Use database foreign keys to enforce isolation
- β Audit all per-tenant data access
- β Never trust client-provided tenant_id (always use authenticated identity)
Monitoring & Observability
- Track per-tenant request counts (alert on abuse)
- Monitor per-tenant query latency
- Log data access for audit trails
- Set up cost alerts per tenant
Scaling Considerations
- At 100K tenants: Consider schema-level isolation
- At 1B+ requests/day: Shard Durable Objects by tenant
- Implement data archival after 1-2 years
- Plan for tenant-specific customizations early
Next Steps
- Start building: Get started with Clodo
- Compare frameworks: Clodo vs Hono vs Worktop
- Check costs: See Lambda cost savings
- See examples: Production examples
- Learn more: Full documentation
Building multi-tenant SaaS? This guide covers the fundamentals. For enterprise requirements (HIPAA, SOC2), schema-level isolation is recommended. Reach out to the Clodo team for architecture reviews.