Docker pour debutants : conteneuriser une app Node.js

Credit : Logo officiel

Docker pour debutants : conteneuriser une app Node.js

Dylan D. — Agent Support Technique Serveur DevOps 1803 mots 10 min de lecture

Docker pour débutants : conteneuriser une app Node.js

Il y a deux semaines, un client me transmet une application Node.js qu'il faut déployer en production. Le README dit : "installer Node 18, MongoDB 5, Redis, et configurer trois variables d'environnement". Sur le papier facile, en réalité un cauchemar : la version de Node sur Debian 12 par défaut est trop ancienne, MongoDB demande un dépôt externe, Redis tourne déjà sur ce serveur pour un autre projet. Trois heures de bidouille pour faire tourner ce qu'un docker compose up -d aurait fait en cinq minutes.

Docker est aujourd'hui le standard de fait pour empaqueter des applications. Voici comment je conteneurise une application Node.js Express depuis zéro sur un VPS IONOS sous Debian 12.

Installer Docker proprement

Le paquet docker.io de Debian fonctionne mais reste en retard sur les versions. Pour la production, j'installe toujours Docker depuis le dépôt officiel.

apt update
apt install ca-certificates curl gnupg -y
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | \
  gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
  | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update
apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y

Vérification :

docker --version
docker compose version
docker run --rm hello-world

Sortie attendue :

Docker version 26.1.4, build 5650f9b
Docker Compose version v2.27.0
Hello from Docker!
This message shows that your installation appears to be working correctly.

Utiliser Docker sans sudo

Ajoutez votre utilisateur au groupe docker (attention : équivaut à donner les droits root) :

usermod -aG docker deployer
newgrp docker

Sur un serveur de production, je préfère restreindre l'accès Docker aux scripts de CI/CD plutôt qu'à des utilisateurs interactifs.

L'application Node.js de démonstration

Créons une app Express minimaliste avec un endpoint santé et une connexion MongoDB.

Fichier package.json :

{
  "name": "mon-app",
  "version": "1.0.0",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },
  "dependencies": {
    "express": "^4.19.2",
    "mongoose": "^8.4.0"
  },
  "engines": {
    "node": ">=20"
  }
}

Fichier server.js :

const express = require('express');
const mongoose = require('mongoose');

const app = express();
const PORT = process.env.PORT || 3000;
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/monapp';

mongoose.connect(MONGO_URI)
  .then(() => console.log('MongoDB connected'))
  .catch(err => console.error('Mongo error:', err));

app.get('/', (req, res) => {
  res.json({ message: 'API en ligne', timestamp: new Date() });
});

app.get('/health', (req, res) => {
  const dbState = mongoose.connection.readyState === 1 ? 'up' : 'down';
  res.json({ status: 'ok', db: dbState });
});

app.listen(PORT, () => {
  console.log(`Serveur demarre sur le port ${PORT}`);
});

Le Dockerfile

Un Dockerfile bien fait suit deux principes : multi-stage build (séparer build et runtime) et utilisateur non-root.

# Stage 1: dependances
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

# Stage 2: image finale
FROM node:20-alpine
WORKDIR /app

# Securite: user non-root
RUN addgroup -g 1001 -S nodejs && \
    adduser -S -u 1001 -G nodejs nodejs

# Copier les dependances depuis le stage deps
COPY --from=deps --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs . .

USER nodejs
EXPOSE 3000

# Healthcheck integre
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "server.js"]

Fichier .dockerignore (crucial pour la taille de l'image) :

node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
Dockerfile
docker-compose*.yml
README.md
coverage
.vscode

Pourquoi Alpine ?

L'image node:20-alpine pèse environ 180 Mo contre 1 Go pour node:20. Sur un VPS avec 50 Go de disque, la différence se voit. Le seul piège : Alpine utilise musl libc, qui peut poser problème avec certains modules natifs (bcrypt, sharp). Si vous rencontrez des erreurs Error loading shared library, basculez sur node:20-slim (~250 Mo, basé sur Debian).

Construire et lancer le conteneur

docker build -t mon-app:1.0 .
docker run -d --name mon-app -p 3000:3000 mon-app:1.0
docker logs -f mon-app
curl http://localhost:3000/health

Sortie attendue :

{"status":"ok","db":"down"}

Le db: down est normal : on n'a pas encore branché MongoDB. C'est là que Docker Compose entre en scène.

Docker Compose : orchestrer plusieurs services

Une app moderne dépend rarement d'un seul process. Avec Compose, un fichier YAML décrit toute la stack.

Fichier docker-compose.yml :

services:
  app:
    build: .
    container_name: mon-app
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      MONGO_URI: mongodb://mongo:27017/monapp
      PORT: 3000
    depends_on:
      mongo:
        condition: service_healthy
    restart: unless-stopped
    networks:
      - app-network
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

  mongo:
    image: mongo:7
    container_name: mongo
    volumes:
      - mongo-data:/data/db
      - ./mongo-init:/docker-entrypoint-initdb.d:ro
    networks:
      - app-network
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
      interval: 10s
      timeout: 5s
      retries: 5

  nginx:
    image: nginx:alpine
    container_name: nginx
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app
    networks:
      - app-network
    restart: unless-stopped

volumes:
  mongo-data:
    driver: local

networks:
  app-network:
    driver: bridge

Lancement :

docker compose up -d
docker compose ps
docker compose logs -f app

Sortie de docker compose ps :

NAME      IMAGE          STATUS                   PORTS
mon-app   mon-app:1.0    Up 2 minutes (healthy)   0.0.0.0:3000->3000/tcp
mongo     mongo:7        Up 2 minutes (healthy)
nginx     nginx:alpine   Up 2 minutes             0.0.0.0:80->80/tcp

Volumes : la persistance des données

docker volume ls
docker volume inspect mon-app_mongo-data

Le volume named mongo-data est stocké dans /var/lib/docker/volumes/. Les données survivent à docker compose down (mais pas à docker compose down -v, qui supprime aussi les volumes).

Pour une sauvegarde MongoDB :

docker compose exec mongo mongodump --archive --gzip > backup-$(date +%F).gz

Pour restaurer :

docker compose exec -T mongo mongorestore --archive --gzip < backup-2026-05-01.gz

Voir aussi Automatiser ses backups en bash + cron pour industrialiser.

Networking : comment les conteneurs communiquent

Dans le réseau app-network, chaque conteneur est résolu par son nom de service. Mon app accède à MongoDB via mongo:27017, jamais via localhost:27017 (qui pointerait sur le conteneur lui-même).

docker network ls
docker network inspect mon-app_app-network

Tester depuis l'intérieur d'un conteneur :

docker compose exec app sh
wget -qO- http://mongo:27017

Exposer ou pas ?

MongoDB et Nginx ont une différence cruciale : seul Nginx expose un port à l'hôte (80:80). MongoDB n'a aucun port mappé : seul le conteneur app peut y accéder, via le réseau interne. C'est la base de la sécurité Docker : n'exposez jamais que ce que l'extérieur doit voir.

Reverse proxy avec Nginx

Fichier nginx.conf :

server {
    listen 80;
    server_name monapp.fr;

    location / {
        proxy_pass http://app:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

Pour ajouter HTTPS, voir Reverse proxy Nginx avec SSL.

Variables d'environnement et secrets

Ne stockez jamais les mots de passe en clair dans le docker-compose.yml. Utilisez un fichier .env à la racine :

# .env
MONGO_USER=app_user
MONGO_PASSWORD=Sup3r-S3cret-2026!
NODE_ENV=production

Dans docker-compose.yml :

services:
  mongo:
    image: mongo:7
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}

Ajoutez .env à votre .gitignore. Pour des secrets vraiment sensibles en production, utilisez plutôt Docker Secrets (mode Swarm) ou un gestionnaire externe comme Vault.

Optimiser la taille de l'image

Une image bien construite pèse moins de 200 Mo pour une app Node typique. Quelques techniques que j'applique :

  1. Multi-stage build : déjà appliqué ci-dessus.
  2. Ordre des couches : copier package*.json AVANT le code source. Le cache Docker évite de réinstaller les dépendances à chaque modification du code.
  3. .dockerignore rigoureux : pas de node_modules, pas de .git, pas de tests.
  4. npm ci --only=production : ne pas installer les devDependencies.
  5. Nettoyage final : npm cache clean --force après l'install.

Mesurez l'impact :

docker images mon-app
REPOSITORY   TAG   IMAGE ID       CREATED        SIZE
mon-app      1.0   abc123def456   2 minutes ago  185MB

Si vous dépassez 500 Mo, il y a un problème. Inspectez les couches :

docker history mon-app:1.0

Commandes Docker que j'utilise tous les jours

# Voir les conteneurs actifs
docker ps

# Voir TOUS les conteneurs (même arrêtés)
docker ps -a

# Logs en temps réel
docker compose logs -f --tail=100 app

# Entrer dans un conteneur
docker compose exec app sh

# Reconstruire après changement
docker compose up -d --build

# Arrêter et nettoyer
docker compose down

# Arrêter et supprimer aussi les volumes
docker compose down -v

# Nettoyer les images orphelines
docker system prune -a

# Voir l'espace disque utilisé
docker system df

Erreurs courantes et leur fix

Error response from daemon: driver failed programming external connectivity

Cause : un autre process écoute déjà sur le port mappé (souvent Nginx ou Apache du serveur hôte).

Solution :

ss -tlnp | grep :80
systemctl stop nginx  # ou changer le port dans docker-compose.yml

npm ci can only install with an existing package-lock.json

Cause : le package-lock.json est dans .dockerignore ou n'existe pas.

Solution : générez-le localement avec npm install et commitez-le. Retirez package-lock.json du .dockerignore.

Cannot connect to MongoDB: ECONNREFUSED 127.0.0.1:27017

Cause : votre app utilise localhost au lieu du nom de service Docker.

Solution : remplacez MONGO_URI=mongodb://localhost:27017/... par mongodb://mongo:27017/... dans le docker-compose.yml.

Permission denied while trying to connect to the Docker daemon socket

Cause : votre utilisateur n'est pas dans le groupe docker.

Solution :

usermod -aG docker $USER
newgrp docker

No space left on device

Cause : les images, volumes et logs Docker remplissent /var/lib/docker.

Solution :

docker system df
docker system prune -a --volumes

Attention : --volumes supprime les données persistantes des volumes orphelins. Faites un backup avant.

Pour aller plus loin

Démarrage automatique au boot

Docker s'active au boot par défaut sur Debian 12. Pour vérifier :

systemctl is-enabled docker
systemctl enable docker

Les conteneurs avec restart: unless-stopped redémarrent automatiquement après un reboot serveur. C'est la politique que je mets sur tous mes services en production. Pour des conteneurs critiques, préférez restart: always.

Vérifiez après reboot :

docker compose ps
ss -tlnp | grep :80

Le conteneur, c'est la nouvelle unité de déploiement

Docker change la donne pour qui gère plusieurs applications sur un serveur : finies les conflits de versions, finies les surprises au déploiement, finie la dépendance à l'OS hôte. Une fois que votre Dockerfile et votre docker-compose.yml sont propres, déployer votre app sur un nouveau serveur prend trois minutes : git clone, docker compose up -d, terminé. C'est ce gain de reproductibilité qui en fait l'outil incontournable pour tout déploiement moderne.

# Articles similaires

Sur les memes sujets et plus loin