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
| Sujet | Sur ton poste | En production |
|---|---|---|
| Lancement | npm run dev : --watch, pino-pretty | node src/server.js, relancé par Docker ou systemd en cas d'arrêt |
NODE_ENV | development | production |
| Dépendances | Toutes | npm ci --omit=dev : sans les outils de développement |
| Configuration | Fichier .env local | Variables injectées par Docker Compose, systemd ou la plateforme ; jamais dans l'image ni dans Git |
| Compte MySQL | Celui de développement | Compte dédié, droits minimaux, mot de passe long et unique |
| HTTP | http://localhost:3000 | HTTPS sur le port 443, assuré par Nginx ; Node.js n'est pas exposé directement |
| Journaux | Dans le terminal | JSON sur la sortie standard, collectés et conservés |
Documentation /docs | Ouverte | Proté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 :
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"]
# .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 lepackage-lock.json, sans supertest ni pino-pretty ;.dockerignore: ninode_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 enrootdans le conteneur. Si une faille permet d'exécuter du code, il le sera avec un compte sans privilège ;HEALTHCHECK: Docker appelle/santetoutes les 30 secondes et marque le conteneurunhealthys'il ne répond plus. Lefetchglobal de Node.js évite d'installer curl dans l'image ;CMD ["node", "src/server.js"]plutôt quenpm start: Node.js est alors le processus principal du conteneur et reçoit directement le signalSIGTERMd'arrêt, que l'API traite proprement.
docker build -t applifrais-api:1.0.0 .
Lancer l'API et MySQL avec Docker Compose
# 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:
docker compose up -d --build
docker compose ps
docker compose logs -f api
| Élément | Rôle |
|---|---|
restart: unless-stopped | Docker relance le conteneur s'il s'arrête (plantage, redémarrage du serveur) |
depends_on avec condition: service_healthy | L'API ne démarre que quand MySQL répond, sinon ses premières requêtes échoueraient avec ECONNREFUSED |
environment: DB_HOST: mysql | Dans 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és | Les données MySQL et les justificatifs survivent à la recréation des conteneurs |
…:/docker-entrypoint-initdb.d/01-schema.sql | Au 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:3000 | Le 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.
# /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) :
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 :
// 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 :
# /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
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 :
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.
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
node:latest: la version change sans prévenir. Fixe-la :node:24-slim.COPY . .avantnpm install: toute modification du code relance l'installation des dépendances, et sans.dockerignore, lenode_moduleset le.envdu poste sont copiés dans l'image. Copie d'abordpackage.jsonetpackage-lock.json, installe, puis copiesrc; ajoute un.dockerignore.npm installau lieu denpm ci --omit=dev: installation non reproductible, avec les dépendances de développement.- Le secret est écrit dans l'image : quiconque obtient l'image le lit avec
docker historyoudocker inspect. Il doit être fourni à l'exécution (env_filede Compose). npm run devlance--watchet pino-pretty, en mode développement, et enroot. Il fautENV NODE_ENV=production,USER nodeetCMD ["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 :
{"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
.envest 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 -vpuisuple recrée à partir des scripts desql/; - les ports 3000 et 3306 ne sont pas déjà pris par un autre logiciel (un MySQL installé sur la machine, par exemple) ;
npm testpasse,/santerépond,/docss'affiche, et les comptes de démonstration fonctionnent ;- une version de secours est prévue : l'API lancée avec
npm run devet 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é ?