Skip to content
T.E.N.E.G.T.A
Language
Blog & news

2024-09-01

Building Multi-Tenant SaaS at Scale: Lessons from 12 Deployments

The architecture decisions that feel like details in a 3-client pilot become load-bearing walls at 40 clients. Here's what we've learned about the ones that matter most.

Building Multi-Tenant SaaS at Scale: Lessons from 12 Deployments

With three pilot customers, multi-tenant SaaS feels like a configuration problem. At forty institutions, it becomes an operational physics problem: isolation, latency attribution, release safety, and the cost of every manual onboarding step compounds into a full-time job for your best engineers.

Across twelve production deployments — fintech, logistics platforms, and internal enterprise tools — we've seen the same pattern: the pilot architecture doesn't fail loudly. It fails gradually, through edge-case leaks, non-linear latency, and deploy fear.


Three Isolation Models (And When Each Wins)

| Model | Isolation strength | Ops complexity | Best for | |-------|-------------------|----------------|----------| | Shared schema + tenant_id | Medium (app-enforced) | Low | Early B2B, < 20 tenants, uniform compliance | | Shared DB + Row-Level Security (RLS) | High (DB-enforced) | Medium | Most B2B SaaS at scale | | Schema-per-tenant | High | Medium–High | Regulated tenants, custom schema needs | | Database-per-tenant | Maximum | High | Few large enterprise clients only |

The mistake at scale: choosing database-per-tenant because it "feels safest" at pilot, then operating 40 databases, 40 backup policies, and 40 migration paths.

What actually works for most B2B platforms: shared schema with RLS as the backstop, plus schema-per-tenant only for tenants that contractually require it — both on the same codebase with a single config flag.


Row-Level Security: The Backstop That Survives Bugs

Application-level filtering (WHERE tenant_id = ?) is necessary but not sufficient. One missed filter in a new endpoint, one ORM eager-load without context, one admin script run without scoping — and you've shipped a cross-tenant data leak.

PostgreSQL RLS moves the boundary into the database:

-- Enable RLS on tenant-scoped tables
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

-- Application sets context once per request/transaction
BEGIN;
SELECT set_config('app.current_tenant', 'a1b2c3d4-...', true);
-- All queries in this transaction inherit the policy
SELECT * FROM orders;  -- only this tenant's rows
COMMIT;

In application code, set tenant context at the connection or request middleware layer — never rely on each repository method remembering:

export async function withTenant<T>(
  tenantId: string,
  fn: (tx: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
  return prisma.$transaction(async (tx) => {
    await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
    return fn(tx);
  });
}

Lesson from production: RLS does not replace authorization logic — it ensures that when authorization fails, the blast radius stays inside one tenant.


Onboarding Automation: Why Under 10 Minutes Matters

At three clients, onboarding is a Slack thread: create DB records, configure feature flags, send credentials. At forty, that's a week of engineering time per month — and every manual step is a security incident waiting for a typo.

We target < 10 minutes from contract signed to first successful API call because:

  1. Sales velocity — enterprise buyers judge operational maturity by onboarding friction
  2. Security — fewer human touchpoints, fewer shared admin sessions
  3. Revenue recognition — delayed onboarding delays go-live dates

A minimal automated pipeline:

Provision tenant record → apply RLS role + schema (if hybrid)
→ seed default config → create IdP connection
→ enable feature flags → smoke test tenant-scoped endpoints
→ notify customer success

Everything idempotent. Everything logged with tenant_id and correlation_id. Re-runnable without duplicating data.


Observability: Averages Lie in Multi-Tenant

When p95 latency spikes, the first question is: which tenant? Shared infrastructure means one noisy neighbor can look like a platform regression.

Minimum observability stack:

  • Tenant ID on every span and log line (never optional in production paths)
  • Per-tenant SLO dashboards — even if you don't contract SLAs yet
  • Query attribution — slow query logs tagged with tenant context from set_config
# Example: alert on single-tenant p95 vs platform p95
- alert: TenantLatencyOutlier
  expr: |
    histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))
    by (tenant_id)
    > 2 * histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))

Without this, you'll optimize the wrong layer — buying bigger CPUs when one tenant's report job needs a queue.


Feature Flags: Per-Tenant Capability Without Forking Code

Enterprise SaaS rarely ships one product. You ship a core platform with per-tenant capabilities: advanced analytics, SSO, custom retention policies.

Feature flags must be:

  • Tenant-scoped — not just global on/off
  • Auditable — who enabled what, when, for which contract tier
  • Evaluated server-side — client-side flags are UX, not security
type TenantFlags = {
  ssoEnabled: boolean;
  advancedReports: boolean;
  maxApiRps: number;
};

async function getFlags(tenantId: string): Promise<TenantFlags> {
  return flagStore.get(tenantId); // backed by DB, cached with TTL
}

Pair flags with trunk-based development: one main branch, capabilities gated per tenant — the pattern that enabled 12 deployments/month for one client without release chaos.


Schema Upgrades Without Downtime: Expand-Contract

The upgrade problem at scale: 40 tenants on a shared schema, zero tolerance for maintenance windows.

Expand-Contract Migration pattern:

  1. Expand — add new columns/tables alongside old (dual-write if needed)
  2. Migrate — backfill tenant-by-tenant with verification
  3. Contract — switch reads to new shape, remove old columns

Never "stop the world" ALTER on Friday night unless you enjoy incident bridges.

For hybrid schema-per-tenant tenants, run the same migration tooling with a tenant manifest — same code path, different connection targets.


What We Don't Recommend

  • One database per tenant by default — ops cost scales linearly; your team doesn't
  • Tenant logic in front-end only — always enforce server-side + RLS
  • "We'll add observability after 20 customers" — you won't know which 20 matter until it's too late
  • Manual onboarding playbooks — they don't survive your first enterprise sales hire's quota

Results That Match the Architecture

In our enterprise SaaS scale case study, a platform moved from 3 to 40+ institutions in six months with:

  • −60% p95 latency after query and cache work
  • 12 deployments/month via trunk-based delivery + feature flags
  • < 10 min new tenant onboarding

Those numbers aren't magic — they're the compound interest of isolation, observability, and automation decisions made before customer 10, not after customer 35.


Next Step

If you're between pilot and production — or already feeling deploy fear — talk to us about an architecture audit. We'll map your tenant boundaries, onboarding path, and the one isolation decision that matters most for your compliance reality.