📐 Construire une app en JS
47. Mettre en production

Mettre en production

Notre client attend depuis le chapitre 19. Mettons ce projet en ligne.

Ce chapitre ne recommandera aucun hébergeur. Ils changent, et le choix dépend de votre équipe. Il détaille ce dont l'application a besoin, et ce qui casse quand on l'oublie.

Notre besoin tient en quatre lignes : un processus Node, une base PostgreSQL, un endroit pour servir des fichiers statiques, un certificat HTTPS. Tout ce qui fournit ces quatre choses fera l'affaire.

La configuration se lit une fois, et elle échoue vite

Le chapitre 40 posait déjà la règle : un seul fichier lit process.env. Complétons-le.

// api/config.js
export function readConfig(env) {
  const required = ["DATABASE_URL", "SESSION_SECRET", "PUBLIC_URL", "SMTP_URL"];
  const missing = required.filter((key) => !env[key]);
  if (missing.length > 0) {
    throw new Error(`Missing configuration: ${missing.join(", ")}`);
  }
  return {
    port: Number(env.PORT ?? 3000),
    databaseUrl: env.DATABASE_URL,
    sessionSecret: env.SESSION_SECRET,
    baseUrl: env.PUBLIC_URL,
    smtpUrl: env.SMTP_URL,
    logLevel: env.LOG_LEVEL ?? "info",
  };
}

Le throw au démarrage est volontaire, et c'est le point important : une variable manquante doit empêcher le serveur de démarrer, pas produire un undefined qui se manifestera trois heures plus tard sur une route peu fréquentée.

Une application qui démarre à moitié configurée est plus dangereuse qu'une application qui ne démarre pas. La première sert du trafic en étant cassée ; la seconde déclenche une alerte immédiate et un retour à la version précédente.

Trois habitudes qui vont avec :

Aucun secret dans le dépôt. Ni .env versionné, ni clé dans un fichier de configuration. Un .env.example avec les noms des variables et des valeurs bidons, oui.

Un secret exposé est un secret révoqué. Pas "supprimé du commit" : git garde l'historique, et les robots qui scrutent GitHub sont plus rapides que vous. On le change.

Attention au frontend. Vite n'expose au navigateur que les variables préfixées par VITE_. C'est une protection, pas un coffre-fort : tout ce qui est préfixé se retrouve en clair dans le fichier JavaScript public. Une clé d'API secrète dans un VITE_STRIPE_SECRET_KEY est publiée au monde entier au premier déploiement.

Les migrations passent avant le code

L'ordre d'un déploiement n'est pas négociable :

1. appliquer les migrations
2. démarrer la nouvelle version
3. arrêter l'ancienne

Et il pose immédiatement un problème, parce qu'entre l'étape 1 et l'étape 3, l'ancien code tourne sur le nouveau schéma.

Renommer une colonne price en price_cents en une migration, c'est planter toutes les requêtes de la version encore en service. Quelques secondes de panne, ou quelques minutes si le déploiement se passe mal — c'est-à-dire au pire moment.

D'où la seule règle qui compte :

Une migration doit être compatible avec la version précédente du code.

On procède donc en deux temps — ce qu'on appelle étendre puis contracter :

Étendre. On ajoute price_cents, on la remplit, on écrit dans les deux colonnes. Les deux versions du code fonctionnent. On déploie.

Contracter. Au déploiement suivant, quand plus personne ne lit price, on la supprime.

Deux déploiements au lieu d'un, pour zéro seconde d'indisponibilité. C'est le bon échange.

Et rappelez-vous la mise en garde du chapitre 36 : un alter table verrouillant sur une grande table est une panne, même s'il est "instantané" en développement sur mille lignes.

S'arrêter proprement

Au déploiement, l'orchestrateur envoie SIGTERM au processus. Par défaut, Node s'arrête net.

Net, c'est-à-dire : au milieu d'une transaction, entre le save et le commit, ou juste après le commit mais avant la publication des événements du chapitre 35.

const server = app.listen(config.port);
 
async function shutdown(signal) {
  logger.info({ signal }, "shutting down");
  server.close();                        // on n'accepte plus de nouvelle connexion
  await waitForInFlightRequests(5000);   // on laisse finir celles en cours
  await dependencies.close();            // on ferme le pool de connexions
  process.exit(0);
}
 
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

Trente lignes qui évitent une catégorie entière d'incidents inexplicables : les erreurs qui n'arrivent qu'au moment des déploiements, et que personne ne parvient jamais à reproduire.

Notez l'ordre : on refuse les nouvelles requêtes avant de terminer les anciennes. L'inverse ne converge jamais.

Deux sondes, pas une

router.get("/health", (request, response) => response.status(200).json({ ok: true }));
 
router.get("/ready", async (request, response) => {
  try {
    await db.query("select 1");
    response.status(200).json({ ok: true });
  } catch {
    response.status(503).json({ ok: false });
  }
});

La distinction est subtile et elle compte.

/health (liveness) répond : ce processus est-il vivant ? S'il échoue, on redémarre le conteneur.

/ready (readiness) répond : ce processus peut-il servir du trafic ? S'il échoue, on cesse de lui envoyer des requêtes, sans le tuer.

L'erreur classique consiste à vérifier la base dans /health. Résultat : la base a un hoquet de dix secondes, et l'orchestrateur redémarre toutes vos instances simultanément. Un incident mineur devient une panne totale — et le redémarrage n'a évidemment rien réparé.

Des logs qu'on peut interroger

console.log en production, c'est du texte que personne ne pourra filtrer.

import pino from "pino";
export const logger = pino({ level: config.logLevel });
 
logger.info({ requestId, userId, accommodationId, durationMs }, "booking confirmed");

Une ligne de JSON, avec des champs. On peut alors demander : toutes les réservations de cet utilisateur, ou les requêtes de plus d'une seconde, ou cet identifiant de requête précis — celui du chapitre 42, que l'utilisateur vous a communiqué.

Trois règles.

Un identifiant de corrélation partout. Sans lui, vous avez des lignes ; avec lui, vous avez des histoires.

Jamais de secret dans les logs. Ni mot de passe, ni jeton de session, ni cookie complet, ni corps de requête d'authentification. Les logs sont copiés, agrégés, conservés des mois, et lus par plus de gens que la base de données. C'est aussi la raison pour laquelle le chapitre 30 insistait pour que le mot de passe ne survive pas à l'appel de fonction.

Des niveaux qui veulent dire quelque chose. error est réservé à ce qui exige une intervention humaine. Une réservation refusée parce que le logement est pris n'est pas une erreur : c'est le fonctionnement normal, en info. Si tout est error, plus personne ne lit les error.

Surveiller ce qui compte

Deux choses à surveiller, et la seconde est celle qu'on oublie.

Les symptômes techniques : taux d'erreurs 5xx, temps de réponse au 95e centile, connexions disponibles dans le pool, retard de l'outbox.

Les indicateurs métier : le nombre de réservations par heure.

Ce dernier est le meilleur détecteur de pannes que vous aurez jamais. Il tombe à zéro quand le serveur de paiement ne répond plus, quand un déploiement casse le bouton "Réserver", quand un moteur de recherche déréférence le site. Aucune sonde technique ne détecte ces trois cas — le serveur va très bien, il ne se passe simplement plus rien.

Alertez sur ce que l'utilisateur ressent, pas sur ce que la machine ressent. Une charge processeur à 90 % sans conséquence n'est pas un incident ; un taux de réussite des réservations qui passe de 98 % à 60 % en est un, même si tous les voyants sont verts.

Deux processus, pas un

Le chapitre 35 a laissé un travail en attente : la boîte d'envoi. Elle ne s'exécute pas dans le serveur HTTP.

api      : sert les requêtes
worker   : lit la table outbox, publie, marque comme traité

Un processus séparé, avec la même image et la même configuration, mais un point d'entrée différent. Deux raisons : une pointe de trafic ne doit pas retarder les mails, et un mail lent ne doit pas occuper une connexion destinée à un utilisateur.

Et le rappel du chapitre 35 : ce processus peut publier deux fois. Les abonnés doivent le supporter.

Ce qui protège vraiment

Une liste courte, à vérifier avant la première mise en ligne. Aucun de ces points n'est exotique, et chacun a déjà coulé un projet.

HTTPS partout, avec redirection et Strict-Transport-Security. Un cookie Secure sur du HTTP n'est jamais envoyé — et un cookie sans Secure sur du HTTP est lisible par n'importe quel réseau Wi-Fi.

Une limite de tentatives sur /api/sessions. Sans elle, un attaquant essaie dix mille mots de passe par minute. Limitez par IP et par compte : la seule limite par IP se contourne, la seule limite par compte permet de bloquer le compte de quelqu'un d'autre.

Les en-têtes de sécurité (helmet ou équivalent) : X-Content-Type-Options, Content-Security-Policy. Une politique de sécurité de contenu bien réglée transforme une faille XSS en erreur de console — et elle protège le jeton, même si vous êtes resté sur localStorage.

L'audit des dépendances dans l'intégration continue. yarn npm audit, et un outil qui propose les mises à jour. Le chapitre 31 le rappelait : une seule dépendance compromise suffit.

Des sauvegardes restaurées. Et voici la seule règle qui vaille sur le sujet :

Une sauvegarde qui n'a jamais été restaurée n'existe pas.

Restaurez-la sur un environnement de test, une fois par trimestre, chronomètre en main. Vous découvrirez ce qu'il vous manque le jour d'un exercice, et pas le jour d'un incident.

Un plan de retour arrière. Avant de déployer, sachez comment revenir. Si le retour arrière suppose de défaire une migration, c'est que la migration n'était pas compatible avec la version précédente — retour au début du chapitre.

Le pipeline

on: [push]
jobs:
  build:
    steps:
      - yarn install --frozen-lockfile
      - yarn typecheck
      - yarn lint
      - yarn test                 # le domaine : quelques secondes
      - yarn test:integration     # les contrats, avec une vraie base
      - yarn build
      - yarn e2e                  # un ou deux scénarios

--frozen-lockfile : l'intégration continue installe exactement ce que vous avez testé. Sans cette option, une version corrective publiée entre-temps s'installe silencieusement, et vous déployez du code que personne n'a jamais exécuté.

Et l'ordre n'est pas anodin : le plus rapide d'abord. Une erreur de typage doit être signalée en quinze secondes, pas après huit minutes de tests de bout en bout.

Le site est en ligne. Il est temps de regarder ce qu'il reste à faire.


Le code complet et terminé du projet est disponible ici (opens in a new tab).