Every SaaS I have helped build started with the same question: how do we keep customers' data apart without running a separate stack for each of them? The answer depends on how many tenants you expect, how sensitive the data is and how much ops you can afford. Here is the architecture I use by default, the alternatives, and the mistakes that only show up once you have paying customers.
The three isolation models
| Shared schema | Schema per tenant | Database per tenant | |
|---|---|---|---|
| How | Every table has tenant_id; one DB | One Postgres schema per tenant; one DB | One DB (or cluster) per tenant |
| Isolation | Logical (code + RLS) | Logical, stronger | Physical |
| Tenants it scales to | Millions | Thousands | Hundreds |
| Migrations | Run once | Run N times | Run N times, N connections |
| Per-tenant backup/restore | Hard (filtered export) | Medium (pg_dump -n) | Trivial |
| Cross-tenant analytics | Trivial | UNION across schemas | ETL required |
| Cost per tenant | ~0 | Low | Real (compute + ops) |
| Best for | Most B2B and B2C SaaS | Mid-market with compliance asks | Enterprise, regulated data, "bring your own DB" |
Start with shared schema unless a customer contract says otherwise. You can offer database-per-tenant to the two enterprise accounts that demand it later, using the same code, because tenant resolution (below) abstracts where the data lives.
Tenant resolution: once, in middleware
// middleware/tenant.ts
import { AsyncLocalStorage } from 'node:async_hooks';
export interface TenantCtx { id: string; slug: string; plan: 'free' | 'pro' | 'enterprise'; limits: Limits; }
export const tenantStore = new AsyncLocalStorage<TenantCtx>();
export const currentTenant = () => { const t = tenantStore.getStore(); if (!t) throw new Error('no tenant in context'); return t; };
export async function resolveTenant(req: Request, res: Response, next: NextFunction) {
const host = req.hostname; // acme.app.com
const sub = host.endsWith('.app.com') ? host.slice(0, -'.app.com'.length) : null;
const apiKey = req.get('x-api-key');
const tenant = sub ? await tenants.bySlug(sub) : apiKey ? await tenants.byApiKeyHash(sha256(apiKey)) : null;
if (!tenant || tenant.status !== 'active') return res.status(404).json({ title: 'Unknown tenant' });
tenantStore.run(tenant, () => next()); // available anywhere below without threading it through
}
app.use(resolveTenant);
AsyncLocalStorage makes the tenant available in the repository layer, the cache layer and the logger without passing it through every function signature. Cache the lookup in Redis; it runs on every request.
Row-level security: the safety net
In a shared schema, every query must filter by tenant. Every developer will eventually forget one. Postgres row-level security enforces it at the database, so the forgotten WHERE returns zero rows instead of everyone's rows.
-- Every tenant-scoped table
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX projects_tenant_idx ON projects (tenant_id, created_at DESC); -- tenant_id FIRST in every index
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY; -- applies even to the table owner
-- The app connects as a role that is NOT the owner and NOT superuser
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
// lib/db.ts -- set the tenant on the connection for the duration of a transaction
export async function withTenantTx<T>(fn: (tx: PoolClient) => Promise<T>): Promise<T> {
const tenant = currentTenant();
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(`SELECT set_config('app.tenant_id', $1, true)`, [tenant.id]); // true = transaction-scoped
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (e) { await client.query('ROLLBACK'); throw e; }
finally { client.release(); }
}
// Repository code no longer needs to remember the WHERE clause; RLS applies it.
// Keep it anyway for index usage and clarity: SELECT * FROM projects WHERE tenant_id = $1 ...
Transaction-scoped set_config matters with a connection pool: a session-scoped setting would leak the previous request's tenant to the next request on the same connection. The tenant_id-first index rule ties into composite index ordering: nearly every query is WHERE tenant_id = ? AND …, so every index should start with it.
Tenant-scope everything, not just the database
- Cache keys:
t:{tenantId}:projects:list. A cache without the tenant prefix serves one customer's data to another; I have seen it happen with a "global" settings cache. - File storage:
s3://bucket/{tenantId}/…with IAM conditions or signed URLs scoped to the prefix. - Search indexes: a
tenant_idfilter in every query, applied server-side (the same rule as access control in RAG retrieval). - Background jobs: the job payload carries
tenantId; the worker enterstenantStore.run()before doing anything. - Logs and metrics: tag every log line and metric with the tenant so you can answer "is it slow for everyone or just Acme?"
Plans, limits and billing
Stripe (or your provider) is the source of truth for what a tenant pays for. Your database caches the resulting plan and limits, updated by webhooks. Enforcement happens in the domain layer, where the action is attempted:
// billing/limits.ts
export const PLAN_LIMITS = {
free: { seats: 2, projects: 3, apiRequestsPerMin: 60, storageGb: 1 },
pro: { seats: 25, projects: 100, apiRequestsPerMin: 600, storageGb: 50 },
enterprise: { seats: Infinity, projects: Infinity, apiRequestsPerMin: 6000, storageGb: 1000 },
} as const;
// modules/projects/service.ts
export async function createProject(input: CreateProjectInput) {
const tenant = currentTenant();
return withTenantTx(async (tx) => {
const [{ count }] = (await tx.query(`SELECT count(*)::int FROM projects WHERE tenant_id = $1 FOR UPDATE`, [tenant.id])).rows;
if (count >= tenant.limits.projects) {
throw new AppError(402, 'Plan limit reached', `Your ${tenant.plan} plan allows ${tenant.limits.projects} projects.`, { upgradeUrl: '/billing' });
}
return projectsRepo.insert(tx, { ...input, tenantId: tenant.id });
});
}
// webhooks/stripe.ts -- keep the cached plan in sync
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, req.get('stripe-signature')!, process.env.STRIPE_WEBHOOK_SECRET!);
if (event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.deleted') {
const sub = event.data.object;
const plan = sub.status === 'active' ? planFromPrice(sub.items.data[0].price.id) : 'free';
await tenants.updatePlan(sub.metadata.tenantId, plan, PLAN_LIMITS[plan]);
await redis.del(`tenant:${sub.metadata.tenantId}`); // bust the resolver cache
}
res.json({ received: true });
});
The 402 with an upgradeUrl is a deliberate product decision: hitting a limit is a sales moment, and the API response should carry what the UI needs to show the upgrade prompt. Returning 403 reads as "you did something wrong".
Noisy neighbours
In a shared system, one tenant's bulk import can slow everyone. Defences, in the order I add them:
- Per-tenant rate limits using the plan's
apiRequestsPerMinas the bucket size (token bucket implementation). - Per-tenant query timeouts:
SET LOCAL statement_timeout = '5s'inwithTenantTx, lower for free plans. - Queue fairness: round-robin across tenants rather than FIFO, or per-tenant concurrency caps, so a 50,000-row import does not starve everyone's emails (BullMQ details).
- Read replicas for reporting, so a tenant's heavy dashboard query never competes with writes.
- Tenant sharding only when a single database is genuinely full. Because everything is keyed by tenant, moving a tenant to another database is a data copy plus a change to
dbTargetin the resolver.
If you are doing this on WordPress
WordPress Multisite is schema-per-tenant by another name: each site gets its own set of tables (wp_2_posts, wp_3_posts). It works well up to a few hundred sites and gives you per-site plugins and themes; beyond that, migrations (wp core update-db --network) and backups get slow, and shared-schema custom tables with a blog_id column serve cross-site features better. I have run both for agency clients; the trade-offs match the table at the top of this post almost exactly.
- Tenant resolved in middleware; tenant ID never read from the request body.
- RLS enabled and forced on every tenant table; app role is not the table owner.
tenant_idfirst in every index; cache keys and storage paths prefixed.- Plan limits enforced in services; Stripe webhooks update the cached plan.
- Per-tenant rate limits and query timeouts.
- A "delete tenant" job that actually removes everything, tested. Data deletion requests are not optional.
Multi-tenancy is mostly discipline: one decision about where the tenant boundary lives, then applying it everywhere without exception. Get the resolver and RLS in place before the first feature, and every feature after that inherits the isolation for free. If you are scoping a SaaS build and want this foundation done right, this is one of my favourite kinds of project.