Écrire un Dockerfile pour conteneuriser son application
Le Dockerfile est la recette de votre image. Bien l'écrire, c'est des déploiements reproductibles, rapides et sûrs. Voici les instructions et les bonnes pratiques, du simple au multi-stage.
Conteneuriser une application consiste à empaqueter son code, son environnement d’exécution et ses dépendances dans une unité portable. L’objectif est simple : obtenir le même comportement sur le poste d’un développeur, dans une chaîne CI/CD et sur le serveur de production.
Le Dockerfile est la recette utilisée pour fabriquer cette unité, appelée image Docker. Chaque instruction décrit une étape reproductible : choisir un système de base, installer les dépendances, copier le code et définir la commande de démarrage.
Un Dockerfile bien conçu améliore la fiabilité des déploiements, mais aussi leur rapidité et leur sécurité. Ce guide présente les instructions essentielles et les pratiques recommandées pour produire une image propre, légère et adaptée à la production.
Anatomie d’un Dockerfile
Un Dockerfile est un fichier texte nommé Dockerfile, sans extension. Docker lit ses instructions de haut en bas pour construire une succession de couches.
Les principales instructions sont les suivantes :
FROMchoisit l’image de base. Elle fournit généralement le système minimal et l’environnement d’exécution, comme Node.js, Python ou PHP.WORKDIRdéfinit le répertoire de travail dans le conteneur. Les instructions suivantes y seront exécutées par défaut.COPYcopie des fichiers depuis le contexte de construction vers l’image.RUNexécute une commande pendant la construction. Elle sert notamment à installer des dépendances ou à compiler l’application.ENVdéfinit une variable d’environnement persistante dans l’image.EXPOSEdocumente le port écouté par l’application. Cette instruction ne publie pas automatiquement le port sur la machine hôte.CMDfournit la commande exécutée par défaut au démarrage du conteneur.ENTRYPOINTdéfinit l’exécutable principal du conteneur et convient aux images conçues comme des commandes dédiées.USERsélectionne l’utilisateur qui exécutera les instructions suivantes et le processus final.HEALTHCHECKpermet à Docker de vérifier régulièrement l’état de l’application.
Chaque instruction crée potentiellement une couche réutilisable. Leur ordre influence donc directement le cache, le temps de construction et la taille de l’image.
Un premier Dockerfile Node.js
Voici un Dockerfile simple pour une application Node.js qui écoute sur le port 3000 :
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node","server.js"]
FROM node:20-slim utilise une image Node.js 20 plus compacte que l’image standard. WORKDIR /app crée et sélectionne le répertoire de l’application.
Les fichiers décrivant les dépendances sont copiés avant le reste du projet. npm ci réalise ensuite une installation reproductible à partir de package-lock.json. L’option --omit=dev exclut les dépendances réservées au développement.
Le code est copié seulement après cette installation. Si le code change mais que les fichiers package.json et package-lock.json restent identiques, Docker peut réutiliser la couche contenant les dépendances.
Enfin, EXPOSE 3000 documente le port du service et CMD lance l’application. Le port sera réellement publié lors de l’exécution avec l’option -p.
Choisir et épingler l’image de base
Une image légère réduit les téléchargements, accélère les déploiements et limite le nombre de composants susceptibles de contenir une vulnérabilité.
Les variantes slim constituent souvent un bon compromis. Elles restent proches d’une distribution Linux classique tout en supprimant de nombreux paquets inutiles. Les variantes Alpine sont encore plus petites, mais utilisent musl au lieu de glibc. Certaines dépendances natives peuvent alors demander une compilation ou présenter des incompatibilités.
Il faut aussi éviter les tags flottants comme latest. Une construction identique pourrait produire une image différente quelques semaines plus tard. Préférez une version explicite comme node:20.16.0-slim. Pour une reproductibilité maximale, il est également possible d’épingler l’image par son digest.
L’épinglage ne dispense pas des mises à jour. Il rend simplement leur adoption volontaire, visible et testable.
Optimiser le cache des couches
Docker invalide une couche dès que l’instruction correspondante ou l’un de ses fichiers d’entrée change. Toutes les couches suivantes doivent alors être reconstruites.
Placez donc les étapes les plus stables au début du Dockerfile. Pour Node.js, copiez les manifestes de dépendances avant le code :
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Avec l’ordre inverse, la modification d’un simple fichier JavaScript invaliderait l’installation des dépendances. Sur un projet important ou dans une chaîne CI/CD, cette différence peut économiser plusieurs minutes à chaque construction.
Exclure les fichiers inutiles avec .dockerignore
Le contexte de construction contient les fichiers transmis au moteur Docker. Sans filtrage, il peut inclure des répertoires volumineux ou des données sensibles.
Créez un fichier .dockerignore à la racine du projet :
node_modules
.git
.env
npm-debug.log
coverage
dist
Dockerfile*
README.md
L’exclusion de node_modules évite de copier des dépendances compilées pour la machine locale. Celle de .git réduit le contexte. L’exclusion de .env limite le risque d’intégrer accidentellement des secrets dans l’image.
Adaptez cependant la liste au projet. Par exemple, ne retirez pas dist si votre processus de construction repose volontairement sur des fichiers déjà compilés.
Exécuter l’application avec un utilisateur non-root
Un conteneur ne constitue pas une frontière de sécurité absolue. Exécuter l’application avec les privilèges root augmente l’impact potentiel d’une compromission.
Créez un utilisateur dédié, attribuez-lui les fichiers nécessaires, puis sélectionnez-le avec USER :
FROM node:20-slim
RUN groupadd --system appgroup \
&& useradd --system --gid appgroup --create-home appuser
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=appuser:appgroup . .
ENV NODE_ENV=production
USER appuser
EXPOSE 3000
CMD ["node","server.js"]
L’application doit pouvoir lire ses fichiers sans disposer de droits inutiles. Si elle écrit des journaux ou des fichiers temporaires, accordez uniquement les permissions nécessaires aux répertoires concernés.
Les images officielles proposent parfois déjà un utilisateur non privilégié. L’image Node.js fournit notamment l’utilisateur node, qui peut être utilisé avec USER node après avoir correctement défini la propriété des fichiers.
Construire une image multi-stage
Une construction multi-stage sépare les outils de compilation de l’environnement final. Le premier stage installe les dépendances de développement et compile le projet. Le second ne récupère que les éléments nécessaires à l’exécution.
Voici un exemple pour une application Node.js compilée vers dist :
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim AS runtime
ENV NODE_ENV=production
RUN groupadd --system appgroup \
&& useradd --system --gid appgroup --create-home appuser
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev \
&& npm cache clean --force
COPY --from=build --chown=appuser:appgroup /app/dist ./dist
USER appuser
EXPOSE 3000
CMD ["node","dist/server.js"]
Les compilateurs, fichiers sources et dépendances de développement restent dans le stage build. L’image runtime contient seulement Node.js, les dépendances de production et le résultat compilé.
Cette séparation réduit la taille de l’image finale et sa surface d’attaque. Elle évite aussi d’embarquer des outils inutiles en production.
Gérer les secrets sans les intégrer à l’image
Ne placez jamais un mot de passe, une clé API ou un jeton dans un Dockerfile. Les instructions ENV et ARG ne sont pas des coffres-forts. Leurs valeurs peuvent rester accessibles dans les couches, l’historique de construction, le cache ou les journaux de la CI/CD.
Évitez donc ce type d’instruction :
ENV API_KEY=secret
ARG DATABASE_PASSWORD
Les secrets doivent être injectés au moment de l’exécution avec le mécanisme fourni par la plateforme de déploiement, un gestionnaire de secrets ou un fichier monté et exclu du dépôt. Notre guide sur la gestion des secrets détaille ces pratiques.
Les variables non sensibles, comme NODE_ENV=production, peuvent rester dans l’image. Les identifiants, certificats privés et chaînes de connexion doivent être fournis uniquement au conteneur qui en a besoin.
Ajouter un contrôle de santé
Un processus actif ne signifie pas forcément que l’application répond correctement. HEALTHCHECK permet de tester périodiquement un point de contrôle HTTP :
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://localhost:3000/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
L’application doit exposer une route légère comme /health. Celle-ci peut vérifier que le serveur traite les requêtes, sans effectuer une opération coûteuse à chaque contrôle.
Le délai initial laisse le temps à l’application de démarrer. L’intervalle et le nombre d’échecs doivent être ajustés pour éviter les redémarrages inutiles lors d’un ralentissement ponctuel.
Construire et tester l’image
Depuis le répertoire contenant le Dockerfile, construisez l’image avec un nom et un tag explicites :
docker build -t mon-application:1.0.0 .
Lancez ensuite un conteneur en publiant son port :
docker run --rm -p 3000:3000 mon-application:1.0.0
Testez l’application sur le port 3000, consultez ses journaux et vérifiez que son arrêt est propre. Si un HEALTHCHECK est présent, son état apparaît dans les informations du conteneur.
Contrôlez également la taille produite :
docker images mon-application
Une image anormalement volumineuse révèle souvent des dépendances de développement, des caches, des artefacts ou un contexte mal filtré.
Réduire la taille et la surface d’attaque
N’installez que les paquets réellement nécessaires. Avec apt, utilisez --no-install-recommends, supprimez les listes de paquets dans la même instruction et évitez de conserver les caches :
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Le nettoyage doit avoir lieu dans le même RUN que l’installation. Sinon, les fichiers supprimés peuvent rester présents dans une couche antérieure.
Regrouper des commandes cohérentes peut réduire les données persistantes, mais un Dockerfile lisible reste préférable à une instruction gigantesque. La meilleure réduction vient généralement du retrait des dépendances inutiles et de l’utilisation d’une construction multi-stage.
Scannez régulièrement les images avec l’outil de votre registre, de votre plateforme CI/CD ou un scanner dédié. Reconstruisez-les lorsque l’image de base reçoit des correctifs, même si le code applicatif n’a pas changé.
Comprendre CMD et ENTRYPOINT
CMD définit une commande ou des arguments par défaut. Elle est facilement remplacée au lancement :
CMD ["node","server.js"]
docker run --rm mon-application:1.0.0 node scripts/migrate.js
ENTRYPOINT fixe l’exécutable principal. Les arguments fournis à docker run lui sont ajoutés :
ENTRYPOINT ["node","cli.js"]
CMD ["--help"]
Sans argument, le conteneur exécute node cli.js --help. Avec docker run mon-application:1.0.0 version, il exécute node cli.js version.
Pour une application web, CMD suffit généralement. Pour une image conçue comme un outil en ligne de commande, la combinaison ENTRYPOINT et CMD offre une interface plus naturelle.
Déployer l’image
Une fois testée, l’image peut être utilisée avec Docker Compose, envoyée vers un registre privé ou déployée sur une plateforme comme Coolify. Le même artefact doit circuler entre les environnements afin d’éviter une reconstruction différente en production.
Docker Compose facilite la définition des ports, volumes, réseaux, variables d’environnement et services associés. Un registre centralise les versions, tandis qu’une plateforme de déploiement automatise leur récupération, le démarrage des conteneurs et les contrôles de santé.
Pour un hébergement en France, l’image pourra également être déployée sur les futurs VPS TalCloud, avec des serveurs situés en France, un cadre adapté au RGPD, du stockage NVMe, une protection Anti-DDoS Netrix et un support humain. Le Dockerfile reste indépendant de l’hébergeur : une image correctement construite conserve le même comportement sur le poste local, dans la CI/CD et sur l’infrastructure cible.