Files
_Assistant_Lead_Tech/knowledge/backend/patterns/general.md
MaksTinyWorkshop b3417ad77b capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)
Triage du 95_a_capitaliser.md (~75 propositions) :
- 60 entrées intégrées dans knowledge/ (backend, frontend, workflow)
- 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md,
  frontend/patterns/general.md, workflow/patterns/general.md
- 6 doublons rejetés
- Mise à jour des READMEs index pour refléter les nouvelles entrées
- 95_a_capitaliser.md restauré à sa structure initiale
- 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant
- 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI,
  prisma migrate diffs cosmétiques

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:12:44 +02:00

28 KiB

title: Backend — Patterns : Général domain: backend bucket: patterns tags: [auth, helpers, architecture, rbac] applies_to: [implementation, review, architecture] severity: medium validated_on: 2026-04-07 source_projects: [RL799_V2]

Backend — Patterns : Général

Extrait de la base de connaissance Lead_tech. Voir knowledge/backend/patterns/README.md pour l'index complet.


Pattern : Helper d'authentification centralisé enrichissable

  • Objectif : éviter la duplication de logique RBAC dans chaque service en centralisant un seul requireRoleAccess dans lib/authHelpers.ts.
  • Contexte : chaque nouveau service réimplémentait requireRoleAccess localement avec des variations subtiles (certains retournaient { email }, d'autres { email, role }).
  • Quand l'utiliser : dès qu'un endpoint nécessite une vérification de rôle ou d'authentification.
  • Validé le : 07-04-2026
  • Contexte technique : backend / RBAC — RL799_V2 epics 7-8-9

Règle

  • Un seul requireRoleAccess dans lib/authHelpers.ts, retournant toutes les infos du token utiles (email, role, sub).
  • Quand un service a besoin d'une info supplémentaire : enrichir le helper centralisé, pas le copier.
  • Le retour riche est rétrocompatible : les consommateurs existants utilisent ce dont ils ont besoin via destructuring.

Signal review

  • Tout service qui importe verifyToken directement ET fait son propre check RBAC est suspect de duplication.

Pattern : Extension d'un service existant par type avec RBAC granulaire

  • Objectif : éviter la duplication de service quand un nouveau besoin est fonctionnellement identique à un service existant mais avec des restrictions d'accès différentes.
  • Contexte : ajout d'un nouveau type de communication (ex: vm) avec restrictions RBAC spécifiques dans un service communications existant.
  • Quand l'utiliser : quand le nouveau besoin utilise le même modèle de données et le même CRUD que le service existant, seules les restrictions d'accès changent.
  • Quand l'éviter : quand le modèle de données diverge (champs spécifiques au nouveau type) — dans ce cas, un service dédié est préférable.
  • Validé le : 08-04-2026
  • Contexte technique : backend / architecture — RL799_V2 story 18-7

Règle

Préférer étendre le service avec un nouveau type (enum/Set) et ajuster les restrictions RBAC par type, plutôt que dupliquer le service.

Avantages

  • Un seul repository, un seul endpoint, même format de réponse
  • Tests existants couvrent la non-régression
  • Moins de code à maintenir

Signal review

  • Nouveau service qui réplique le CRUD d'un service existant avec un filtre additionnel → candidat à la fusion par type

Pattern : Builder les packages workspace en amont des tests CI (monorepo pnpm)

  • Objectif : garantir qu'un package workspace compilé (TypeScript → dist/) est construit avant que les apps consommatrices lancent leurs tests dans le CI.
  • Contexte : monorepo pnpm où apps/* consomme packages/<lib> via "workspace:*", et où le package.json du package pointe sur un build artifact ("main": "dist/index.js", "types": "dist/index.d.ts").
  • Quand l'utiliser : dans tout pipeline CI/CD qui lance des tests sur les apps consommatrices d'un package compilé.
  • Quand l'éviter : si tous les packages workspace sont consommés en source directement (TS sans build, ex. via tsx, vite-node, @swc-node).
  • Validé le : 30-04-2026
  • Contexte technique : pnpm monorepo / GitHub Actions / Node 22 / TypeScript — RL799_V2

Règle

Ajouter une étape Build workspace packages entre pnpm install et le premier pnpm run test:* du pipeline. Préférer un build complet du workspace plutôt qu'un build ciblé : pnpm -r --filter '<scope>/*' build (ou pnpm run build si un script racine équivalent existe). pnpm -r respecte le graphe de dépendances et bâtit les libs avant les apps.

Anti-pattern à éviter

Ne pas placer le build du package partagé dans la commande de tests de ce package ("test:shared": "pnpm -C packages/shared build && pnpm -C packages/shared test"). Si le script test:shared tourne après test:api dans la séquence CI, le build arrive trop tard pour les tests qui consomment dist/. Garder le build comme étape CI explicite et amont, et garder test:<package> minimaliste.

Signal review

  • Pipeline CI qui enchaîne pnpm install puis directement pnpm run test:api (ou équivalent) sans étape de build intermédiaire, alors que le package workspace consommé pointe sur un dist/ compilé.
  • Bug type : Cannot find module '<scope>/<package>' ou Cannot find module '<scope>/<package>/dist/...' au démarrage des tests CI, alors que les tests passent en local.

Pourquoi ce bug est silencieux en local

En local, dist/ existe presque toujours (build précédent) — donc les tests passent. Le pipeline CI part d'un environnement vierge et casse. Test de fumée local avant un push CI sensible : rm -rf packages/<lib>/dist && pnpm run build && pnpm run test:<app>.


Pattern : Ordre canonique des gates dans un handler HTTP

  • Objectif : éviter les fuites d'information via les codes HTTP — un user non authentifié ne doit jamais distinguer "ressource n'existe pas" de "ressource existe mais non autorisée".
  • Contexte : tout handler HTTP qui combine validation de payload, authentification, autorisation, vérification d'existence de ressource et gates métier.
  • Quand l'utiliser : systématique sur tout handler HTTP exposé.
  • Quand l'éviter : jamais.
  • Avantage :
    • la sémantique HTTP reste cohérente entre endpoints
    • aucune énumération possible via les codes 4xx
  • Limites / vigilance :
    • test "non auth + payload problématique → 401, pas 400" obligatoire pour verrouiller l'ordre
  • Validé le : 27-04-2026
  • Contexte technique : Next.js / NestJS / API HTTP — RL799_V2

Ordre canonique (du plus permissif au plus restrictif)

  1. Parsing du body (400 VALIDATION_ERROR si malformé)
  2. Validation du schéma (400 VALIDATION_ERROR si payload invalide)
  3. Auth (401 si non authentifié)
  4. Autz (403 si rôle insuffisant)
  5. Existence ressource (404 si l'id n'existe pas)
  6. Gates métier (400/403 si règle business violée)
  7. Mutation

Anti-pattern

// ❌ Gate métier AVANT auth — leak l'existence de la route + de la règle
if (payload.contains_locked_field) return 400 LOCKED;
const auth = requireAuth(...); // jamais atteint si payload contient le champ

// ✅ Auth AVANT gate métier
const auth = requireAuth(...);
if (auth instanceof Response) return auth;
if (payload.contains_locked_field) return 400 LOCKED;

Test associé

Ajouter systématiquement un test non auth + payload problématique → 401, pas 400. Sans ce test, la régression passe.


Pattern : Délégation au niveau d'un agrégat → endpoint agrégé serveur

  • Objectif : éviter les cascades de 403 silencieux côté client quand un user "délégué" doit accéder à des entités liées hors de l'agrégat parent.
  • Contexte : user avec un rôle "délégué" rattaché à un agrégat parent (Soiree.secretaireDeSeanceId), qui doit pouvoir lire/écrire sur des entités liées au-delà de l'agrégat (tenues précédentes du même grade).
  • Quand l'utiliser : la délégation ouvre l'accès à plusieurs entités liées qui sont rendues ensemble dans une vue unique.
  • Quand l'éviter : si le frontend a vraiment besoin des entités séparément (rare).
  • Avantage :
    • une seule réponse hydrate toute la vue → pas de cascade de 403
    • guard centrale au niveau du parent réutilisée en lecture ET en écriture
    • source de vérité unique de la "fenêtre légitime" (un repo helper)
  • Limites / vigilance :
    • les codes d'erreur restent standards (403 FORBIDDEN), pas de codes ad hoc FORBIDDEN_OUT_OF_DELEGATION_SCOPE
  • Validé le : 27-04-2026
  • Contexte technique : Next.js / API HTTP — RL799_V2

Le pattern

  1. Endpoint agrégé côté serveur : GET /api/<parent>/[id]/<vue-agrégée> qui hydrate en une seule réponse toutes les entités liées dont la vue a besoin.
  2. Guard centrale au niveau du parent : requireAccessForParent(request, parentId, { roleSet }) retourne { userId, role, viaDelegation, delegatedParentId? } ou Response 403/404.
  3. Si la guard sur l'entité enfant doit aussi reconnaître la délégation (cas d'écriture) : étendre avec un slow-path qui appelle la résolution serveur — « cet enfant fait-il partie de la fenêtre légitime ouverte par la délégation ? ». Pas de re-vérification dispersée dans chaque service.
  4. Single source of truth de la "fenêtre légitime" : la même fonction de résolution est utilisée par l'endpoint agrégé (lecture) ET par la guard d'écriture.

Ce qu'on évite

  • Cascade de N requêtes côté client → autant de chances de 403/erreurs silencieuses
  • Logique métier dupliquée dans la guard et dans le service de lecture (dérive garantie)

Pattern : Anti-énumération sur DELETE — 204 systématique

  • Objectif : empêcher un user authentifié d'énumérer les ressources d'autres users via les codes HTTP du DELETE (204 vs 404 fuite l'existence).
  • Contexte : endpoint DELETE qui révoque/supprime une ressource identifiée par un identifiant connu uniquement de son propriétaire (push endpoint, refresh token, magic link, OAuth state).
  • Quand l'utiliser : la ressource est identifiée par un secret/identifiant non énumérable.
  • Quand l'éviter : ressource identifiée par un ID séquentiel public — fixer d'abord l'autorisation.
  • Avantage :
    • aucune information ne fuit (inconnue / à un autre / déjà révoquée → indistinguables)
    • simplifie le code : pas de branchement 404 vs 204
    • idempotence naturelle (rejouer un DELETE n'a aucun effet observable)
  • Limites / vigilance :
    • garder une validation de format en amont (400 si body malformé) — c'est le format qui ne fuit rien, pas l'existence
    • le filtre SQL DOIT inclure userId = currentUser — sinon on révoque la ressource d'un autre user
  • Validé le : 28-04-2026
  • Contexte technique : API HTTP / Prisma — RL799_V2

Implémentation

export const handleDelete = async (request: Request): Promise<Response> => {
  const auth = requireAuthenticatedUser(request, ROLES);
  if ('status' in auth) return auth;

  const parsed = bodySchema.safeParse(await request.json());
  if (!parsed.success) {
    return errorResponse(400, 'VALIDATION_ERROR', 'Body invalide');
  }

  try {
    await revokeByOwner({ userId: auth.userId, ...parsed.data });
  } catch {
    /* logger côté serveur, retourner 204 quand même */
  }

  // 204 systématique : inconnu / autre user / déjà révoqué → indistinguables
  return new Response(null, { status: 204 });
};

// Repository
export const revokeByOwner = async (input: {
  userId: string;
  endpoint: string;
}): Promise<void> => {
  await prisma.subscription.updateMany({
    where: {
      userId: input.userId,    // filtre propriétaire OBLIGATOIRE
      endpoint: input.endpoint,
      revokedAt: null,         // idempotent
    },
    data: { revokedAt: new Date() },
  });
};

Anti-patterns

  • findUnique + if (!sub) return 404 : fuit l'existence
  • findUnique + check userId === auth.userId + 403 : fuit l'existence (la 403 vs 404 vs 204 distingue)
  • prisma.delete({ where: { endpoint } }) sans filtre userId : un user peut révoquer la sub d'un autre

Tests minimaux

DELETE inconnu / DELETE autre user / DELETE déjà révoqué → tous 204, état inchangé pour les autres users.


Pattern : Lazy init memoizé pour libs avec config globale

  • Objectif : initialiser une lib qui exige une config globale (clés API, credentials) seulement au premier appel utile pour permettre feature flag, tests isolés et démarrage gracieux sans env complet.
  • Contexte : libs externes type web-push.setVapidDetails, Stripe(secret), S3Client({ region }), Resend(apiKey).
  • Quand l'utiliser : lib avec init globale + feature flag possible + tests qui doivent reset l'état.
  • Quand l'éviter : lib qui réclame son init au boot (ex : connexion persistante TCP), libs trivialement instanciables par appel.
  • Avantage :
    • le service ne crash pas au boot si l'env n'est pas encore configuré
    • feature flag respecté de bout en bout (isEnabled() court-circuite avant init)
    • tests parallèles peuvent reset l'état via __resetForTests()
    • setX n'est appelé qu'une fois par process (memoization)
  • Limites / vigilance :
    • state module-level partagé entre tous les imports — bien isoler la lib derrière un service
    • __resetForTests doit être gardé par NODE_ENV === 'test' pour éviter l'usage en prod
  • Validé le : 28-04-2026
  • Contexte technique : Node.js / SDK avec config globale — RL799_V2

Implémentation

let initialized = false;

const isEnabled = (): boolean =>
  process.env.FEATURE_FLAG === 'true' &&
  Boolean(process.env.API_KEY && process.env.SECRET);

const ensureInit = (): boolean => {
  if (initialized) return true;
  if (!isEnabled()) return false;
  externalLib.configure({
    apiKey: process.env.API_KEY!,
    secret: process.env.SECRET!,
  });
  initialized = true;
  return true;
};

export const callExternal = async (input: Input): Promise<void> => {
  if (!ensureInit()) return; // no-op silencieux si flag off ou env manquant
  await externalLib.send(input);
};

/** Reset interne — usage tests uniquement. */
export const __resetInitForTests = (): void => {
  if (process.env.NODE_ENV !== 'test') return;
  initialized = false;
};

Checklist

  • isEnabled() vérifie flag ET présence env complète
  • ensureInit() retourne booléen, no-op silencieux si non activé
  • __resetForTests gardé par NODE_ENV === 'test'
  • Pas de log "skipped" en cas de flag off (silencieux par design — c'est le contrat du flag)

Pattern : Cap LRU sur ressources par-user avec contrainte d'unicité externe

  • Objectif : empêcher un user (malveillant ou bugué) de générer un nombre illimité de ressources par-user en exploitant l'unicité d'un identifiant externe.
  • Contexte : ressources où chaque insert produit une row unique côté DB (push subscription endpoint, refresh token, device fingerprint, OAuth state).
  • Quand l'utiliser : ressource sans limite naturelle côté usage, où chaque action utilisateur peut créer une nouvelle row.
  • Quand l'éviter : ressource intrinsèquement bornée (1 par user — utiliser une PK composite), ou ressource où l'historique compte (audit, logs).
  • Avantage :
    • cap dur prévisible (10 actives max, p.ex.) — borne supérieure de coût stockage connue
    • LRU eviction naturelle : les anciennes subs (devices oubliés, browsers réinstallés) sont nettoyées automatiquement
    • pas besoin de TTL global, le user peut garder ses N appareils légitimes
  • Limites / vigilance :
    • faire le check + révocation avant l'insert, pas après (sinon unique constraint violation possible si race)
    • choisir entre revoked (soft delete) et delete selon les besoins audit
    • le cap doit être au-dessus de l'usage légitime max (10 pour push = laptop + perso + pro + mobile + tablette + marges)
  • Validé le : 28-04-2026
  • Contexte technique : Prisma — RL799_V2

Implémentation

const MAX_ACTIVE_PER_USER = 10;

export const handleCreate = async (userId: string, input: CreateInput) => {
  const active = await prisma.resource.count({
    where: { userId, revokedAt: null },
  });

  if (active >= MAX_ACTIVE_PER_USER) {
    const oldest = await prisma.resource.findFirst({
      where: { userId, revokedAt: null },
      orderBy: { lastSeenAt: 'asc' },
      select: { id: true },
    });
    if (oldest) {
      await prisma.resource.update({
        where: { id: oldest.id },
        data: { revokedAt: new Date() },
      });
    }
  }

  return prisma.resource.upsert({
    where: { externalKey: input.externalKey },
    create: { userId, ...input },
    update: { userId, revokedAt: null, lastSeenAt: new Date() },
  });
};

Checklist

  • Cap (constante) défini + commenté avec justification du chiffre
  • Eviction LRU faite avant l'insert
  • lastSeenAt bumpé à chaque usage légitime, pas juste à la création
  • Test : seed cap-1 actives + 1 nouvel insert → cap respecté, plus ancienne révoquée

Pattern : Convention dot-notation pour audit events

  • Objectif : aligner le nommage des audit events avec la convention des outils observables (segment.io, datadog, posthog) qui utilisent tous la dot notation.
  • Contexte : projet avec un AUDIT_ACTION_CATALOG ou équivalent listant les actions auditées.
  • Quand l'utiliser : tout nouvel audit event, et migration progressive des events legacy en colon (document:delete).
  • Quand l'éviter : projets dont l'outil d'observabilité impose un autre séparateur.
  • Avantage :
    • cohérence inter-outils (segment.io, datadog, posthog)
    • lecture humaine plus fluide (document.soft_delete > document:soft-delete)
    • multi-niveaux possible (planche.tronc.admin_override) sans confusion avec un séparateur de namespace JS
  • Limites / vigilance :
    • migration en PR atomique (call sites + catalog ensemble) pour éviter les events orphelins en filtrage UI
  • Validé le : 20-04-2026
  • Contexte technique : audit / observabilité — RL799_V2

Convention

  • Préférer la dot notation : <entity>.<action_detail> (ex : document.update_metadata, document.soft_delete, cotisation_payment.created)
  • Legacy colon (document:delete) toléré pour rétrocompatibilité — migration encouragée lors du prochain touchement du module concerné

Comment migrer

  1. Vérifier qu'il n'y a plus de call site (grep -rn 'document:delete')
  2. Retirer l'entrée du AUDIT_ACTION_CATALOG
  3. Si des call sites existent, les migrer en même temps que le retrait (PR atomique)

Pattern : Whitelist explicite pour audit metadata fields

  • Objectif : empêcher qu'un futur dev ajoutant un champ secret au schema (backupPassword, apiToken) ne le voie automatiquement loggé dans le journal d'audit.
  • Contexte : PATCH d'admin qui logge metadata: { fields: Object.keys(payload) } pour tracer ce qui a changé.
  • Quand l'utiliser : tout audit qui veut tracer "quels champs ont été modifiés".
  • Quand l'éviter : audit qui ne logge que l'action et l'id cible (pas les champs).
  • Avantage :
    • pas de fuite silencieuse dans le journal d'audit
    • satisfies readonly (keyof typeof baseShape)[] garantit que la whitelist ne peut pas contenir de champ inexistant (typo-safe)
  • Limites / vigilance :
    • test "PATCH multi-champs valides → tous présents dans metadata.fields" pour vérifier que la whitelist couvre 100 % des champs PATCH-ables légitimes (silence par omission, pas de fuite)
  • Validé le : 27-04-2026
  • Contexte technique : audit / observabilité — RL799_V2

Implémentation

// packages/shared/src/validation/<entity>Schemas.ts
export const AUDITABLE_LODGE_SETTINGS_FIELDS = [
  'nameLong',
  'nameShort',
  // … uniquement les champs SAFE à logger
] as const satisfies readonly (keyof typeof baseShape)[];

// Côté service
const fields = AUDITABLE_LODGE_SETTINGS_FIELDS.filter((k) => k in payload);
await logActionSync(tx, userId, '<entity>.update', '<entity>', id, { fields });

Test obligatoire

test('AUDITABLE_<X>_FIELDS couvre tous les champs PATCH-ables légitimes', () => {
  const allPatchableFields = Object.keys(updateXxxSchema.shape);
  const sensitiveFields = ['secretToken', 'backupPassword'];
  const expected = allPatchableFields.filter((k) => !sensitiveFields.includes(k));
  expect([...AUDITABLE_X_FIELDS]).toEqual(expected);
});

Pattern : Singleton DB pour config globale d'instance

  • Objectif : stocker une configuration applicative qui doit être éditable runtime, unique pour l'instance, et protégée contre toute création accidentelle de doublon.
  • Contexte : application mono-tenant déployable où chaque instance a sa propre DB et expose une config globale (paramètres de l'instance, branding, identité).
  • Quand l'utiliser : config (a) en DB pour être éditable runtime sans rebuild, (b) unique pour l'instance, (c) protégée par contrainte DB.
  • Quand l'éviter : SaaS multi-tenant — chaque tenant a sa propre row.
  • Avantage :
    • le CHECK SQL est la dernière ligne de défense même si un dev contourne le repo
    • le repo centralise le where: { id: 'singleton' } — première ligne d'abstraction
  • Limites / vigilance :
    • @default("singleton") seul ne suffit pas — un dev peut créer une row avec id: 'other'
    • cache mémoire à invalider AVANT mutation (cf. pattern pattern-invalidation-cache-avant-mutation)
  • Validé le : 27-04-2026
  • Contexte technique : Prisma / Postgres — RL799_V2

Implémentation

model LodgeSettings {
  id        String @id @default("singleton")
  // …champs
  @@map("lodge_settings")
}

Garde-fou SQL au niveau migration (édition manuelle du .sql après prisma migrate dev --create-only) :

ALTER TABLE "lodge_settings"
  ADD CONSTRAINT "lodge_settings_singleton_check"
  CHECK (id = 'singleton');

Repository centralisé : tout accès passe par getXxx() / updateXxx() qui prennent un client transactionnel optionnel pour permettre l'atomicité mutation + audit. Jamais de prisma.xxx.create() direct depuis ailleurs.

Tests d'intégration

test('rejette la création d\'une 2e row', async () => {
  await expect(
    prisma.lodgeSettings.create({ data: { id: 'other', ... } }),
  ).rejects.toThrow('lodge_settings_singleton_check');
});

Pattern : Invalidation cache mémoire AVANT mutation atomique

  • Objectif : éviter qu'un GET parallèle pendant la transaction re-cache l'ancienne valeur jusqu'à expiration TTL.
  • Contexte : config en cache mémoire (settings, feature flags, catalog) modifiée par une mutation atomique.
  • Quand l'utiliser : tout flow cache + mutation + audit où le cache vit dans le même process que la mutation.
  • Quand l'éviter : cache distribué Redis avec SETEX — la TTL gère naturellement.
  • Avantage :
    • cohérence forte (au pire, GET parallèle bloque sur fetch DB → trade-off latence acceptable)
    • pas de fenêtre où le client a vu une valeur déjà obsolète après ACK serveur
  • Limites / vigilance :
    • garde-fou cacheVersion (compteur incrémenté à chaque invalidate()) recommandé : tout fetch en vol capture la version au démarrage et n'écrit le cache que si la version est inchangée au retour
  • Validé le : 27-04-2026
  • Contexte technique : Node.js / cache mémoire — RL799_V2

Séquence sûre

1. invalidateCache()       // AVANT toute mutation
2. transaction {           // Prisma $transaction
     update(...)
     logActionSync(tx, ...)
   }
3. return                  // Le prochain GET re-fetch fresh DB

Pourquoi avant et pas après

Si on invalide après mutation :

  1. GET parallèle pendant la transaction hit le cache (ancienne valeur) → return cachedValue
  2. Si la transaction commit, le cache contient l'ancienne valeur jusqu'à TTL
  3. Le client a vu une valeur déjà obsolète après ACK serveur → race condition

Si on invalide avant mutation :

  1. Cache vide pendant la transaction
  2. GET parallèle fait un fetch DB (peut renvoyer l'ancienne ou la nouvelle selon timing)
  3. Au pire, GET parallèle bloque sur fetch DB → cohérence forte

Anti-pattern à éviter

  • Auto-chaînage entre invalidations couplées de caches dont les timings doivent diverger (ex : cache settings DTO à invalider AVANT la mutation, cache logo filesystem à invalider APRÈS pour éviter un re-cache d'un fichier supprimé). Chaque cache expose son invalidate() propre, le caller décide explicitement du moment.

TTL recommandé

TTL court (60 s) plutôt que long (5 min) : fenêtre de stale plus courte si plusieurs admins éditent.


Pattern : Pipeline CI/CD GitHub Actions → VPS (compose externe + GHCR + SSH)

  • Objectif : déployer automatiquement un monorepo Node + Postgres + Docker à chaque merge sur main vers un VPS hébergeant déjà des stacks globales (Traefik + Postgres).
  • Contexte : VPS multi-apps avec Traefik global + Postgres global sur réseaux Docker externes (traefik, stack). Repo GitHub privé, image GHCR privée, user SSH dédié deploy (pas superuser).
  • Quand l'utiliser : projet ≥ 1 app + ≥ 1 service partagé sur un VPS, sans Kubernetes ni service externe (pas de Vercel/Render/Railway).
  • Quand l'éviter : déploiement sur un seul app/VPS dédié (compose simple suffit), ou besoin de blue/green strict (k8s ou Nomad plus adaptés).
  • Avantage :
    • réutilisation des stacks Traefik + Postgres existantes via réseaux externes
    • pas de superuser, juste membre du groupe docker
    • migrations exécutées AVANT le redémarrage applicatif (séquence pull → migrate → up -d)
  • Limites / vigilance :
    • plan GitHub gratuit pour repos privés : pas de gating manuel possible — compenser par workflow_dispatch only sur le job deploy
    • pnpm run <script> dans le service migrate peut casser si le script dépend de scripts/ non embarqué dans l'image — appeler les binaires directement
  • Validé le : 02-05-2026
  • Contexte technique : pnpm monorepo / Next.js / Vue / Prisma / Postgres / Docker — RL799_V2

compose.vps.yml — compose dédié CI/CD

services:
  api:
    image: ${API_IMAGE:?API_IMAGE is required}
    env_file: [.env]
    networks: [app_net, stack_net, traefik_net]
    labels:
      - traefik.enable=true
      - traefik.http.routers.api.rule=Host(`${APP_DOMAIN}`) && PathPrefix(`/api`)

  frontend:
    image: ${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}
    networks: [app_net, traefik_net]

  migrate:
    image: ${API_IMAGE:?API_IMAGE is required}
    profiles: [ops]
    # Appel DIRECT du binaire Prisma — éviter `pnpm run prisma:migrate` qui
    # exécute scripts/check-node-version.mjs absent de l'image API
    command: ['pnpm', '-C', 'apps/api', 'exec', 'prisma', 'migrate', 'deploy']
    networks: [stack_net]

networks:
  traefik_net:
    external: true
    name: traefik
  stack_net:
    external: true
    name: stack

Workflow CI/CD en 2 jobs

  • build-and-push : docker buildx → push image sur GHCR (tag = SHA court)
  • deploy : SSH au VPS, scp compose.vps.yml, pull + migrate + up -d + healthcheck retries

Trigger : workflow_run du workflow Tests quand vert sur main, OU workflow_dispatch manuel.

Secrets GitHub à configurer

Secret Rôle Exemple
VPS_HOST hostname réel (pas alias ~/.ssh/config) 82.x.x.x
VPS_USER deploy
VPS_SSH_PORT port custom éventuel 2287
VPS_SSH_PRIVATE_KEY clé privée multi-lignes -----BEGIN OPENSSH...
VPS_SSH_KNOWN_HOSTS ssh-keyscan -p <port> <host> 3 lignes
VPS_APP_DIR chemin app sur VPS /srv/sites/<app>

Pièges anticipés

  1. pnpm run <script> dans le service migrate : si le script appelle un wrapper (pnpm run env:node) qui exécute scripts/check-node-version.mjs, et que scripts/ n'est pas embarqué dans l'image API → 500 au boot du job migrate. Toujours appeler les binaires directement dans les services Docker.

  2. Image GHCR privée + docker login côté VPS : credentials par-user (~/.docker/config.json). Faire docker login en tant que deploy via SSH, pas via sudo -u deploy.

  3. Hostname réel vs alias ~/.ssh/config : VPS_HOST doit être l'hostname résolu (ssh -G <alias> | grep ^hostname), pas l'alias.

  4. Permissions dossier /srv/sites/<app>/ : si owner historique = autre user, deploy ne peut pas scp. Solution propre = groupe partagé avec setgid.

  5. Healthcheck timeout : si l'API met longtemps à boot (Chromium/Puppeteer lazy load, migrations longues), augmenter au-delà du défaut 60 s.