Multi-Tenant SaaS Architecture: What Nobody Tells You Before You Build

Published: (March 29, 2026 at 03:57 PM EDT)
5 min read
Source: Dev.to

Source: Dev.to

The Three Canonical Patterns

PatternDescriptionProsCons
1. Separate databases per tenantEach tenant gets its own database instance.• Full data isolation
• No risk of cross‑tenant leakage
• Simple off‑boarding
• Trivial per‑tenant backup/restore
• Provisioning time ↑
• Connection‑pool management becomes complex
• Schema migrations must run across N databases
• Cost scales linearly with tenant count
2. Separate schemas, shared databaseOne database server, each tenant has its own schema (PostgreSQL‑native).• Logical separation without the overhead of separate instances• Connection pools (e.g., PgBouncer) work at the connection level, not the schema level
• Must set search_path per request
• Some ORMs handle this gracefully, others don’t
• Migrations still need coordination across all tenants
3. Shared schema, shared database (row‑level tenancy)All tenants share the same tables; each row has a tenant_id column.• Cheapest to operate
• Simplest migrations
• Fastest onboarding
• High risk of accidental data leakage if a tenant_id filter is missed
• Requires Row‑Level Security (RLS) as a hard defence

3️⃣ Shared‑Schema Approach: Enforcing Row‑Level Security

If you choose the shared‑schema model, PostgreSQL RLS is not optional—it’s the last line of defence.

-- Enable RLS on the projects table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Create a policy that restricts reads to the current tenant
CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

Setting the tenant context per request

SET app.current_tenant_id = '{{tenant_uuid}}';

Even if application code forgets a tenant_id filter, the database enforces the boundary (defence‑in‑depth).

Cost: current_setting() adds a marginal overhead per query—negligible for most workloads, but benchmark if you run at very high query rates.

How Does Your Application Identify the Tenant?

ApproachExampleImplications
Subdomain‑basedacme.yourapp.com → resolve acme to a tenantRequires DNS wildcard or per‑tenant records; adds routing complexity
Custom domainapp.acmecorp.comMust map arbitrary domains to tenant IDs at the edge
Path‑basedyourapp.com/t/acme/dashboardSimpler DNS, but URL parsing needed
Token‑basedTenant ID encoded in JWT or session tokenWorks well for APIs; requires secure token handling

Most teams mix approaches: subdomain for the main UI, token‑based for the API.

Migration Strategies

  • Single‑tenant app: Migration runs once.
  • Multi‑tenant (shared schema): Migration runs once.
  • Multi‑tenant (schema‑per‑tenant or database‑per‑tenant): Migration runs N times.

Requirements for a multi‑tenant migration runner

  1. Track migration state per tenant
  2. Run migrations in parallel (configurable concurrency)
  3. Handle failures gracefully – avoid partial rollouts (e.g., some tenants on version 7, others on version 8)

Proven pattern

Maintain a tenant_migrations table in a management database:

tenant_idmigration_versionlast_run_at

Your deployment pipeline:

  1. Query tenant_migrations to find tenants not at the current version.
  2. Run migrations in batches (parallel or sequential, per your concurrency limits).
  3. Update the table on success or log failures for retry.

Onboarding New Tenants

ModelOnboarding stepsTypical latency
Shared‑schemaInsert a row into tenants table; use generated ID as tenant_id for all writes.Near‑instant (atomic)
Schema‑per‑tenantCreate a new schema → run baseline migrations.Seconds for small schemas; minutes for large ones (often async with a “workspace is being prepared” UI)
Database‑per‑tenantProvision a new DB instance → configure access → run migrations → update routing table.Minutes (background job)

Design your UX around the provisioning model; don’t discover mismatches after shipping.

Key Takeaway

Multi‑tenancy is a data‑architecture and application‑design concern, not an infrastructure concern.
You can run a multi‑tenant app on a single server or across hundreds, and you can run a single‑tenant app in Kubernetes with 50 replicas. The isolation model lives in the data layer and application logic; scaling, availability, and deployment are separate (though equally important) decisions.

Further Reading

  • Actinode guide on multi‑tenant SaaS architecture – a comprehensive decision matrix covering compliance, pattern trade‑offs, and migration strategies.

Tenant Isolation Options

ModelScopeCostMigration ComplexityBest for
Separate databasesFullHighHighEnterprise, regulated industries
Separate schemasLogicalMediumMediumMid‑market SaaS
Shared schema + RLSRow‑levelLowLowHigh‑volume B2B, most startups

Note: The choice you make here will be with you for years. Make it deliberately.

Test Your Tenant Isolation

Whatever model you choose, write explicit tests that verify your tenant isolation holds.

  • Not just unit tests for the query logic — integration tests that simulate cross‑tenant access attempts and verify they fail at the database layer.

Example test suite

1. Create two test tenants with separate datasets
2. Authenticate as tenant A
3. Attempt to read tenant B's records via your application's own API routes
4. Assert the response contains zero tenant B records
  • This test should run in your CI pipeline on every merge.
  • Tenant isolation failures are the kind of bug that makes the news, and the cost of catching them in CI is trivially low compared to the cost of discovering them in production.

RLS policy verification

-- Connect with the RLS policy active
SET app.current_tenant_id = '<tenant_uuid>';

-- Attempt to SELECT tenant B's rows directly
SELECT * FROM sensitive_table WHERE tenant_id = '<other_tenant_uuid>';
  • If RLS is working correctly, the query returns zero rows.
  • If it returns any rows, your policy has a gap.

Bottom Line

Isolation is a correctness property, not just a design preference.
Test it like one.

0 views
Back to Blog

Related posts

Read more »