Die Datenbank-Wahl für deine SaaS ist nicht eine Entscheidung. Es sind 4.
Einen Abend lang habe ich über "welche Datenbank für mein SaaS" gegrübelt, als wäre es eine einzige Entscheidung. Ist es aber nicht. Wie du Mandantendaten aufteilst, ist eine Entscheidung. Was verhindert, dass dein eigener Code versehentlich Daten zwischen Mandanten durchsickern lässt, ist eine zweite. Womit du tatsächlich in TypeScript mit der Datenbank sprichst, ist eine dritte. Ob du das alles selbst betreibst, ist eine vierte – und die meisten Guides kommen nie über die erste hinaus.
Keine dieser 4 Entscheidungen ist für sich genommen schwer. Der Fehler liegt darin, die erste als die komplette Entscheidung zu behandeln und dann nach 6 Monaten überrascht zu sein, wenn sich herausstellt, dass die zweite diejenige ist, die in der Produktion tatsächlich kaputt geht.

Pool gewinnt, außer du bist reguliert

Es gibt 3 Wege, eine gemeinsame Datenbank zu strukturieren, und nur einer davon macht für die meisten Solo-SaaS Sinn.
- Silo: 1 Datenbankinstanz pro Mandant. Vollständige Isolation, null Risiko, dass Kunde A einen Blick auf Kunde B's Zeilen wirft, und eine lineare Kostenkurve, die um den 50. Kunden herum hässlich wird.
- Bridge: 1 Datenbank, 1 Schema pro Mandant. Günstiger als Silo, aber jede Migration läuft jetzt über so viele Schemas, wie du hast, und Postgres wurde nicht gerade dafür gebaut, hunderte Kopien derselben Tabellenstruktur zu hosten. Übersteige ein paar tausend Mandanten und der Systemkatalog selbst beginnt unter dem Gewicht zu ächzen.
- Pool: 1 Set von Tabellen, geteilt von allen, aufgeteilt durch eine tenant_id-Spalte.
Pool ist die langweilige Antwort und die richtige. Am günstigsten zu betreiben, ein Schema zu migrieren, ein Connection Pool zu dimensionieren. Der Haken ist derselbe Haken, den jedes Shared-Table-Setup hat: dein Code muss sich daran erinnern, bei jeder einzelnen Abfrage nach tenant_id zu filtern, jedes einzelne Mal, für immer. Vergiss diesen Filter einmal, in einem Hintergrund-Job, den du nicht sorgfältig getestet hast, und Kunde A bekommt einen Blick auf Kunde B's Rechnungen. Karen aus der Buchhaltung bemerkt es vor dir, und jetzt schreibst du einen Incident-Report statt Features zu shippen.
Die Ausnahme ist real, nicht theoretisch: Gesundheitswesen, Finanzen, alles wo ein Auditor irgendwann von dir verlangen wird zu beweisen, dass die Isolation auf Infrastruktur-Ebene stattfindet, nicht nur auf Query-Ebene. Wenn das dein Kontext ist, hört Silo auf paranoid zu sein und wird zum Eintrittspreis. Für alle anderen gilt: Pool.
Der Türsteher, den Postgres bereits gebaut hat
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);
Das ist Row-Level Security (RLS), Postgres' eingebaute Antwort seit 2016. 4 Zeilen, und jetzt weigert sich die Datenbank selbst, eine Zeile herauszugeben, die nicht zum aktuellen Mandanten passt, egal was dein Anwendungscode tut oder zu tun vergisst. Stell es dir wie einen Türsteher vor, der auf der Query-Ebene steht: egal wie charmant dein SELECT-Statement ist, wenn die tenant_id auf deinem Armband nicht passt, kommst du nicht an der Tür vorbei.
Das FORCE-Schlüsselwort ist wichtiger als die Leute erwarten. Ohne es marschiert der Tabellenbesitzer und jede Superuser-Rolle direkt durch die Policy, was zufällig die Rolle ist, als die die meisten lokalen Dev-Setups und Admin-Skripte laufen. Als Superuser zu testen ist im Grunde wie im Gott-Modus zu spielen: nichts was du kaputt machst, zeigt sich im Durchlauf, der zählt. Das habe ich herausgefunden, nachdem ich gute 20 Minuten damit verbracht hatte mich zu fragen, warum meine angeblich kugelsichere Policy jeden Mandanten die Daten aller anderen sehen ließ, alles nur weil ich nie den Gott-Modus verlassen hatte. Teste immer als die tatsächliche Anwendungsrolle, nicht als Admin, denn Admin lügt dich genau auf die Art an, die du während einer Demo nicht willst.
Unrelated, aber während wir bei Datenbank-Mysterien sind, die ich nicht gelöst habe: meine Staging-Datenbank geht jeden einzelnen Sonntag die Verbindungen aus, wie ein Uhrwerk, und ich habe nie an diesem Tag etwas deployed. C'est la vie.
Noch eine Stolperfalle, und diese lebt auf Connection-Pooler-Ebene. Wenn du PgBouncer oder ähnliches im Transaction-Modus laufen lässt, muss das Setzen des Mandanten-Kontexts innerhalb der Transaktion passieren, mit SET LOCAL begrenzt, nicht mit einem nackten SET.
BEGIN;
SET LOCAL app.tenant_id = '3f29e1d2-91aa-4b3a-9d21-7e0dcb9a1234';
SELECT * FROM invoices;
COMMIT;
Ein nacktes SET klebt an der Verbindung, und Verbindungen werden vom Pooler recycelt und an die nächste Anfrage weitergegeben. Mach das und Mandant B's Anfrage kann Mandant A's Session-Variable erben, die RLS-Policy läuft sauber durch, und du hast genau das Leck gebaut, das RLS verhindern sollte. SET LOCAL stirbt bei COMMIT oder ROLLBACK, egal wer als nächstes die Verbindung bekommt.
RLS ist eine Schicht, nicht die ganze Verteidigung. Die DevOps-Grundlagen, die ein AI-Agent übersprungen hat, bevor er Prod gelöscht hat sind genauso wichtig wie jede Policy in Postgres: begrenzte Credentials, echte Staging-Umgebungen, Backups die irgendwo anders leben als auf derselben Box, denn RLS hält Mandanten auseinander, wurde aber nie dafür gebaut, etwas zu sichern.
1 Index, 25 Mal schneller
Ohne Index zwingt jede Query, die eine Tabelle unter RLS trifft, Postgres dazu, die Policy Zeile für Zeile zu prüfen, und wenn nichts da ist, um das einzugrenzen, fällt es auf einen Sequential Scan zurück. Das ist das Datenbank-Äquivalent dazu, jeden Random Encounter durchzumahlen statt den Warp-Punkt direkt zum Boss zu nehmen. Bei einer Tabelle mit ein paar tausend Zeilen ist das unsichtbar. Bei einer Tabelle mit ein paar Millionen wird deine Dashboard-Ladezeit zur Kaffeepause.
CREATE INDEX idx_invoices_tenant_id ON invoices (tenant_id, created_at);
Füge einen zusammengesetzten Index mit tenant_id als führender Spalte hinzu und Postgres wechselt vom Scannen der ganzen Tabelle zu einem Bitmap Index Scan, geht direkt zu den Zeilen, die wichtig sind. Benchmarks auf Millionen-Zeilen-Tabellen setzen den Unterschied bei 25 Mal schneller an, manchmal mehr, und der p95-Latenz-Overhead von RLS selbst fällt auf unter 2%, nah genug an kostenlos, dass es aufhört ein Diskussionspunkt zu sein.
Die Policy-Prüfung selbst ist günstig: ein einziger Gleichheitsvergleich, um den Postgres bereits weiß wie er planen soll. Die echten Kosten verstecken sich woanders, in einer Query-Planner-Entscheidung, die du nur bemerkst, wenn du danach suchst. So landen Teams dabei, RLS zu shippen, eine langsame Query 3 Wochen später in der Produktion auftauchen zu sehen, und einen Nachmittag damit zu verbringen, überzeugt zu sein, dass das Sicherheitsmodell selbst das Problem ist, wenn der tatsächliche Fix ein CREATE INDEX-Statement war, das die ganze Zeit da gesessen hat.
Noch ein Detail, klein aber teuer wenn du es überspringst: wenn deine Policy eine benutzerdefinierte Funktion statt eines einfachen Spaltenvergleichs aufruft, markiere diese Funktion als STABLE.
CREATE FUNCTION current_tenant() RETURNS uuid
LANGUAGE sql STABLE
AS $$ SELECT current_setting('app.tenant_id')::uuid $$;
Lass es VOLATILE (der Standard) und Postgres evaluiert es für jede einzelne Zeile neu statt einmal pro Query, was stillschweigend den ganzen Sinn des Index zunichte macht.
Ich denke, das deckt das RLS-Setup ab, das für die große Mehrheit der Pool-Model-SaaS da draußen funktioniert, obwohl ich zugeben muss, dass ich nicht völlig sicher bin, ob es hält, wenn du dutzende gestapelte Policies pro Tabelle laufen lässt. Das ist eine Komplexitätsstufe, die ich persönlich noch nicht erreicht habe.
Prisma, Drizzle, oder SQL komplett überspringen

Prisma und Drizzle sind beide ORMs, die Schicht zwischen deinem TypeScript und dem SQL darunter, und Prisma liest sich wie einfaches Deutsch während das Autocomplete dich gefährlich fühlen lässt 🤓. Version 7 hat endlich die alte Rust Query Engine Binary für einen WebAssembly-Compiler fallen gelassen, das Bundle von 14MB auf 1.6MB reduziert und das Cold-Start-Problem behoben, das es jahrelang auf Serverless schmerzhaft gemacht hat.
Drizzle überspringt die Abstraktion fast vollständig. Schemas sind einfaches TypeScript, Queries sehen dem SQL ähnlich, das sie generieren, und das Ganze wiegt irgendwo zwischen 12KB und 57KB, je nachdem was du importierst. Cold Start landet bei 50ms. Wenn du auf die Edge deployest (Cloudflare Workers, diese Art Runtime), ist Drizzle die Wahl. Du musst tatsächlich SQL können, um es gut zu nutzen, was je nach Ausgangspunkt entweder ein Feature oder ein Filter ist. Prismas generierte Joins lesen sich gelegentlich wie eine Stack Overflow-Antwort, schnell kopiert und nie wieder gelesen.
Drizzle hat auch eine echte Antwort für das RLS-Verdrahtungsproblem: es lässt dich Rollen und Policies direkt im TypeScript-Schema deklarieren, und Node's AsyncLocalStorage trägt die tenant_id von deiner Middleware runter zur Query-Funktion, ohne sie durch jede Funktionssignatur von Hand durchzureichen. Prisma-Extensions können denselben Trick mit Client-Wrappern vortäuschen, aber es ist angebaut. Drizzle wurde damit im Hinterkopf gebaut.
Wähle Prisma für die glatteste lokale Dev-Erfahrung, wenn du nicht auf die Edge deployest, wähle Drizzle wenn du bereits in SQL komfortabel bist und Cold Start wirklich für deinen Stack wichtig ist.
Supabase händigt dir RLS und die Ops-Rechnung aus
Supabase ist Postgres darunter, verdrahtet mit ihrer eigenen Auth, Storage und einer auto-generierten API-Schicht (PostgREST), die dein Frontend fast direkt mit der Datenbank sprechen lässt. Dieser letzte Teil ist, warum RLS auf Supabase aufhört optional zu sein: deine Client-App kann die Datenbank treffen ohne ein Backend dazwischen, also ist die Policy das einzige, was zwischen Kunde A und Kunde B's Zeilen steht. Supabase läuft auf genau dem RLS-Setup von früher in diesem Artikel, nur vorverdrahtet in ihr Dashboard statt einer Migrationsdatei, die du von Hand schreibst.
Supabase selbst zu hosten ist real und kostenlos, sie veröffentlichen den vollen docker-compose-Stack. Es sind auch 12 Container: Postgres, GoTrue für Auth, PostgREST, Realtime, Storage, Kong als API-Gateway, Studio für das Dashboard, und ein paar mehr je nachdem was du aktivierst. Den vollen Stack selbst zu betreiben bedeutet, der Sysadmin für ein Dutzend Services zu werden, die früher jemand anderes Problem waren, was sein eigener Endboss ist. RAM klettert schnell, und jeder dieser 12 Container will seine eigenen Updates, sein eigenes TLS, seine eigenen Augen darauf wenn etwas umfällt.
Für einen Solo-Build entfernt die gehostete Free Tier all diese Mathematik. Selbst-Hosting zahlt sich erst aus, wenn es einen echten Grund dahinter gibt: Datenresidenz-Regeln, oder eine gehostete Rechnung, die über das gewachsen ist, was die Ops-Belastung dich an Zeit kostet. (Supabase ist genau der Stack, den ich Schritt für Schritt in Vibe Coding, For Real durchgehe, wenn du die geführte Version vom ersten Next.js-Demo zu etwas tatsächlich Geshipptem willst.)
Convex überspringt SQL und selbst-hostet in 1 Container
Mein Convex-Seitenprojekt hat keine schema.sql, keine CREATE POLICY, keinen Migrationsordner. Queries sind TypeScript-Funktionen, Multi-Tenancy ist ein Filter, den du innerhalb dieser Funktion schreibst, und Echtzeit-Updates werden standardmäßig geliefert statt ein Feature zu sein, das du anschraubst. Der Trade ist auch real: kein roher SQL-Zugang bedeutet kein Abspringen in psql wenn etwas falsch aussieht, du bist vollständig innerhalb Convex' Query-Sprache oder du steckst fest.
Convex liefert auch ein Open Source selbst-gehostetes Backend, und der Kontrast zu Supabase' Stack ist der ganze Pitch: 1 Container, der die Sync Engine und die Datenbank laufen lässt, nicht 12. Weniger zu updaten, weniger zu überwachen, weniger wofür man angepiept wird. Der Haken ist Reife, nicht Komplexität: Convex selbst-gehostet ist jünger, das Community-Tooling drumherum ist dünner, und du tauschst Supabase' Jahrzehnt von "jemand ist auf diesen Bug vor dir gestoßen" gegen ein Projekt mit viel weniger Runway dahinter.
Ich bin all-in gegangen beim Shippen eines SaaS-Backends ohne SQL zu berühren für dieses Seitenprojekt. Selbst-hoste Supabase und du erbst ein Dutzend Container um das zu bekommen, was ihre Free Tier bereits aushändigt, und selbst-hoste Convex und du erbst 1 Container, der ein Projekt unterstützt, das noch nicht durch so viele Produktionsfeuer gegangen ist. Zwei verschiedene Rechnungen für denselben Instinkt, deinen Stack zu besitzen.
Eigentlich, lass es mich anders ausdrücken: keine dieser 4 Entscheidungen ist für sich genommen schwer. Pool, Bridge, Silo (ein motivierter Dev landet in 10 Minuten mit einer Serviettenskizze auf der richtigen Antwort). Prisma oder Drizzle ist auch ein 10-Minuten-Anruf. Supabase, Convex, oder rohes Postgres? Wähle eins und mach weiter. Der schwere Teil ist der zusammengesetzte Index, den du hinzufügst oder nicht, die Funktion, die du STABLE markierst oder nicht, das SET LOCAL, das du in die Transaktion packst oder nicht. Die Art von Detail, vor der dich keine der anderen 3 Entscheidungen jemals warnt.
Deine Datenbank versteckt nie deine Architekturfehler. Sie wartet nur darauf, dass das Volumen auftaucht, bevor sie dir die Rechnung schickt.
Füge den Index hinzu, bevor sie es tut.
Quellen
Dieser Post kann Affiliate-Links enthalten. Wenn du sie anklickst, verdiene ich möglicherweise eine kleine Provision (kostet dich nichts und hilft mir dabei, weiterhin täglich qualitativ hochwertige Artikel für dein Lesevergnügen zu liefern).