Building a Multi-Tenant SaaS Platform on GCP – Architecture Blueprint
"Multi-tenant" is not one architecture — it's a spectrum from a fully shared database with a tenant_id column on every table, to one dedicated GCP project per customer, and most real SaaS platforms need different points on that spectrum for different tenant tiers within the same product. The mistake that shows up repeatedly in production is picking one isolation model for the whole platform and discovering it's wrong in both directions at once: too expensive to run for a free-tier signup, and too weak on isolation for an enterprise customer who reasonably asks where their data physically lives and what happens if another tenant's query goes rogue.
This guide builds one running blueprint: a project-management SaaS with three tenant tiers (free, pro, enterprise) on GCP, where free and pro tenants share a pooled Cloud SQL instance with row-level isolation, and enterprise tenants get dedicated Cloud SQL instances behind the same API surface. You'll see the three isolation models and when each is the right default, how a request resolves which tenant it belongs to before touching any tenant data, the data-isolation choice within the pooled tier, and a real noisy-neighbor failure: one free-tier tenant's report query degrading pro-tier tenants sharing its database. For the schema-level version of this same problem, see multi-tenant SaaS with a Postgres schema.
Silo, pool, and bridge — three isolation models on GCP
Silo gives each tenant a fully dedicated stack — its own Cloud SQL instance, its own GKE namespace or Cloud Run service, sometimes its own GCP project. It's the strongest isolation (a bug or a runaway query in tenant A's stack cannot touch tenant B's) and the most expensive per tenant, since idle capacity for a low-traffic tenant is still fully provisioned. Pool puts every tenant on shared infrastructure — one Cloud SQL instance, one Cloud Run service, tenant data distinguished only by a tenant_id column or a request header — which is cheap and scales well for many small tenants, at the cost of one tenant's load or bug being able to affect others sharing the same physical resources.
Bridge is the pragmatic middle: pool the compute layer (one Cloud Run service handles every tenant's requests) but silo the data layer for tenants that need it (an enterprise tenant gets a dedicated Cloud SQL instance, reached through the same API and the same tenant-resolution logic as pooled tenants). This is the model most SaaS platforms converge on in practice — free and pro tiers pooled for cost efficiency, enterprise tenants siloed at the data layer for the isolation and compliance guarantees that tier's contracts require, without maintaining two entirely separate codebases.
Quick reference
- Silo isolation cost scales with tenant count regardless of tenant activity — right for tenants paying for guaranteed isolation, wrong as a default for a free tier.
- Pool isolation is a
tenant_idforeign key plus row-level security or an ORM-enforcedWHERE tenant_id = ?on every query — cheap, but one missed filter is a cross-tenant data leak. - Bridge lets the same API layer serve both models — the tenant-resolution and routing logic doesn't change; only which Cloud SQL instance a tenant's queries land on does.
- Decide the isolation tier per tenant at signup/upgrade time based on contract requirements (data residency, guaranteed capacity), not retroactively after a scaling incident.
Remember this
Pool for cost efficiency at low-value tenant volume, silo for tenants whose contract requires guaranteed isolation, and bridge the two behind one API rather than forcing every tenant onto the same model.
Resolving which tenant a request belongs to
Every request must resolve to exactly one tenant before it touches any tenant data, and that resolution has to happen at a layer the application trusts — not by reading a client-supplied tenant id out of a request body, which any client can forge to request another tenant's data. The reliable pattern: resolve the tenant from the authenticated identity (a JWT claim set at login, verified by signature, not a header the client can set arbitrarily), look up that tenant's isolation tier and target Cloud SQL instance/connection string in a small tenant-directory service or cached table, and only then execute the request against the correct data store.
For pooled tenants, this resolution ends in a tenant_id value used in every query's WHERE clause (or better, enforced via Postgres row-level security so a forgotten filter fails closed instead of silently returning all tenants' rows). For siloed enterprise tenants, resolution ends in a different Cloud SQL connection string entirely — the application code path is identical, only the connection pool it draws from differs, which is what makes bridge isolation maintainable as one codebase instead of two.
Quick reference
- Resolve the tenant from a signed identity claim (JWT), never from a client-controlled header or query parameter — that's the difference between authorization and a support ticket.
- Postgres row-level security (
CREATE POLICY ... USING (tenant_id = current_setting('app.tenant_id'))) makes a forgottenWHEREclause fail closed instead of leaking cross-tenant rows. - Cache the tenant-directory lookup (tier, connection target) aggressively — it changes rarely (tier upgrades, not per-request) and sits on the hot path of every single request.
- Log the resolved tenant id on every request at the point of resolution, not derived later — that's the audit trail a compliance review or incident postmortem will need.
Remember this
Resolve tenant identity from a signed claim before any data access, and enforce the tenant boundary at the database layer (row-level security) so an application-code mistake fails closed instead of leaking data.
Choosing data isolation within the pooled tier
Within the pooled tier, there's a further choice: shared tables with a tenant_id column (simplest, cheapest, scales to many small tenants on one instance), or one schema per tenant within the same Cloud SQL instance (stronger logical isolation — a schema-level permission mistake affects one tenant's schema, not a shared table — at the cost of schema-count overhead and migration tooling that must apply changes across every tenant's schema). Most pooled SaaS tenants are well served by shared tables plus row-level security; schema-per-tenant earns its complexity when tenants need to bring their own read replicas or export tooling that's naturally schema-scoped.
Whichever choice, cross-tenant queries (an internal admin dashboard showing aggregate usage across all tenants) need their own explicit, audited code path that deliberately bypasses the per-tenant filter — never build that path by reusing the same query functions tenant-facing code uses with the filter accidentally omitted, since that's the exact bug shape that causes real data leaks.
Quick reference
- Shared tables + row-level security is the right default for a pooled tier with many low-value tenants — cheapest to run, cheapest to migrate schema changes for.
- Schema-per-tenant adds real operational cost (migrations must iterate every tenant schema) — reserve it for cases needing per-tenant backup/restore or export granularity.
- Build cross-tenant admin queries as a distinct, explicitly-audited code path — never by omitting the tenant filter from an otherwise-shared query function.
- Cloud SQL's per-instance connection limits become the pooled tier's real scaling constraint before CPU does — monitor connection count, not just query latency.
Remember this
Shared tables with row-level security are the right default within a pooled tier; reserve schema-per-tenant for cases that genuinely need per-tenant backup or export isolation, and never build cross-tenant admin access by omitting a filter from tenant-facing query code.
One tenant's report query degrading everyone else
The realistic failure: a free-tier tenant runs a heavy, unindexed reporting query (a full-table aggregate across a year of data) against the shared Cloud SQL instance pro-tier tenants also depend on. Cloud SQL's CPU and I/O are shared across every connection to that instance, so the free tenant's one slow query consumes disk I/O and lock time that pro-tier tenants' ordinary, fast queries now have to wait behind — pro-tier customers see elevated latency with no code change on their side, and the root cause is invisible unless you're looking at per-tenant query cost, not just aggregate instance metrics.
The recovery has two layers. Immediately: a connection-pool-level statement timeout kills the runaway query before it monopolizes resources indefinitely (statement_timeout in Postgres), and per-tenant rate limiting on expensive endpoints (see rate limiting algorithms) prevents one tenant from issuing that query repeatedly. Structurally: this is the signal that a tenant has outgrown the pooled tier — move it to a dedicated read replica or a siloed instance rather than tuning the shared instance indefinitely around one tenant's workload shape.
Quick reference
- Set a
statement_timeouton the pooled Cloud SQL instance so no single query can monopolize shared I/O and lock time indefinitely. - Rate-limit expensive per-tenant endpoints (reports, exports, bulk operations) separately from ordinary CRUD endpoints — the failure mode and blast radius are different.
- Track per-tenant query cost (Cloud SQL's query insights, or your own slow-query log tagged by tenant) — aggregate instance metrics hide which tenant caused the spike.
- A tenant repeatedly triggering this is a signal to move them to a dedicated read replica or a siloed instance, not a signal to keep tuning shared infrastructure around their workload.
Remember this
A pooled instance shares CPU and I/O across every tenant on it — cap runaway queries with a statement timeout and per-tenant rate limits, and treat repeated noisy-neighbor incidents as a signal to move that tenant to dedicated infrastructure.
Key takeaway
Stand up a small API with two Cloud SQL connections — one shared "pooled" instance with row-level security keyed on tenant_id, one "siloed" instance for a single enterprise tenant — and a tenant-directory lookup that resolves a signed JWT claim to the right connection. Expected result: three seeded tenants (two pooled, one siloed) each see only their own rows through the same /api/projects endpoint. Then break it — run a deliberately unindexed full-table scan as one pooled tenant while measuring query latency for the other pooled tenant concurrently, and confirm the second tenant's ordinary indexed query slows down measurably. Recovery: add a statement_timeout to the pooled connection pool, re-run the same load, and confirm the runaway query gets killed before it meaningfully degrades the other tenant's latency. Pass criterion: tenant data stays isolated across all three tenants, and the statement timeout measurably bounds the noisy-neighbor query's impact on shared-instance latency.
Related Articles
Explore this topic