Aller au contenu
← ProjetsDevInfraEn production

Plateforme d’apprentissage multi-tenant, modules activables par organisation.

Rôle
Lead dev full-stack & infra
Période
2024 — aujourd’hui
Civinat

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.

  1. 01Dev

    Monorepo NestJS + Next.js

    API NestJS 11 / Prisma 6, front Next.js 16 / MUI / React Query, docs Docusaurus.

  2. 02Dev

    Multi-tenant

    Modules et routes activables par organisation, contrôle d’accès par rôle.

  3. 03Infra

    Sortie du managé

    Migration de Neon vers un Postgres auto-hébergé, sauvegardé vers Cloudflare R2.

  4. 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

Page d’accueil publique de civinat.com : « Réussi ton entretien de naturalisation »
La vitrine publique civinat.com : c’est le point d’entrée de l’organisation principale, servie par le même Next.js que l’app.

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

Tableau de bord administrateur de l’organisation de démo PM Academy
Le tableau de bord d’un ADMIN : tout ce qu’il voit est limité à son organisation, ici PM Academy, résolue depuis le domaine.

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

Liste des modules de formation avec import et export CSV
Les modules propres à l’organisation, avec import et export CSV : un nouveau client arrive rarement sans contenu existant.

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

Écran « Accès aux routes apprenant » avec un interrupteur par route
Un interrupteur par route apprenant : couper « Podcast » retire l’entrée du menu et bloque l’accès direct à l’URL.

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

Configuration PWA d’une organisation, avec l’état de la configuration
Chaque organisation a sa propre PWA : nom, icônes et couleurs alimentent un manifest généré par domaine.

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é.

Écran Facturation : configuration Stripe, offres et gestion des tarifs
La facturation est réglée par organisation : clés Stripe, offres (ce qu’un abonnement débloque) et tarifs.

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

Assistant d’onboarding coach en plusieurs étapes
À sa première connexion, un MENTOR passe par un onboarding : profil, disponibilités, puis mise en ligne.
Espace coach : gestion des disponibilités hebdomadaires
L’espace coach : les créneaux définis ici sont les seuls que les apprenants peuvent réserver.

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

Comment une route coupée est bloquée

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.

backend/src/common/middleware/tenant.middleware.ts (extrait)
// 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.

backend, base locale vide

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

Page d’accueil publique de civinat.com : « Réussi ton entretien de naturalisation »
Liste des modules de formation avec import et export CSV
Configuration PWA d’une organisation, avec l’état de la configuration
Assistant d’onboarding coach en plusieurs étapes
agent-setup
Projet suivant →agent-setupLe premier orchestrateur multi-agents, pour Claude Code et Codex.

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