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.

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

Multi-Tenant SaaS on Workers

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 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
);
⚠️ Critical Rule: Every WHERE clause should include 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

Monitoring & Observability

Scaling Considerations

Next Steps

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.