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 schemaSchema per tenantDatabase per tenant
HowEvery table has tenant_id; one DBOne Postgres schema per tenant; one DBOne DB (or cluster) per tenant
IsolationLogical (code + RLS)Logical, strongerPhysical
Tenants it scales toMillionsThousandsHundreds
MigrationsRun onceRun N timesRun N times, N connections
Per-tenant backup/restoreHard (filtered export)Medium (pg_dump -n)Trivial
Cross-tenant analyticsTrivialUNION across schemasETL required
Cost per tenant~0LowReal (compute + ops)
Best forMost B2B and B2C SaaSMid-market with compliance asksEnterprise, 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

acme.app.com ──▶ middleware: tenant = lookup('acme') ──▶ ctx.tenant = { id, plan, dbTarget } api key ab12… ──▶ middleware: tenant = lookup(hash(key)) ─┘ │ ▼ every query / job / cache key carries ctx.tenant.id
// 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_id filter in every query, applied server-side (the same rule as access control in RAG retrieval).
  • Background jobs: the job payload carries tenantId; the worker enters tenantStore.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:

  1. Per-tenant rate limits using the plan's apiRequestsPerMin as the bucket size (token bucket implementation).
  2. Per-tenant query timeouts: SET LOCAL statement_timeout = '5s' in withTenantTx, lower for free plans.
  3. 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).
  4. Read replicas for reporting, so a tenant's heavy dashboard query never competes with writes.
  5. 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 dbTarget in 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.

The checklist before your first paying customer
  • 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_id first 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.