Multi-Tenant SaaS Architecture: What Nobody Tells You Before You Build
Source: Dev.to
The Three Canonical Patterns
| Pattern | Description | Pros | Cons |
|---|---|---|---|
| 1. Separate databases per tenant | Each 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 database | One 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?
| Approach | Example | Implications |
|---|---|---|
| Subdomain‑based | acme.yourapp.com → resolve acme to a tenant | Requires DNS wildcard or per‑tenant records; adds routing complexity |
| Custom domain | app.acmecorp.com | Must map arbitrary domains to tenant IDs at the edge |
| Path‑based | yourapp.com/t/acme/dashboard | Simpler DNS, but URL parsing needed |
| Token‑based | Tenant ID encoded in JWT or session token | Works 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
- Track migration state per tenant
- Run migrations in parallel (configurable concurrency)
- 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_id | migration_version | last_run_at |
|---|
Your deployment pipeline:
- Query
tenant_migrationsto find tenants not at the current version. - Run migrations in batches (parallel or sequential, per your concurrency limits).
- Update the table on success or log failures for retry.
Onboarding New Tenants
| Model | Onboarding steps | Typical latency |
|---|---|---|
| Shared‑schema | Insert a row into tenants table; use generated ID as tenant_id for all writes. | Near‑instant (atomic) |
| Schema‑per‑tenant | Create 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‑tenant | Provision 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
| Model | Scope | Cost | Migration Complexity | Best for |
|---|---|---|---|---|
| Separate databases | Full | High | High | Enterprise, regulated industries |
| Separate schemas | Logical | Medium | Medium | Mid‑market SaaS |
| Shared schema + RLS | Row‑level | Low | Low | High‑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.