Marcus Chen, YuSMP Group
Marcus Chen Staff Engineer (Backend & Cloud), YuSMP Group · multi-tenant SaaS architecture on AWS and GCP

The 60-second answer

Multi-tenant SaaS architecture means one running application, backed by shared infrastructure, serving many customer organisations (tenants) side by side while every tenant’s data stays walled off — logically or physically. One decision drives everything after it: the isolation model. Auth, billing, data residency and observability all inherit from that first call. Our SaaS development team ships all three models below in production.

Three credible isolation models in 2026:

  1. Shared database, shared schema, tenant_id column + Postgres RLS. Default. Cheapest to operate. Supports 10,000+ tenants on a single Aurora or Neon cluster. Migration to schema-per-tenant later is painful but possible.
  2. Schema-per-tenant (separate Postgres schemas in the same database). The enterprise upgrade. Useful for “logical isolation” conversations with auditors without doubling infra cost. Supports 200–2,000 tenants per cluster.
  3. Database-per-tenant. Regulated workloads, single-tenant deployments, customers paying $100k+/year. Expensive to operate, easiest to delete cleanly, easiest to argue for in regulated audits.

Pick (1) for any new B2B SaaS. Add (2) at the business tier when you sign your first deal that asks “is my data isolated from other tenants?” Add (3) only for regulated verticals or VIP deals over $100k ARR.

Three isolation models — tradeoffs

ModelCost/tenantOps complexityTenants/clusterAudit story
Shared schema + RLS$0.05–0.50/moLow10,000+Logical isolation via RLS
Schema-per-tenant$2–8/moMedium200–2,000Schema-level isolation
Database-per-tenant$25–150/moHigh1 per DBStrong physical isolation

The 10-step build sequence

If you want the whole article as an execution order rather than a reference, here is the sequence we actually run when we stand up a new multi-tenant SaaS from zero:

  1. Pick the default isolation model. Shared schema + RLS, unless you already have a signed regulated-vertical contract that forces database-per-tenant on day one.
  2. Model tenants as a first-class table before you write any other domain table — id, slug, plan, region, created_at. Every other table references it.
  3. Add tenant_id to every domain table at creation time, non-null, foreign-keyed, indexed as the leading column of your most common composite indexes.
  4. Turn on Postgres RLS on every domain table with a policy against current_setting('app.tenant_id'), and set FORCE ROW LEVEL SECURITY on the application role.
  5. Wire tenant resolution into request middleware. Resolve tenant ID from the auth token (not the URL or a client-supplied header), then SET LOCAL app.tenant_id at the start of every transaction.
  6. Pick a managed auth provider with organisation/team support (Clerk, WorkOS, Auth0, Supabase Auth) rather than rolling your own multi-tenant login.
  7. Design authorization as tenant-scoped RBAC from the start — do not bolt roles on after the fact once support tickets start asking “why can this user see another team’s data.”
  8. Namespace every cache key by tenant_id before you introduce Redis or any shared in-memory cache — this is the single easiest place to leak data later.
  9. Wire up Stripe (or Orb/Metronome) with one Customer per tenant and a nightly reconciliation job before you take your first paying customer.
  10. Make tenant ID a first-class dimension in observability — traces, error tags, dashboards — before you have enough tenants that “the API is slow” stops being answerable by eyeballing logs.

Steps 1–5 are non-negotiable for any multi-tenant SaaS. Steps 6–10 are where teams cut corners under deadline pressure, and where the gotchas in the migration section below come from.

Shared schema with RLS — the right default

For a new B2B SaaS in 2026, shared schema with RLS is what most senior teams reach for first. Here is how the mechanics play out:

  1. Every domain table has a non-null tenant_id uuid references tenants(id) column, indexed as part of every common query.
  2. Postgres Row-Level Security is enabled on every domain table.
  3. Each table has an RLS policy: USING (tenant_id = current_setting('app.tenant_id')::uuid).
  4. Every request handler runs SET LOCAL app.tenant_id = '...' at the start of its transaction, derived from the verified auth token.
  5. The database role used by the application has FORCE ROW LEVEL SECURITY set, so even the application cannot bypass the policy without changing role.

Why go to the trouble? Because a sloppy SQL bug in your own code then cannot spill one tenant’s data into another’s. Whatever a query asks for, the database still refuses to hand back rows that belong to a different tenant. We have watched this catch real bugs during customer audits: queries that should have leaked other tenants’ rows, stopped cold by RLS, and logged with a clear error.

Production tips from our delivery work:

  • Use a connection pooler (PgBouncer in transaction-pooling mode) but be aware that SET LOCAL resets at end of transaction — this is the correct behaviour.
  • For background jobs, set app.tenant_id in the job handler, never trust the job payload alone.
  • Add a smoke test in CI that intentionally tries to read another tenant’s row and asserts that 0 rows return.
  • Composite indexes should lead with tenant_id — e.g., (tenant_id, created_at) — for query plan stability.

Schema-per-tenant — the enterprise upgrade

Sooner or later an enterprise buyer asks “is my data sitting in the same database as everyone else?”, and you start losing deals over the answer. That is where schema-per-tenant earns its keep. Each tenant gets a dedicated Postgres schema (tenant_a1b2c3) holding the full set of tables, and the application sets search_path at the top of each transaction.

Benefits over shared schema:

  • True schema-level isolation. A bug cannot return another tenant’s rows.
  • Easier per-tenant backup / restore (single schema dump).
  • Easier per-tenant deletion (single DROP SCHEMA CASCADE).
  • Auditor-friendly answer: “each tenant has a logically separated schema.”

Costs:

  • Migrations have to run across N schemas, in parallel, with proper failure handling. Tools: Sqitch, Flyway, or a custom orchestrator.
  • Cross-tenant analytics queries become awkward (you cannot easily “SUM across all tenants”). Solve with a separate analytics database fed from CDC.
  • Postgres catalog size grows with N tenants × N tables. Past ~2,000 schemas, you start seeing pg_class bloat affect planner time.

Database-per-tenant — regulated and isolated

Some customers get a whole database to themselves: regulated verticals (HealthTech, GovTech, defence, a handful of financial workloads) and whale accounts paying $100k+/year. Often their own VPC too, and their own region.

It is not cheap — reckon on $25–150/month per tenant on AWS RDS or an equivalent. What you buy for that money is an audit story that is almost boring to tell, a blast radius of exactly one customer when something breaks, and the simple fact that anyone signing a six-figure contract will happily fund the isolation.

Operationally you need:

  • An IaC pipeline (Terraform, Pulumi) that provisions a new database, runs migrations, configures backups and connects observability for each new tenant.
  • A tenant routing layer (typically a Cloudflare Worker, an API gateway or an in-app subdomain router) that maps tenant ID to database connection string.
  • A connection pool per tenant, with sensible idle-eviction.
  • A robust deprovisioning flow — deleting a database, archiving its backup, and removing its IaC state cleanly.

Auth and identity at scale

Rolling your own multi-tenant auth in 2026 buys you almost nothing. The managed options have converged on more or less the same feature set:

ProviderStrengthsWatch out for
ClerkBest DX, organisations primitive, SDKs for Next/Remix/ExpoPricing scales hard past 10k MAU
WorkOSEnterprise SSO (SAML/OIDC), SCIM, audit logsLess polished consumer auth UI
Auth0Mature, every integration existsCostly at scale; complex tenant primitive
Supabase AuthFree with Supabase; pairs with Postgres RLSOrg/tenant model is DIY
FusionAuth / KeycloakSelf-host, GDPR-friendlyYou operate it

A common 2026 setup: Clerk or Supabase early on, then WorkOS bolted on at the business tier once you need enterprise SSO and SCIM. The API layer verifies the token, pulls out the tenant ID and the user’s role, and from there the request handler sets the Postgres GUC.

Authorization — tenant-scoped RBAC that doesn’t leak

Role-based access control layers connecting users to tenant-scoped permissions in a multi-tenant SaaS

Authentication answers “who is this user?”. Authorization answers “what can this user do inside this tenant?” — and it is the part teams most often bolt on after launch, once a support ticket asks why a viewer-role user could edit billing. Design it up front as one of three shapes:

  • Global roles. A role that applies the same everywhere the user has access (simple, but wrong for most B2B SaaS — a user is rarely “admin” on every tenant they belong to).
  • Tenant-scoped roles. The role lives on the membership record (tenant_id, user_id, role), not on the user. Same person can be admin on one tenant and viewer on another — the correct default for B2B SaaS.
  • Hybrid templates + overrides. A tenant picks a role template (Owner, Admin, Member, Viewer) and can override individual permissions per member for enterprise customers who need fine-grained control without you shipping a full policy engine.

The invariant that matters more than the shape you pick: every authorization check is scoped by both tenant_id and permission, in the same query. Checking permission first and tenant second (or trusting a cached role from a previous request) is how cross-tenant privilege bugs get into production. Enforce it in the data layer alongside RLS, not in a middleware that can be skipped by a new route.

Caching without leaking tenants

Cache is the fastest way to leak one tenant’s data into another’s response, because it sits outside the database and outside RLS entirely — a Postgres policy cannot protect a Redis key that was built without a tenant_id in it. Rules that hold up in production:

  • Namespace every cache key by tenant_id, always, even for data that looks tenant-agnostic today (feature flags, config, plan limits) — it usually stops being tenant-agnostic later.
  • Never share an in-process/in-memory cache across tenants for anything derived from a request. A per-process LRU cache keyed only by resource ID, shared across all tenants on that instance, is the classic incident.
  • Invalidate per tenant, not globally. A plan upgrade or permission change for tenant A should never require a global cache flush that degrades performance for every other tenant.
  • Cache the tenant resolution itself carefully. If you cache “domain → tenant_id” lookups for custom-domain routing, set a short TTL and invalidate on tenant deletion — a stale mapping can route a request to the wrong tenant.

Billing, metering and reconciliation

One Stripe Customer per tenant, no exceptions. Each tenant carries a stored stripe_customer_id that ties it back to Stripe, and Stripe Billing owns the subscriptions. For anything usage-based:

  • Emit usage events from the application to an event bus (SNS, Kafka, NATS) tagged with tenant ID, meter name, quantity, idempotency key.
  • A worker aggregates per-tenant usage into your internal ledger (Postgres table).
  • A reconciliation job runs nightly, pushes deltas to Stripe Meters (or Orb/Metronome/Lago) and writes a daily reconciliation report.
  • Surface usage to customers in-product — live, with at most 60s lag. Customers will not trust an opaque bill.

The strategy behind pricing we unpacked separately in SaaS pricing models in 2026; here we are on the engineering side of it. Budget 6–10 weeks to get metered billing production-ready. Not two, whatever the first estimate tells you.

EU data residency the right way

Sell into the EU, and “data stays in the EU” stops being a marketing line: for regulated industries and the public sector it is a procurement checkbox that someone will actually verify. The architecture that holds up:

  1. Two separate clusters: us-east-1 and eu-central-1 (or eu-west-1 for Ireland, eu-north-1 for Sweden).
  2. Each cluster has its own Postgres, blob storage, queues, search, observability stack.
  3. Tenant-to-cluster mapping in a small global registry. Login flow redirects to the correct regional API.
  4. Auth provider must support EU residency (Clerk EU, WorkOS EU, Auth0 EU tenant).
  5. Observability vendor must support EU (Datadog EU, Sentry EU, Grafana Cloud EU).
  6. Do not split rows in a single database by region — auditors do not accept it.

Shipping AI features? Then the LLM provider’s region matters too. Anthropic serves EU endpoints, Bedrock runs Claude in eu-central-1, and Azure OpenAI offers EU regions for GPT-4o. For the paperwork behind all of this, see EU AI Act compliance.

Runtime isolation and noisy neighbors

Shared infrastructure means shared failure modes. One tenant running a heavy export, backfilling data or hitting your API in a retry loop can degrade latency for everyone else on the same database or app instance — long before it shows up as a capacity problem. Containment measures worth having before you need them:

  • Per-tenant rate limits on the API gateway or edge (tenant_id as the rate-limit key, not IP), with a higher ceiling for paid tiers.
  • Connection pool caps per tenant in PgBouncer or your app-level pool, so a single tenant cannot exhaust the shared pool during a traffic spike.
  • Separate background-job queues by tenant tier (or by tenant, for your largest accounts) so a backlog from one customer’s bulk import does not delay another customer’s webhook delivery.
  • Query timeouts and statement_timeout set aggressively per role, so one runaway analytical query from a tenant’s reporting integration cannot pin a connection for minutes.
  • A tenant-tiering plan for outliers. When a single tenant’s data volume or traffic is 100× the median, that is the signal to move them to schema-per-tenant or database-per-tenant rather than trying to out-tune the shared path.

Observability and per-tenant SLOs

Say “the API is slow” in a multi-tenant system and you have said nothing useful until you slice the numbers per tenant. So make tenant ID a first-class dimension on every metric, log and trace:

  • OpenTelemetry traces with tenant.id attribute on every span.
  • Sentry tag tenant_id on every error.
  • Datadog/Grafana dashboards that slice p50/p95/p99 latency and error rate by tenant.
  • Top-10 noisy tenants report — daily, automated.
  • Per-tenant SLOs for your top 20 accounts. When their SLO budget is at risk, alert their CSM, not just engineering.

Skip per-tenant observability and one noisy enterprise tenant dragging down p95 reads like a site-wide outage. Add it, and you get to phone the customer before they phone you.

Database servers powering a multi-tenant SaaS architecture
Per-tenant slicing turns “the API is slow” into “tenant X is hitting a query plan regression on the orders index since Tuesday.”

Migration paths and gotchas

Migration comes for everyone eventually. Two paths we have actually shipped:

  1. Shared schema → schema-per-tenant. Doable but painful. New tenants land in schema-per-tenant; existing tenants migrate in batches via dual-write + verification + cutover. Expect 6–14 weeks of engineering for a 500-tenant migration.
  2. Single region → multi-region (EU residency). Build the EU cluster first, route new EU customers to it, migrate existing EU customers via export + import + cutover with a maintenance window. 8–16 weeks.

Gotchas we have hit repeatedly:

  • UUIDs without v7. Random UUIDs (v4) thrash B-tree indexes. Use UUID v7 (time-ordered) for all new primary keys.
  • Missing tenant_id on side tables. Audit log, attachments, webhook delivery records — all need tenant_id and all need RLS.
  • Long-running tenants with bloated data. One tenant with 10M rows in a table where the median has 5k slows everyone via shared buffers. Move them to schema-per-tenant or DB-per-tenant.
  • Stripe customer drift. A tenant deletion that does not cancel Stripe subscriptions leaves zombie revenue and accounting headaches.
  • Background job leakage. A job triggered for tenant A that queries tenant B because the job runner forgot to set the GUC. Always set the GUC inside the job handler, never trust the payload.

FAQ

What is multi-tenant SaaS architecture?

A design where one application instance and shared infrastructure serve many customer organisations (tenants) at once, while each tenant’s data stays logically or physically isolated. The central decision is the isolation model: shared schema with row-level security, schema-per-tenant, or database-per-tenant.

What is the best tenant isolation strategy for a new SaaS?

Shared schema + Postgres RLS, with tenant_id on every table and the GUC set per request. Easiest to operate, cheapest to scale, easiest to migrate later.

How do I handle auth in a multi-tenant SaaS?

Use a managed provider (Clerk, Auth0, WorkOS, Supabase Auth). Add WorkOS for enterprise SSO/SCIM at the business tier.

How do I design authorization (RBAC) for multi-tenant SaaS?

Store the role on the tenant membership record, not on the user — the same person can be admin on one tenant and viewer on another. Check tenant_id and permission together in every query, alongside RLS, so a bug in one layer does not become a cross-tenant leak.

Where should tenant_id live in the data model?

Every table, indexed, foreign-keyed, enforced by RLS. Always.

How do I handle EU data residency?

Separate EU and US clusters with their own databases, storage and observability. Route by tenant. Do not split rows within a single database.

Serverless or container runtime?

Containers (Fargate, GKE Autopilot, Fly.io) for the main API; serverless for edges like webhooks and image processing.

How do I bill across tenants cleanly?

One Stripe Customer per tenant, Subscriptions linked to internal tenant_id, metered events via Stripe Meters or Orb/Metronome. Reconcile nightly.

How do I cache data safely in a multi-tenant SaaS?

Namespace every cache key by tenant_id, never share an in-process cache across tenants, and invalidate per tenant instead of flushing globally. Cache is outside RLS, so it is the easiest place to leak data if you skip the tenant_id prefix.

Build it right the first time

Tell us your tenancy model, the isolation you need, and the audit story you are on the hook for. We will come back with the cheapest architecture that still holds together through 100× growth.

Last updated 3 July 2026.