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