7
multi-tenant projekter bygget
3
arkitektur-valg vi altid tager
1
vi fortryder stadig

Multi-tenancy er en af de beslutninger der ser simpel ud på dag 1 og viser sit sande ansigt seks måneder inde. Valget man træffer tidligt — om isolation, routing, ID-strategi — er svære at ændre bagefter. Dem der har siddet med den refaktorering ved det.

Det her er ikke en introduktion til multi-tenancy. Det er de konkrete valg vi har konsolideret os på efter syv platforme — med begrundelserne bag.

Valg 1: Row-level security frem for separate schemas

Det klassiske spørgsmål: isolerer man tenants via separate Postgres-schemas (ét schema pr. tenant) eller via en enkelt schema med organisation_id på alle tabeller kombineret med Row-Level Security?

Vi bruger altid RLS. Her er hvorfor.

Separate schemas lyder rent. Og det er det — i starten. Men ved 50 tenants begynder migration-overhead at blive et problem. Når man skal tilføje en kolonne, kører man den migration 50 gange, eller man bygger en migration-orchestrator. Når man skal query på tværs af tenants (analytics, admin-views), joiner man 50 schemas. Det er ikke holdbart.

Med RLS aktiverer man det på policy-niveau i Postgres og sætter app.current_organisation_id via SET LOCAL i starten af hver session. Alle queries er automatisk tenant-isolerede. Migrationerne kører én gang. Cross-tenant queries til interne formål er trivielle.

RLS-policies ser sådan ud i praksis:

  • CREATE POLICY tenant_isolation ON orders USING (organisation_id = current_setting('app.current_organisation_id')::uuid);
  • Man aktiverer ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
  • En superuser-rolle til migrations og interne jobs er undtaget fra RLS via BYPASSRLS

Det eneste sted separate schemas giver mening er ved very high compliance-krav — banker, sundhed — hvor tenants skal kunne kræve fuld data-isolation inklusive backup-niveau. Det er ikke 95% af SaaS-platforme.

Valg 2: Subdomain-routing til white-label

Hvis platformen skal understøtte white-label — altså at tenants har deres eget domæne eller subdomain — er subdomain-routing det rigtige fra dag 1. Ikke path-baseret routing.

Path-baseret routing (app.dk/acme/dashboard) er nemmere at implementere initielt, men det lækker tenant-identiteten i URL'en på en måde der er svær at skjule. Det komplicerer også CORS, cookies og session-management.

Subdomain-routing (acme.app.dk eller app.acme.dk med custom domain via CNAME) giver en ren isolation. Middleware resolver tenant fra subdomain eller Host-headeren. Custom domains er en naturlig udvidelse.

Teknisk set: wildcard SSL-certifikat til egne subdomains via Let's Encrypt, plus en custom domain-feature der gemmer et bekræftet domæne på tenant-modellen og laver reverse lookup i middleware. Det er to-tre dages arbejde at bygge ordentligt — og man sparer sig for en meget ubehagelig refaktorering senere.

Valg 3: UUID som tenant-ID, aldrig sequential

Aldrig id SERIAL som tenant-ID. Altid UUID v4.

Sequential integers som tenant-ID er et informationslæk. En konkurrent kan registrere sig som tenant 1 og vende tilbage tre måneder senere som tenant 847 og vide præcis hvor mange tenants I har fået. Det er dårlig opsec og det er unødvendigt.

UUIDs er også nemmere at arbejde med på tværs af systemer — ingen konflikt ved database-merge, ingen afhængighed af auto-increment-sekvens. Og de er immutable på en måde sequential IDs ikke er: der er ingen fristelse til at "bare skifte dem" for at rydde op.

Brug UUID v4 (tilfældig) frem for v7 (timestamp-baseret) til tenant-IDs. Timestamp-UUIDs er sekventielle i praksis og lider af det samme problem som integers.

Det vi fortryder: connection pool uden tenant-isolation

På projekt tre byggede vi en shared connection pool — én PgBouncer-instans, alle tenants deler connections i transaction-pooling mode. Det var den oplagte løsning og det var den forkerte.

Problemet: i transaction-pooling mode er SET LOCAL ikke garanteret at overleve transaktionsskiftet. Den session-variable der sætter app.current_organisation_id kan i visse konfigurationer lække til næste transaktion på samme connection. Vi opdagede det ikke i test — vi opdagede det via en edge-case i produktion, seks måneder inde, da en admin-bruger fik vist data fra forkert tenant.

Det var ikke et databrud — det var to interne brugere i en testflow — men det var en alvorlig wake-up call. Vi brugte tre uger på at refaktorere til session-pooling mode med korrekt SET LOCAL + RESET håndtering, og vi lavede en fuld audit af alle queries.

"Tenant-isolation er ikke noget man kan teste sig til. Det er noget man bygger rigtigt fra starten — eller betaler for at fikse bagefter."

I dag bruger vi altid session-pooling mode i PgBouncer, eller vi bruger Supabase's connection pooler der håndterer det korrekt. Og vi har eksplicitte tests der verificerer at en bruger i tenant A aldrig kan se data fra tenant B — ikke som unit tests, men som integration tests mod en reel testdatabase med to tenants.

Hvad der ikke er på listen

Et par ting vi bevidst har valgt fra:

  • Separate Postgres-instanser pr. tenant — for tidskrævende at drifte, for dyrt ved mange tenants. Giver kun mening ved enterprise-kontrakter med eksplicit isolation-krav.
  • Tenant-aware caching med Redis prefix — vi bruger det, men det er ikke et arkitektur-valg, det er en implementationsdetalje.
  • Feature flags pr. tenant — vigtigt, men det er applikationslogik, ikke infrastruktur. Det fortjener sit eget indlæg.

Multi-tenancy handler om at træffe fem valg rigtigt fra starten. Resten løser sig selv.