Plateforme d’apprentissage multi-tenant, modules activables par organisation.
- Rôle
- Lead dev full-stack & infra
- Période
- 2024 — aujourd’hui

1 100+
commits
multi
tenant, modules par organisation
R2
sauvegardes Postgres
2
migrations de plateforme
Contexte
Une plateforme passée par plusieurs générations (Appwrite, Directus, Logto, marque blanche) qu’il fallait consolider.
Le défi
Unifier la base de code, garder l’isolation entre organisations et réduire fortement les coûts d’hébergement.
- 01Dev
Monorepo NestJS + Next.js
API NestJS 11 / Prisma 6, front Next.js 16 / MUI / React Query, docs Docusaurus.
- 02Dev
Multi-tenant
Modules et routes activables par organisation, contrôle d’accès par rôle.
- 03Infra
Sortie du managé
Migration de Neon vers un Postgres auto-hébergé, sauvegardé vers Cloudflare R2.
- 04Infra
De Coolify à Dokploy
Déploiement standardisé sur la même PaaS que mes autres produits.
Architecture
Stack
- NestJS
- Prisma
- PostgreSQL
- Next.js
- React
- Stripe
- DDokploy
- Cloudflare (DNS, R2)
- Docker
Résultats
- ✦Coûts d’infrastructure réduits
- ✦Un seul monorepo au lieu de plusieurs générations
Le récit
Les captures ci-dessous viennent du monorepo lancé en local, sur une base remplie par le seed du backend : trois organisations fictives (TechCorp Academy, EduSoft Learning, PM Academy) et leurs comptes ADMIN, LEARNER, MENTOR et PARTNER. Seule la vitrine vient du site public.
Comment ça marche
1. Une organisation, un domaine

Civinat sert plusieurs organisations depuis une seule base de code. Le domaine fait office d’identifiant : civinat.com pour l’organisation principale, un sous-domaine *.civinat.com pour les autres. Traefik route tout le wildcard vers le même conteneur Next.js, et c’est le backend qui décide à quelle organisation appartient la requête.
2. Le tableau de bord de l’administrateur

Un ADMIN arrive sur un tableau de bord qui ne montre que son organisation. Le front envoie le domaine dans un header x-tenant-domain ; côté API, un middleware retrouve l’organisation et l’attache à la requête. Les rôles se posent par-dessus : ADMIN gère son organisation, SUPER_ADMIN passe par un domaine dédié et voit toutes les organisations.
3. Le contenu de l’organisation

Les modules, sous-modules, leçons et questions appartiennent à une organisation. L’import et l’export CSV existent parce que les organisations arrivent avec leurs propres contenus et qu’il fallait les charger sans tout ressaisir.
4. Les routes activables

C’est la fonctionnalité qui rend la plateforme vraiment multi-tenant : chaque route du parcours apprenant (tableau de bord, modules, coach, podcast, actualités…) a un interrupteur. Une route coupée disparaît de la barre latérale, et le middleware Next.js redirige un accès direct vers /access-denied. Une organisation qui ne vend pas de coaching n’a donc pas à montrer un onglet vide.
5. PWA et facturation, par organisation

Le manifest PWA n’est pas un fichier statique : manifest.webmanifest est une route Next.js qui lit le domaine et demande au backend le nom, les icônes (stockées sur R2) et les couleurs du thème de l’organisation. Chaque organisation s’installe donc sur un téléphone avec sa propre identité.

La facturation suit la même logique : chaque organisation a sa configuration Stripe, ses offres (les parcours et types de quiz qu’un abonnement débloque) et ses tarifs.
6. L’espace coach


Un MENTOR a son propre espace. Un assistant le guide au premier passage, puis il déclare ses créneaux hebdomadaires. Les apprenants ne peuvent réserver que sur ces créneaux ; le paiement des coachs passe par Stripe Connect.
Sous le capot
Le diagramme suit un apprenant qui ouvre /app/podcast. Le middleware Next.js lit le cookie tenantId et demande la configuration des routes à une route API interne, qui relaie l’appel au backend avec le domaine. Le backend retrouve l’organisation par son domaine, puis renvoie la liste des routes et leur état. Si la route demandée est désactivée, la page n’est jamais rendue.
Détail qui compte : le contrôleur NestJS utilise req.tenant.id, l’organisation résolue depuis le domaine, et pas l’identifiant passé dans l’URL. Changer l’id dans l’URL ne donne pas accès à la configuration d’une autre organisation.
// Get domain from header first (for multi-tenant), fallback to hostname
let domain =
req.headers['x-tenant-domain']?.toString() || req.hostname.split(':')[0];
if (!domain && this.defaultPublicTenantDomain) {
domain = this.defaultPublicTenantDomain;
}
const superAdminDomain = this.config.get<string>('SUPER_ADMIN_DOMAIN');
if (domain === superAdminDomain) {
return next();
}
// Look up tenant for the domain
let tenant = await this.prisma.organization.findUnique({
where: { domain },
select: { id: true, domain: true },
});
if (!tenant) throw new NotFoundException('Tenant not found');
req.tenant = tenant;
next();Le middleware tient en peu de lignes : le header d’abord, le hostname ensuite, une organisation publique par défaut, et une sortie anticipée pour le domaine super-admin. Si aucune organisation ne correspond, la requête s’arrête en 404 avant d’atteindre un contrôleur.
Cette session vient de la mise en route locale pour ces captures. Le health check répond, mais rejouer toutes les migrations sur une base vide échoue : l’historique Prisma ne se rejoue plus depuis zéro. J’ai dû passer par prisma db push, puis adapter le seed, qui envoyait encore un champ ponderation retiré du schéma.
Ce que j’en retiens
- Le domaine comme clé de tenant est simple et lisible : une organisation, un sous-domaine, un header. Le prix, c’est un certificat wildcard et des DNS à tenir propres.
- Les routes activables valent mieux que des forks. Les générations précédentes (Appwrite, Directus, marque blanche) multipliaient les variantes. Un interrupteur par route a remplacé une bonne partie de ces copies.
- Un historique de migrations doit se rejouer. Tant que la production avance, on ne le voit pas. On le découvre le jour où on monte une base neuve, comme ici.
Galerie





Parlons-en. Décrivez votre besoin en quelques lignes, je reviens vers vous sous 48 h.