← Retour aux articles
Backend DockerNode.jsDevOps

Docker pour les développeurs Node.js : du dev à la production

· 7 min de lecture

“Ça marche sur ma machine” a cessé d’être une réponse acceptable le jour où les conteneurs sont devenus mainstream. Docker empaquette votre application Node.js avec son runtime exact, ses dépendances et sa configuration dans une image qui tourne de manière identique partout. Votre laptop, la machine d’un collègue, le staging, la production, tout pareil. Mais écrire un Dockerfile qui produit réellement une image petite, sécurisée et rapide demande de comprendre comment fonctionnent les layers, pourquoi l’ordre du build compte, et ce qui a sa place en production versus ce qui n’en a pas.

Un Dockerfile prêt pour la production

La plupart des Dockerfiles Node.js trouvés en ligne sont mono-stage, gonflés, et tournent en root. Une vraie image de production utilise le multi-stage build pour séparer l’environnement de build du runtime, gardant l’image finale légère et débarrassée des dépendances de dev.

# Build stage
FROM node:22-alpine AS build
WORKDIR /app

COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

COPY . .
RUN pnpm build

# Production stage
FROM node:22-alpine AS production
WORKDIR /app

RUN corepack enable

COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod

COPY --from=build /app/dist ./dist

USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Le stage de build installe toutes les dépendances et compile le TypeScript. Le stage de production repart de zéro, installe uniquement les dépendances de production, et copie la sortie compilée. Les dépendances de dev comme TypeScript, ESLint et les librairies de test n’atterrissent jamais dans l’image finale. La directive USER node abandonne les privilèges root, et --frozen-lockfile garantit que le lockfile est respecté exactement.

Cache de layers : pourquoi l’ordre compte

Docker met chaque layer en cache. Quand un layer change, chaque layer qui suit est reconstruit. Ça signifie que l’ordre des instructions COPY impacte directement la vitesse de build.

# Mauvais : tout changement de fichier invalide le cache des dépendances
COPY . .
RUN pnpm install

# Bon : les dépendances sont en cache tant que package.json ne change pas
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .

En copiant package.json et le lockfile en premier, Docker réutilise le layer pnpm install en cache tant que les dépendances n’ont pas changé. Les changements de code source, qui arrivent constamment, n’invalident que le layer COPY final. Sur un projet avec des centaines de dépendances, ça économise des minutes par build.

Le fichier .dockerignore

Sans .dockerignore, Docker envoie tout le répertoire du projet au daemon de build, y compris node_modules, .git, les fichiers de test, et tout ce qui n’a rien à faire dans une image.

node_modules
.git
.gitignore
*.md
.env*
.next
dist
coverage
.turbo

Ça réduit drastiquement la taille du contexte de build et empêche l’inclusion accidentelle de secrets comme les fichiers .env. Ça empêche aussi les node_modules locaux d’écraser les dépendances installées dans le conteneur, un bug subtil qui cause des incompatibilités d’architecture sur les machines Apple Silicon qui buildent pour Linux.

Variables d’environnement et secrets

Les variables d’environnement n’ont pas leur place dans l’image. Elles appartiennent au runtime, injectées par l’orchestrateur ou la commande docker run.

# Ne jamais faire ça
ENV DATABASE_URL=postgres://user:password@host:5432/db

# Faire ça : déclarer sans valeur
ENV NODE_ENV=production
docker run -e DATABASE_URL=postgres://user:password@host:5432/db myapp

Les secrets de build (comme les tokens de registre npm privé) nécessitent un traitement spécial. Docker BuildKit supporte les montages de secrets qui ne sont jamais persistés dans les layers de l’image.

RUN --mount=type=secret,id=npm_token \
  NPM_TOKEN=$(cat /run/secrets/npm_token) pnpm install --frozen-lockfile
docker build --secret id=npm_token,src=.npmrc .

Le secret est disponible pendant l’étape de build mais n’est stocké dans aucun layer. Quiconque pull l’image ne peut pas l’extraire, contrairement à ARG ou ENV qui sont visibles dans l’historique de l’image.

Health checks

Un conteneur qui tourne n’est pas nécessairement en bonne santé. Le processus peut être vivant mais l’event loop bloqué, la connexion à la base de données perdue, ou l’app coincée dans un état d’erreur. Les health checks permettent à Docker et aux orchestrateurs de détecter ça.

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
app.get('/health', async (req, res) => {
  try {
    await db.raw('SELECT 1');
    res.status(200).json({ status: 'ok' });
  } catch {
    res.status(503).json({ status: 'unhealthy' });
  }
});

Le endpoint de santé vérifie ce qui compte : la connectivité à la base de données, la disponibilité des services critiques, tout ce dont votre app a besoin pour fonctionner. Docker marque le conteneur comme unhealthy après trois échecs consécutifs, et les orchestrateurs comme Kubernetes ou Docker Swarm peuvent le redémarrer automatiquement.

Docker Compose pour le développement local

Les images de production sont optimisées pour la taille et la sécurité. Le développement a besoin de l’inverse : live reload, toutes les dépendances, accès au debugger, et le code source monté. Docker Compose fait le pont entre les deux mondes.

services:
  app:
    build:
      context: .
      target: build
    volumes:
      - .:/app
      - /app/node_modules
    ports:
      - '3000:3000'
      - '9229:9229'
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:postgres@db:5432/myapp
    command: pnpm dev
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myapp
    ports:
      - '5432:5432'
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pgdata:

La directive target: build utilise le stage de build du Dockerfile multi-stage, qui a toutes les dépendances de dev. Le montage de volume synchronise le code source pour le live reload. Le volume anonyme pour node_modules empêche les modules de l’hôte d’écraser ceux du conteneur, évitant le problème d’incompatibilité d’architecture. Le port 9229 expose le debugger Node.js.

depends_on avec service_healthy garantit que l’app ne démarre pas tant que PostgreSQL n’accepte pas les connexions. Plus d’erreurs “connection refused” au démarrage.

Taille de l’image : pourquoi ça compte

Une image node:22 par défaut fait plus de 1Go. En passant à node:22-alpine, on tombe à ~130Mo. Après un multi-stage build propre avec uniquement les dépendances de production, une image d’API Node.js typique atterrit entre 150 et 200Mo.

# Comparer les tailles d'images
docker images
REPOSITORY   TAG          SIZE
myapp        debian       1.2GB
myapp        alpine       180MB
myapp        distroless   95MB

Des images plus petites signifient des pulls plus rapides, des déploiements plus rapides, moins de stockage, et une surface d’attaque réduite. Chaque mégaoctet supprimé est un binaire ou une librairie qui ne peut pas contenir de vulnérabilité.

Pour les setups les plus soucieux de sécurité, les images distroless de Google vont plus loin en supprimant le shell, le gestionnaire de paquets, et tout sauf le runtime. Le compromis est qu’on ne peut pas faire de docker exec dans le conteneur pour débugger.

Arrêt gracieux

Quand Docker stoppe un conteneur, il envoie SIGTERM et attend 10 secondes avant d’envoyer SIGKILL. Votre app doit gérer SIGTERM pour fermer les connexions à la base de données, terminer les requêtes en cours, et s’arrêter proprement.

const server = app.listen(3000);

function shutdown() {
  server.close(async () => {
    await db.disconnect();
    process.exit(0);
  });
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

Sans ça, les requêtes actives sont terminées en pleine réponse, les connexions à la base de données fuient, et les données peuvent être laissées dans un état incohérent. C’est particulièrement critique dans les environnements orchestrés où les conteneurs sont régulièrement redémarrés pendant les déploiements.

Conclusion

Docker transforme “ça marche sur ma machine” en “ça marche partout.” Le multi-stage build garde les images légères. L’ordre des layers garde les builds rapides. Les health checks gardent les conteneurs honnêtes. Et Docker Compose donne un environnement local complet avec une seule commande. L’investissement dans un Dockerfile propre se rentabilise à chaque déploiement, chaque onboarding, et chaque conversation “tu peux reproduire ce bug” qui n’a plus jamais besoin d’avoir lieu.