edtech / docs /railway_deployment_crash_postmortem.md
CognxSafeTrack
chore: finalize Sprint P2 & P3 optimizations, baseline prisma migrations, and update technical audit docs
cfbb685

Postmortem : Crash au déploiement Railway — 30 avril 2026

Résumé

Deux crashs successifs ont empêché le démarrage des processus api et whatsapp-worker en production sur Railway. Aucune régression fonctionnelle n'a eu lieu : le service n'a jamais démarré. Les deux bugs étaient liés à une mauvaise configuration de la chaîne de compilation TypeScript dans le monorepo.


Crash 1 — Cannot find package 'tsx'

Symptôme

Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'tsx' imported from /app/
Did you mean to import "tsx/dist/loader.mjs"?

PM2 redémarrait api et worker en boucle avec code de sortie 1.

Cause racine

Le fichier ecosystem.config.js configurait PM2 pour exécuter les fichiers source TypeScript directement, en passant --import tsx à Node.js :

// ecosystem.config.js (avant correction)
{
  script: 'apps/api/src/index.ts',
  interpreter: 'node',
  interpreter_args: '--import tsx',
}

tsx était déclaré comme devDependency dans les deux apps. En production, les devDependencies ne sont pas installées, donc tsx était introuvable au démarrage.

Le Dockerfile compilait déjà api via pnpm --filter api build (vers dist/), mais PM2 ignorait ce build et tentait de re-exécuter le source TypeScript — contradiction inutile.

Correction

ecosystem.config.js — pointer vers les fichiers compilés, sans tsx :

// Avant
{ script: 'apps/api/src/index.ts', interpreter_args: '--import tsx' }
{ script: 'apps/whatsapp-worker/src/index.ts', interpreter_args: '--import tsx' }

// Après
{ script: 'apps/api/dist/index.js' }
{ script: 'apps/whatsapp-worker/dist/index.js' }

Dockerfile — ajouter la compilation du worker (seul api était compilé) :

# Avant
RUN pnpm --filter api build

# Après
RUN pnpm --filter api build
RUN pnpm --filter whatsapp-worker build

Crash 2 — Cannot find module '.../packages/database/src/extension.js'

Symptôme

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/packages/database/src/extension.js'
imported from /app/packages/database/index.ts

Cause racine

Trois problèmes imbriqués dans le package @repo/database :

1. package.json pointait vers le source TypeScript

// packages/database/package.json (avant)
"main": "./index.ts",
"types": "./index.ts",
"exports": { ".": "./index.ts" }

Node.js chargeait index.ts directement — impossible sans tsx.

2. tsconfig.json héritait noEmit: true sans le surcharger

// packages/tsconfig/base.json
{ "noEmit": true }

// packages/database/tsconfig.json (avant)
{
  "extends": "../tsconfig/base.json",
  "compilerOptions": { "outDir": "dist", "rootDir": "." }
  // noEmit: true hérité — tsc ne produisait aucun fichier JS
}

Le Dockerfile n'appelait que prisma generate pour ce package, jamais tsc. Même si on l'avait appelé, aucun fichier n'aurait été émis.

3. Imports avec extension .js incompatibles avec moduleResolution: "node"

// packages/database/index.ts (avant)
export * from './src/extension.js'; // résolution node16 requise
export * from './src/context.js';

// packages/database/src/extension.ts (avant)
import { getOrganizationId } from './context.js';

La convention import './foo.js' pour référencer foo.ts est une pratique ESM/node16. Avec moduleResolution: "node" (celui des apps), TypeScript ne fait pas la substitution .js.ts et la résolution échoue à la compilation comme au runtime.

Correction

packages/database/tsconfig.json — activer l'émission et aligner sur le style CommonJS des apps :

{
  "extends": "../tsconfig/base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": ".",
    "noEmit": false,
    "declaration": true,
    "module": "CommonJS",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "allowImportingTsExtensions": false,
    "target": "ES2020",
    "lib": ["ES2020"]
  },
  "include": ["index.ts", "src/**/*"]
}

packages/database/index.ts — supprimer les extensions .js :

// Avant
export * from './src/extension.js';
export * from './src/context.js';

// Après
export * from './src/extension';
export * from './src/context';

packages/database/src/extension.ts — idem :

// Avant
import { getOrganizationId } from './context.js';

// Après
import { getOrganizationId } from './context';

packages/database/package.json — ajouter le script build, pointer vers dist/ :

{
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": { ".": "./dist/index.js", "./seed": "./src/seed.ts" },
  "scripts": {
    "build": "tsc --build",
    ...
  }
}

Dockerfile — compiler @repo/database après generate :

RUN pnpm --filter @repo/database generate
RUN pnpm --filter @repo/database build   # ajouté
RUN pnpm --filter @repo/shared-types build
RUN pnpm --filter @repo/prompts build

Ordre de build final dans le Dockerfile

prisma generate (@repo/database)
  → tsc (@repo/database)       → dist/index.js + dist/src/*.js
  → tsc (@repo/shared-types)   → dist/index.js
  → tsc (@repo/prompts)        → dist/index.js
  → tsc (api)                  → dist/index.js  (consomme les packages ci-dessus)
  → tsc (whatsapp-worker)      → dist/index.js  (idem)

PM2 démarre ensuite apps/api/dist/index.js et apps/whatsapp-worker/dist/index.js avec Node.js pur — aucune dépendance de dev requise au runtime.


Leçons retenues

# Règle
1 Tout package workspace consommé par une app compilée doit lui-même être compilé et pointer main vers dist/.
2 Vérifier que noEmit est explicitement surchargé à false dans tout package qui doit émettre des fichiers JS.
3 Ne jamais utiliser --import tsx ou tsx en production — compiler TypeScript à l'étape build, pas au runtime.
4 Les imports avec extension .js dans un fichier .ts requièrent moduleResolution: "node16" ou "nodenext". Avec "node", utiliser des imports sans extension.
5 Le Dockerfile doit compiler tous les packages et apps dans leur ordre de dépendance, pas seulement certains.