Choisir une base de données pour votre SaaS, ce n'est pas 1 décision. C'en est 4.

11 min read

J'ai passé une soirée sur "quelle base de données pour mon SaaS" comme si c'était une seule décision. Ce n'en est pas une. Comment séparer les données des locataires, c'est un choix. Ce qui empêche vos propres erreurs de code de faire fuiter un locataire vers un autre, c'en est un second. Ce que vous utilisez pour vraiment parler à la base de données en TypeScript, c'en est un troisième. Si vous hébergez quoi que ce soit vous-même, c'en est un quatrième, et la plupart des guides ne dépassent jamais le premier.

Aucune de ces 4 décisions n'est difficile prise individuellement. L'erreur, c'est de traiter la première comme la décision complète, puis d'être surpris 6 mois plus tard quand la seconde s'avère être celle qui plante vraiment en production.

Employé de bureau pointant un arbre de décision simple pendant qu'un super-héros étudie quatre diagrammes complexes d'architecture de base de données sur un tableau blanc
Choisir une base de données, c'est facile. Tout le reste ? Plot twist.

Pool gagne, sauf si vous êtes régulé

TITLE "The Pool, Bridge, and Silo Blueprint" + subtitle "3 ways to split tenant data, 1 clear winner for most builders". Metaphor: 3 warehouses side by side, 1 giant shared warehouse with labeled shelves, 1 warehouse split into locked inner rooms, and a row of separate small warehouses. Style: cartoon 90s Hanna-Barbera/Nickelodeon, thick black outlines, halftone dots, bouncy rounded shapes. Palette: mustard #F4C430, hot pink #FF3E7F, sky blue #4FC3F7, cream #FFF8E7, black #111111. Content: 3 labeled warehouses, SILO (1 small building per tenant, tiny door, heavy padlock), BRIDGE (1 building, locked inner rooms labeled by tenant name), POOL (1 open warehouse floor, shelves color coded by tenant, 1 shared forklift). Highlight: POOL warehouse glowing with a gold outline and small sparkle stars, forklift labeled TENANT ID. Legend: sticky note bottom left, lock icon means strict isolation, shelf icon means shared and filtered. Footer: © rentierdigital.xyz bottom-right, small, handwritten. NOT flat corporate vector, NOT minimalist tech startup aesthetic.
Comparaison visuelle de trois modèles d'architecture de données multi-locataires

Il existe 3 façons de structurer une base de données partagée, et une seule fait sens pour la plupart des SaaS construits en solo.

  • Silo : 1 instance de base de données par locataire. Isolation complète, zéro risque que le client A jette un œil aux lignes du client B, et une courbe de coûts linéaire qui devient moche vers votre 50e client.
  • Bridge : 1 base de données, 1 schéma par locataire. Moins cher que Silo, mais chaque migration s'exécute maintenant sur autant de schémas que vous en avez, et Postgres n'a pas exactement été conçu pour héberger joyeusement des centaines de copies de la même structure de tables. Dépassez quelques milliers de locataires et le catalogue système lui-même commence à gémir sous le poids.
  • Pool : 1 jeu de tables, partagé par tout le monde, séparé par une colonne tenant_id.

Pool est la réponse ennuyeuse et c'est la bonne. La moins chère à faire tourner, un schéma à migrer, un pool de connexions à dimensionner. Le piège est le même que pour toute configuration de tables partagées : votre code doit se souvenir de filtrer par tenant_id sur chaque requête, à chaque fois, pour toujours. Oubliez ce filtre une fois, dans un job en arrière-plan que vous n'avez pas testé soigneusement, et le client A jette un œil aux factures du client B. Karen de la compta le remarque avant vous, et maintenant vous rédigez un rapport d'incident au lieu de livrer des fonctionnalités.

L'exception est réelle, pas théorique : santé, finance, tout domaine où un auditeur vous demandera éventuellement de prouver l'isolation au niveau infrastructure, pas seulement au niveau requête. Si c'est votre contexte, Silo cesse d'être paranoïaque et devient le prix d'entrée. Pour tous les autres, c'est Pool.

Le videur que Postgres a déjà construit

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);

Voilà Row-Level Security (RLS), la réponse intégrée de Postgres depuis 2016. 4 lignes, et maintenant la base de données elle-même refuse de renvoyer une ligne qui ne correspond pas au locataire actuel, peu importe ce que fait ou oublie de faire votre code applicatif. Voyez ça comme un videur posté à la couche requête : peu importe le charme de votre SELECT, si le tenant_id sur votre bracelet ne correspond pas, vous ne passez pas la porte.

Le mot-clé FORCE compte plus que les gens ne l'imaginent. Sans lui, le propriétaire de la table et tout rôle superutilisateur passent directement à travers la politique, ce qui arrive être le rôle sous lequel tournent la plupart des configurations de dev local et des scripts d'admin. Tester en tant que superutilisateur, c'est basiquement jouer en mode dieu : rien de ce que vous cassez n'apparaît dans l'exécution qui compte. J'ai découvert ça en passant 20 bonnes minutes à me demander pourquoi ma politique soi-disant blindée laissait chaque locataire voir les données de tout le monde, tout ça parce que je n'avais jamais quitté le mode dieu. Testez toujours avec le rôle applicatif réel, pas admin, parce qu'admin vous ment exactement de la façon que vous ne voulez pas pendant une démo.

Sans rapport, mais tant qu'on parle de mystères de base de données que je n'ai pas résolus : ma base de staging tombe en panne de connexions tous les dimanches, comme une horloge, et je ne déploie jamais rien ce jour-là. C'est la vie.

Encore un piège, et celui-ci vit au niveau du pooler de connexions. Si vous faites tourner PgBouncer ou quelque chose de similaire en mode transaction, définir le contexte locataire doit se faire à l'intérieur de la transaction, avec une portée SET LOCAL, pas un SET nu.

BEGIN;
SET LOCAL app.tenant_id = '3f29e1d2-91aa-4b3a-9d21-7e0dcb9a1234';
SELECT * FROM invoices;
COMMIT;

Un SET nu colle à la connexion, et les connexions sont recyclées par le pooler et remises à la requête suivante. Faites ça et la requête du locataire B peut hériter de la variable de session du locataire A, la politique RLS passe proprement, et vous avez construit exactement la fuite que RLS était censé empêcher. SET LOCAL meurt au COMMIT ou ROLLBACK, peu importe qui récupère la connexion ensuite.

RLS est une couche, pas toute la défense. Les bases DevOps qu'un agent IA a sautées avant d'effacer la prod comptent autant que toute politique qui traîne dans Postgres : identifiants à portée limitée, vrais environnements de staging, sauvegardes qui vivent ailleurs que sur la même machine, puisque RLS sépare les locataires mais n'a jamais été conçu pour sauvegarder quoi que ce soit.

1 index, 25 fois plus rapide

Sans index, chaque requête qui touche une table sous RLS force Postgres à vérifier la politique ligne par ligne, et s'il n'y a rien pour l'aider à réduire ça, il retombe sur un scan séquentiel. C'est l'équivalent base de données de moudre chaque combat aléatoire au lieu de prendre le point de téléportation direct vers le boss. Sur une table avec quelques milliers de lignes, c'est invisible. Sur une table avec quelques millions, le temps de chargement de votre tableau de bord se transforme en pause café.

CREATE INDEX idx_invoices_tenant_id ON invoices (tenant_id, created_at);

Ajoutez un index composite avec tenant_id comme colonne de tête et Postgres passe du scan de toute la table à un bitmap index scan, allant directement aux lignes qui comptent. Les benchmarks sur des tables d'un million de lignes placent la différence à 25 fois plus rapide, parfois plus, et la latence p95 de RLS lui-même tombe sous les 2%, assez proche de gratuit pour que ça cesse d'être un sujet de discussion.

La vérification de politique elle-même est bon marché : une simple comparaison d'égalité que Postgres sait déjà planifier. Le vrai coût se cache ailleurs, dans une décision du planificateur de requêtes que vous ne remarquez qu'une fois que vous la cherchez. C'est comme ça que les équipes finissent par livrer RLS, voir une requête lente apparaître en production 3 semaines plus tard, et passer un après-midi convaincues que le modèle de sécurité lui-même est le problème, alors que le vrai correctif était une instruction CREATE INDEX qui traînait là tout le temps.

Un détail de plus, petit mais coûteux si vous le sautez : si votre politique appelle une fonction personnalisée au lieu d'une simple comparaison de colonne, marquez cette fonction STABLE.

CREATE FUNCTION current_tenant() RETURNS uuid
  LANGUAGE sql STABLE
AS $$ SELECT current_setting('app.tenant_id')::uuid $$;

Laissez-la VOLATILE (le défaut) et Postgres la réévalue pour chaque ligne au lieu d'une fois par requête, ce qui défait silencieusement tout l'intérêt d'ajouter l'index en premier lieu.

Je pense que ça couvre la configuration RLS qui marche pour la grande majorité des SaaS en modèle Pool, même si j'admets ne pas être totalement sûr que ça tienne une fois que vous faites tourner des dizaines de politiques empilées par table. C'est un niveau de complexité que je n'ai pas encore personnellement atteint.

Prisma, Drizzle, ou zapper SQL entièrement

TITLE "The Prisma vs Drizzle Race" + subtitle "bundle size and cold start, side by side". Metaphor: 2 racers on a track, 1 hauling a heavy trailer, 1 on a light bike. Style: retro arcade 8-bit pixel art. Palette: mustard #F4C430, hot pink #FF3E7F, sky blue #4FC3F7, cream #FFF8E7, black #111111. Content: left racer labeled PRISMA pulling a trailer marked with a weight icon, right racer labeled DRIZZLE riding a light bike with a lightning bolt sticker, finish line labeled EDGE RUNTIME. Highlight: DRIZZLE racer glowing with speed lines and small lightning bolt sparkles. Legend: sticky note bottom left, trailer icon means compiled query engine, lightning icon means zero dependency runtime. Footer: © rentierdigital.xyz bottom-right, small, handwritten. NOT flat corporate vector, NOT stock infographic style.
Infographie de course comparant les performances de Prisma vs Drizzle

Prisma et Drizzle sont tous deux des ORM, la couche entre votre TypeScript et le SQL en dessous, et Prisma se lit comme du français courant tandis que l'autocomplétion vous fait vous sentir dangereux 🤓. La version 7 a enfin largué l'ancien binaire du moteur de requête Rust pour un compilateur WebAssembly, faisant chuter le bundle de 14MB à 1,6MB et corrigeant le problème de démarrage à froid qui le rendait pénible sur serverless pendant des années.

Drizzle saute presque entièrement l'abstraction. Les schémas sont du TypeScript pur, les requêtes ressemblent au SQL qu'elles génèrent, et le tout pèse entre 12KB et 57KB selon ce que vous importez. Le démarrage à froid atterrit à 50ms. Si vous déployez sur l'Edge (Cloudflare Workers, ce genre de runtime), Drizzle est le choix. Vous devrez vraiment connaître SQL pour bien l'utiliser, ce qui est soit une fonctionnalité soit un filtre selon d'où vous partez. Les jointures générées de Prisma se lisent parfois comme une réponse Stack Overflow, copiée vite et jamais relue.

Drizzle a aussi une vraie réponse au problème de câblage RLS : il vous laisse déclarer rôles et politiques directement dans le schéma TypeScript, et AsyncLocalStorage de Node porte le tenant_id depuis votre middleware jusqu'à la fonction de requête, sans le passer à travers chaque signature de fonction à la main. Les extensions Prisma peuvent simuler le même truc avec des wrappers de client, mais c'est boulonné. Drizzle a été conçu avec ça en tête.

Choisissez Prisma pour l'expérience de dev local la plus fluide si vous ne déployez pas sur l'Edge, choisissez Drizzle si vous êtes déjà à l'aise en SQL et que le démarrage à froid compte vraiment pour votre stack.

Supabase vous tend RLS et la facture ops

Supabase c'est Postgres en dessous, câblé à leur propre auth, stockage, et une couche API auto-générée (PostgREST) qui laisse votre frontend parler à la base de données presque directement. Cette dernière partie explique pourquoi RLS cesse d'être optionnel sur Supabase : votre app client peut toucher la base de données sans backend assis entre les deux, donc la politique est la seule chose qui se dresse entre le client A et les lignes du client B. Supabase tourne sur exactement la configuration RLS d'avant dans cet article, juste pré-câblée dans leur tableau de bord au lieu d'un fichier de migration que vous écrivez à la main.

Auto-héberger Supabase est réel et gratuit, ils publient la stack docker-compose complète. C'est aussi 12 conteneurs : Postgres, GoTrue pour l'auth, PostgREST, Realtime, Storage, Kong comme passerelle API, Studio pour le tableau de bord, et quelques autres selon ce que vous activez. Faire tourner la stack complète vous-même signifie devenir l'admin système de douze services qui étaient le problème de quelqu'un d'autre, ce qui est son propre boss final. La RAM grimpe vite, et chacun de ces 12 conteneurs veut ses propres mises à jour, son propre TLS, ses propres yeux dessus quand quelque chose tombe.

Pour une construction solo, le tier gratuit hébergé supprime tout ce calcul. L'auto-hébergement ne commence à payer qu'une fois qu'il y a une vraie raison derrière : règles de résidence des données, ou une facture hébergée qui a grandi au-delà de ce que le fardeau ops vous coûte en temps. (Supabase est exactement la stack que je détaille étape par étape dans Vibe Coding, For Real, si vous voulez la version guidée pour passer de cette première démo Next.js à quelque chose de vraiment livré.)

Convex zappe SQL et s'auto-héberge en 1 conteneur

Mon projet parallèle Convex n'a pas de schema.sql, pas de CREATE POLICY, pas de dossier migration. Les requêtes sont des fonctions TypeScript, le multi-tenant est un filtre que vous écrivez dans cette fonction, et les mises à jour temps réel sont livrées par défaut au lieu d'être une fonctionnalité que vous boulonnez. Le compromis est réel aussi : pas d'accès SQL brut signifie pas de plongeon dans psql quand quelque chose semble foireux, vous êtes entièrement dans le langage de requête de Convex ou vous êtes coincé.

Convex livre aussi un backend auto-hébergé open source, et le contraste avec la stack de Supabase est tout le pitch : 1 conteneur qui fait tourner le moteur de sync et la base de données, pas 12. Moins à mettre à jour, moins à surveiller, moins pour quoi être appelé. Le piège c'est la maturité, pas la complexité : Convex auto-hébergé est plus jeune, l'outillage communautaire autour est plus mince, et vous échangez la décennie de Supabase de "quelqu'un a déjà touché ce bug avant vous" pour un projet avec beaucoup moins de piste derrière lui.

Je me suis lancé à fond dans livrer un backend SaaS sans toucher SQL pour ce projet parallèle. Auto-hébergez Supabase et vous héritez d'une douzaine de conteneurs pour obtenir ce que leur tier gratuit vous tend déjà, et auto-hébergez Convex et vous héritez d'1 conteneur soutenant un projet qui n'a pas traversé autant de feux de production encore. Deux factures différentes pour le même instinct de posséder votre stack.

En fait, laissez-moi le dire différemment : aucune de ces 4 décisions n'est difficile prise individuellement. Pool, Bridge, Silo (un dev motivé atterrit sur la bonne réponse en 10 minutes avec un croquis sur serviette). Prisma ou Drizzle c'est un appel de 10 minutes aussi. Supabase, Convex, ou Postgres brut ? Choisissez-en un et avancez. La partie difficile c'est l'index composite que vous ajoutez ou pas, la fonction que vous marquez STABLE ou pas, le SET LOCAL que vous mettez dans la transaction ou pas. Le genre de détail dont aucun des 3 autres appels ne vous prévient jamais.

Votre base de données ne cache jamais vos erreurs d'architecture. Elle attend juste que le volume se pointe avant de vous envoyer la facture.

Ajoutez l'index avant qu'elle le fasse.

Sources

Cet article peut contenir des liens d'affiliation. Si vous cliquez dessus, je pourrais gagner une petite commission (ça ne vous coûte rien, et ça m'aide à continuer de livrer des articles de qualité chaque jour pour votre plaisir de lecture).