Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1420 minIntermédiaire

Déployer une API Node.js : variables d'environnement, Docker et Nginx

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • préparer la configuration de production avec des variables d'environnement ;
  • construire l'image Docker de l'API et la lancer avec MySQL ;
  • placer l'API derrière un reverse proxy Nginx en HTTPS ;
  • arrêter et redémarrer l'API sans perdre de requête.

4 exercices corrigés1 schéma

Déployer une API Node.js, c'est la faire tourner sur un serveur de façon fiable et sûre : avec la configuration de production, des secrets hors du code, un processus relancé en cas d'arrêt, HTTPS, et une procédure de mise à jour répétable. La méthode la plus répandue aujourd'hui est de construire une image Docker de l'API, de la lancer avec sa base MySQL grâce à Docker Compose, et de la placer derrière un reverse proxy Nginx qui gère HTTPS. Ce chapitre déploie ainsi l'API AppliFrais, puis présente PM2 et systemd pour un serveur sans Docker.

Ce qui change entre ton poste et la production

SujetSur ton posteEn production
Lancementnpm run dev : --watch, pino-prettynode src/server.js, relancé par Docker ou systemd en cas d'arrêt
NODE_ENVdevelopmentproduction
DépendancesToutesnpm ci --omit=dev : sans les outils de développement
ConfigurationFichier .env localVariables injectées par Docker Compose, systemd ou la plateforme ; jamais dans l'image ni dans Git
Compte MySQLCelui de développementCompte dédié, droits minimaux, mot de passe long et unique
HTTPhttp://localhost:3000HTTPS sur le port 443, assuré par Nginx ; Node.js n'est pas exposé directement
JournauxDans le terminalJSON sur la sortie standard, collectés et conservés
Documentation /docsOuverteProtégée ou désactivée, selon l'API

NODE_ENV=production n'est pas qu'une étiquette : Express et de nombreuses bibliothèques adaptent leur comportement (le gestionnaire d'erreurs par défaut d'Express n'affiche plus la pile d'appels, par exemple), et l'API AppliFrais s'en sert pour activer trust proxy.

La configuration de production

Les secrets de production (mot de passe MySQL, JWT_SECRET) ne doivent apparaître ni dans le code, ni dans Git, ni dans l'image Docker. Ils sont fournis à l'exécution par des variables d'environnement, depuis un fichier présent uniquement sur le serveur, lisible par le seul compte qui déploie, ou depuis un gestionnaire de secrets.

Le module config.js du chapitre 2 vérifie la configuration au démarrage : si JWT_SECRET manque ou est trop court, l'API refuse de démarrer, et le déploiement échoue tout de suite au lieu de produire une API qui plante à la première connexion. Les bonnes pratiques de gestion des secrets dans un pipeline sont dans le cours Intégration continue.

Construire l'image Docker

Les notions de conteneur, d'image et de couche sont expliquées dans le cours Docker. Le Dockerfile de l'API :

dockerfile
FROM node:24-slim

WORKDIR /app
ENV NODE_ENV=production

# 1. Les dépendances d'abord : cette couche reste en cache tant que package*.json ne change pas
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# 2. Puis le code
COPY src ./src
COPY docs ./docs

# 3. Le dossier des justificatifs, et un compte sans privilège
RUN mkdir -p stockage/justificatifs && chown -R node:node stockage
USER node

EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:3000/sante').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"

CMD ["node", "src/server.js"]
text
# .dockerignore
node_modules
.env
.env.*
.git
test
coverage
stockage

Ligne par ligne, les choix qui comptent :

  • node:24-slim : l'image officielle de Node.js 24 sur une Debian réduite. Fixe la version majeure, comme partout ailleurs ;
  • les dépendances avant le code : Docker réutilise les couches qui n'ont pas changé. Modifier un contrôleur ne relance pas npm ci, ce qui rend la construction rapide ;
  • npm ci --omit=dev : installation exacte depuis le package-lock.json, sans supertest ni pino-pretty ;
  • .dockerignore : ni node_modules (reconstruit dans l'image, compilé pour Linux), ni .env (les secrets n'entrent jamais dans une image, qui peut être partagée ou publiée) ;
  • USER node : l'API ne tourne pas en root dans le conteneur. Si une faille permet d'exécuter du code, il le sera avec un compte sans privilège ;
  • HEALTHCHECK : Docker appelle /sante toutes les 30 secondes et marque le conteneur unhealthy s'il ne répond plus. Le fetch global de Node.js évite d'installer curl dans l'image ;
  • CMD ["node", "src/server.js"] plutôt que npm start : Node.js est alors le processus principal du conteneur et reçoit directement le signal SIGTERM d'arrêt, que l'API traite proprement.
bash
docker build -t applifrais-api:1.0.0 .

Lancer l'API et MySQL avec Docker Compose

yaml
# compose.yaml
services:
  api:
    build: .
    image: applifrais-api:1.0.0
    restart: unless-stopped
    env_file: .env                 # les secrets, présents seulement sur le serveur
    environment:
      DB_HOST: mysql               # le nom du service MySQL sur le réseau de Compose
    depends_on:
      mysql:
        condition: service_healthy
    volumes:
      - justificatifs:/app/stockage/justificatifs
    ports:
      - "127.0.0.1:3000:3000"      # joignable seulement depuis le serveur (par Nginx)

  mysql:
    image: mysql:8.4
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}   # valeurs lues dans le fichier .env
      MYSQL_DATABASE: applifrais
      MYSQL_USER: ${DB_USER}                        # le compte de l'API
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - donnees-mysql:/var/lib/mysql
      - ./sql/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
      - ./sql/donnees.sql:/docker-entrypoint-initdb.d/02-donnees.sql:ro   # démonstration seulement
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
      interval: 5s
      timeout: 3s
      retries: 20

volumes:
  donnees-mysql:
  justificatifs:
bash
docker compose up -d --build
docker compose ps
docker compose logs -f api
ÉlémentRôle
restart: unless-stoppedDocker relance le conteneur s'il s'arrête (plantage, redémarrage du serveur)
depends_on avec condition: service_healthyL'API ne démarre que quand MySQL répond, sinon ses premières requêtes échoueraient avec ECONNREFUSED
environment: DB_HOST: mysqlDans le réseau de Compose, un service se joint par son nom ; cette valeur remplace celle du fichier .env
${DB_USER}, ${DB_PASSWORD}Compose remplace ces variables par les valeurs du fichier .env placé à côté de compose.yaml : le même fichier configure MySQL et l'API. L'image MySQL crée ce compte au premier démarrage, avec tous les droits sur la base
Volumes nommésLes données MySQL et les justificatifs survivent à la recréation des conteneurs
…:/docker-entrypoint-initdb.d/01-schema.sqlAu premier démarrage seulement (volume vide), l'image MySQL exécute les scripts .sql de ce dossier dans la base MYSQL_DATABASE, par ordre alphabétique : les préfixes 01- et 02- font passer le schéma avant les données
127.0.0.1:3000:3000Le port de l'API n'est ouvert que sur l'interface locale : de l'extérieur, on passe obligatoirement par Nginx. MySQL n'a aucun port publié

Le même compose.yaml, tel quel avec le jeu de données, est aussi un excellent moyen de faire tourner ton projet le jour de l'E6 : docker compose up et tout démarre, dans les mêmes versions que sur ton poste (préparer son environnement).

Le reverse proxy Nginx et HTTPS

Node.js pourrait écouter directement sur le port 443 avec un certificat, mais on place presque toujours un reverse proxy devant : il termine HTTPS, sert éventuellement le front React, limite la taille des requêtes, et répartit la charge entre plusieurs instances de l'API si besoin.

InternetNavigateurfront ReactHTTPS, 443ApplicationmobileServeur Linuxpare-feu : seuls 80 et 443 ouvertsNginxHTTPS, TLSLet's Encrypt80 → 443X-ForwardedDocker Compose, réseau interneHTTP:3000conteneur apiNode.js 24, port 3000utilisateur node, sans privilègejournal JSON sur la sortie standardmysql:3306conteneur mysqlMySQL 8.4, aucun port publiévolume donnees-mysql
Seul Nginx est exposé ; l'API et MySQL restent sur le réseau interne du serveur
nginx
# /etc/nginx/sites-available/api.gsb.example
server {
    listen 80;
    server_name api.gsb.example;
    return 301 https://$host$request_uri;   # tout en HTTPS
}

server {
    listen 443 ssl;
    http2 on;
    server_name api.gsb.example;

    ssl_certificate     /etc/letsencrypt/live/api.gsb.example/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.gsb.example/privkey.pem;

    client_max_body_size 3m;                 # un peu plus que la limite des justificatifs (2 Mo)

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Request-Id $request_id;
    }
}

Le certificat s'obtient gratuitement auprès de Let's Encrypt avec certbot, qui le renouvelle automatiquement : sudo certbot --nginx -d api.gsb.example. Le fonctionnement de TLS et des certificats est expliqué dans le cours Cryptographie.

Côté Express, une ligne fait le lien (chapitre 10) :

js
if (config.env === 'production') app.set('trust proxy', 1); // derrière Nginx

X-Request-Id: $request_id transmet l'identifiant unique que Nginx attribue à chaque requête : pino-http le reprend (chapitre 11), et une même requête se suit dans les journaux de Nginx et dans ceux de l'API.

Arrêter et redémarrer sans perdre de requête

docker compose stop, une mise à jour ou un redémarrage du serveur envoient le signal SIGTERM au processus Node.js, puis le tuent (SIGKILL) s'il n'est pas arrêté au bout de 10 secondes. L'API profite de ce délai pour finir proprement :

js
// src/server.js
import { app } from './app.js';
import { config } from './config.js';
import { pool } from './db.js';
import { logger } from './logger.js';

const serveur = app.listen(config.port, (erreur) => {
  if (erreur) {
    logger.fatal(erreur, 'Démarrage impossible');
    process.exit(1);
  }
  logger.info(`API AppliFrais à l'écoute sur http://localhost:${config.port}`);
});

// Arrêt propre : on finit les requêtes en cours, puis on ferme le pool MySQL.
function arreter(signal) {
  logger.info({ signal }, 'Arrêt demandé');
  serveur.close(async () => {
    await pool.end();
    process.exit(0);
  });
}
process.on('SIGTERM', arreter);
process.on('SIGINT', arreter);

serveur.close cesse d'accepter de nouvelles connexions et attend la fin des requêtes en cours ; ensuite seulement, le pool MySQL est fermé et le processus quitte avec le code 0. Une transaction en cours n'est pas coupée au milieu.

Sans Docker : PM2 ou systemd

Sur un serveur Linux sans Docker, il faut un gestionnaire de processus qui démarre l'API au démarrage du serveur et la relance si elle s'arrête.

systemd, présent sur toutes les distributions Linux courantes :

ini
# /etc/systemd/system/applifrais-api.service
[Unit]
Description=API AppliFrais
After=network.target mysql.service

[Service]
User=applifrais
WorkingDirectory=/opt/applifrais-api
EnvironmentFile=/etc/applifrais/api.env
ExecStart=/usr/bin/node src/server.js
Restart=on-failure

[Install]
WantedBy=multi-user.target
bash
sudo systemctl daemon-reload
sudo systemctl enable --now applifrais-api
journalctl -u applifrais-api -f        # les journaux de l'API

PM2 est un gestionnaire de processus écrit pour Node.js, très utilisé chez les hébergeurs :

bash
npm install -g pm2
pm2 start src/server.js --name applifrais-api --node-args="--env-file=.env"
pm2 save            # se souvenir des processus
pm2 startup         # affiche la commande qui relance PM2 au démarrage du serveur
pm2 logs applifrais-api

PM2 sait aussi lancer plusieurs instances de l'API (mode cluster, option -i max) pour utiliser tous les cœurs du processeur. Attention alors à ce qui est gardé en mémoire dans un processus : le compteur de la limitation de débit n'est plus partagé entre les instances, il faut un stockage commun (Redis).

Mettre à jour en continu

Une mise à jour suit toujours les mêmes étapes, qu'un pipeline de CI/CD automatise : les tests passent (chapitre 12), l'image est construite avec un numéro de version et publiée dans un registre, puis le serveur récupère la nouvelle image et recrée le conteneur de l'API. Les évolutions de la base passent par des scripts de migration versionnés (ou npx prisma migrate deploy avec Prisma, chapitre 7), appliqués avant de démarrer la nouvelle version. Le cours Intégration continue construit ce pipeline pas à pas, jusqu'au déploiement continu.

Exercices

Exercice 1 sur 4 Relire un Dockerfile Réussi À revoir

Trouve les cinq défauts de ce Dockerfile et corrige-le.

dockerfile
FROM node:latest
WORKDIR /app
COPY . .
RUN npm install
ENV JWT_SECRET=mon-secret-de-production-tres-long-0123456789
EXPOSE 3000
CMD npm run dev
Voir la réponseMasquer la réponse de l'exercice 1
  1. node:latest : la version change sans prévenir. Fixe-la : node:24-slim.
  2. COPY . . avant npm install : toute modification du code relance l'installation des dépendances, et sans .dockerignore, le node_modules et le .env du poste sont copiés dans l'image. Copie d'abord package.json et package-lock.json, installe, puis copie src ; ajoute un .dockerignore.
  3. npm install au lieu de npm ci --omit=dev : installation non reproductible, avec les dépendances de développement.
  4. Le secret est écrit dans l'image : quiconque obtient l'image le lit avec docker history ou docker inspect. Il doit être fourni à l'exécution (env_file de Compose).
  5. npm run dev lance --watch et pino-pretty, en mode développement, et en root. Il faut ENV NODE_ENV=production, USER node et CMD ["node", "src/server.js"].

Tu avais trouvé ?

Exercice 2 sur 4 L'API ne démarre pas Réussi À revoir

Après docker compose up -d, docker compose logs api affiche :

text
{"level":50,…,"err":{"message":"connect ECONNREFUSED 127.0.0.1:3306","code":"ECONNREFUSED"},"msg":"Erreur non prévue"}

Le service MySQL est pourtant healthy. Quelle est la cause la plus probable, et comment la corriges-tu ?

Voir la réponseMasquer la réponse de l'exercice 2

L'API essaie de joindre MySQL sur 127.0.0.1, c'est-à-dire son propre conteneur, où aucun MySQL n'écoute. Le fichier .env copié depuis le poste de développement contient DB_HOST=localhost. Dans Docker Compose, MySQL se joint par le nom du service : DB_HOST=mysql, fixé dans la section environment du service api (qui l'emporte sur env_file). Le message montre aussi que l'API a démarré sans vérifier la base : un await pool.query('SELECT 1') avant app.listen aurait arrêté le démarrage avec un message clair.

Tu avais trouvé ?

Exercice 3 sur 4 Le client vu comme 172.18.0.1 Réussi À revoir

En production, le journal de l'API indique la même adresse 172.18.0.1 pour toutes les requêtes, et la limitation de débit de la connexion bloque des utilisateurs qui ne se sont jamais trompés. Explique et corrige.

Voir la réponseMasquer la réponse de l'exercice 3

Toutes les requêtes arrivent à l'API par Nginx et le réseau Docker : vue de Node.js, l'adresse source est celle de la passerelle du réseau (172.18.0.1). La limitation de débit compte alors tous les utilisateurs ensemble. Correction : app.set('trust proxy', 1) en production, pour que req.ip soit lue dans X-Forwarded-For, et vérifier que Nginx envoie bien proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;.

Tu avais trouvé ?

Exercice 4 sur 4 Préparer la démonstration de l'E6 Réussi À revoir

Tu présentes ton API à l'E6 sur ton ordinateur portable. Liste ce que tu vérifies la veille pour que docker compose up démarre du premier coup, sans connexion Internet.

Voir la réponseMasquer la réponse de l'exercice 4
  • Les images sont déjà téléchargées et l'image de l'API construite (docker compose build, docker compose pull), puisqu'il n'y aura peut-être pas de réseau ;
  • le fichier .env est présent sur le portable (il n'est pas dans Git), avec des valeurs de démonstration ;
  • le volume MySQL contient le jeu de données, ou docker compose down -v puis up le recrée à partir des scripts de sql/ ;
  • les ports 3000 et 3306 ne sont pas déjà pris par un autre logiciel (un MySQL installé sur la machine, par exemple) ;
  • npm test passe, /sante répond, /docs s'affiche, et les comptes de démonstration fonctionnent ;
  • une version de secours est prévue : l'API lancée avec npm run dev et un MySQL local, si Docker refuse de démarrer.

Le cours Épreuve E6 SLAM détaille la préparation de l'environnement et le plan B.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Exercices corrigés Node.js et Express type E6 et E7Revoir le sommaire du cours