Aller au contenu

Optimiser ses Dockerfiles : multi-stage et bonnes pratiques

Docker te permet de créer un env isolé de ton application. Tu vas pouvoir la lancer ou tu veux, tu peux être sur qu'elle fonctionnera. C'est donc pratique pour distribuer. C'est d'ailleurs le moyen utilisé si tu veux deployer ton application sur un cluster Kubernetes, ou via Helm pour faire des templates très utile, ou encore via les outils des cloud provider (AWS Fargate, Azure container apps, Google cloud run, etc.)

Tools pré-requis

  • Docker desktop (ou Rancher desktop)

Créer un simple Dockerfile

Frontend app en mode dév

Ici un basique docker file qui expose une application en React. Celle-ci est servie en mode développement, donc pour des tests internes seulement. Tu ne dois pas distribuer cette application.

FROM node:24-alpine AS client-builder
WORKDIR /app

COPY client/package*.json .
RUN npm ci
COPY client/. ./

EXPOSE 5173

CMD ["npm", "run", "dev", "--", "--host"]
  • L1. On défini l'image sur lequel on travaille. Bossant sur une application React, il nous faut obligatoirement NodeJS.
  • L2. Ici on défini dans quel répertoire de l'image définie précédemment on bosse
  • L4. On copy les fichiers package.json et package-lock.json qui nous permettent de savoir quel module node on a besoin pour faire fonctionner le projet
  • L5. On lance un install des modules nécéssaire
  • L6. On copie le projet dans notre workdir de notre image
  • L8. On expose un port pour notre app docker (5173, le port par défaut du serveur de dev Vite)
  • L10. Commande lancé au lancement de notre container, ici on veut simplement lancer notre app via le script dev. L'option --host fait écouter Vite sur toutes les interfaces, sinon il n'est pas joignable depuis l'extérieur du container

Frontend app en mode prod

Je te propose ici de faire une seconde version que l'on va build en mode production.

FROM node:24-alpine AS client-builder
WORKDIR /app

COPY client/package*.json .
RUN npm ci
COPY client/. ./

RUN npm run build

RUN npm install -g serve

EXPOSE 3000

CMD ["serve", "-s", "dist"]
  • L8. On rajoute ici la commande pour builder notre application afin de faire une prod
  • L10. On installe serve, en mode global. Cela nous permet de servir notre build de production.

Ici je te propose de servir ton application via le module serve. Libre à toi de partir sur un autre comme http-server pour ne citer qu'un exemple. Une autre méthode encore : tu peux aussi partir d'une autre image, FROM nginx:alpine par exemple, et servir avec les bons arguments dans CMD ["nginx", "-g", "daemon off;"] pour servir ton application depuis un NGINX.

Backend app

Si tu fais un backend basique sous ExpressJS par exemple, tu n'as pas besoin, comme pour le frontend, de build ton application, étant donné que celle-ci est faite en pur JS et non en TypeScript ou en fichiers .tsx qui contiennent des fonctionnalités propres à React. Tu as donc juste à lancer le main de ton serveur via node index.js

FROM node:24-alpine AS server
ENV NODE_ENV=production
WORKDIR /app

COPY package*.json .
COPY index.js .
COPY openapi.yaml .

RUN npm ci --omit=dev

USER node

EXPOSE 5000

CMD ["node", "/app/index.js"]
  • L2 : NODE_ENV=production active les optimisations de production d'Express et de pas mal de libs (messages d'erreur moins verbeux, cache des vues, etc.)
  • L5 : on copie les fichiers package.json et package-lock.json
  • L6 : on copie le fichier d'entrée de notre serveur
  • L7 : on copie notre OpenAPI qui spec tous nos endpoints du serveur pour validation des requêtes et réponses
  • L9 : on installe uniquement les dépendances de prod, sans tes outils de lint ou de test (--omit=dev)
  • L11 : on bascule sur le user node (uid 1000), un user non privilégié déjà présent dans l'image officielle. Sans cette ligne, ton container tourne en root : c'est exactement ce que Trivy te remonte lors de l'analyse de ton Dockerfile
  • L13 : on ouvre le port 5000 de notre container
  • L15 : on lance le main de notre serveur

Info

Attention, ici je code mon backend en mode CommonJS (cjs) qui ne nécessite donc pas de build. Mais pour profiter des nouvelles features et des imports style ES, tu peux très bien coder façon EcmaScript Module (esm). Pas besoin de bundler pour autant : NodeJS exécute nativement l'ESM, il suffit d'ajouter "type": "module" dans ton package.json (ou d'utiliser l'extension .mjs). Un bundler (esbuild, Rollup, etc.) ne devient utile que si tu écris en TypeScript ou souhaites produire un fichier unique.

Optimiser son Dockerfile

Choix de l'image : Alpine Linux, Distroless, Scratch...

Tu penses faire un bon choix de partir sur une image NodeJS pure si tu fais une application React comme vu précédemment. Mais il existe des images qui embarque à la fois NodeJS, mais qui se veulent bien plus légère en plus d'être d'avantage maintenu en terme de sécurité. Je pense notemment aux distribution Alpine Linux.
Example:

  • node:24 => ~410mo (taille compressée sur Docker Hub, amd64)
  • node:24-alpine => ~60mo

Soit une image environ 6 fois plus légère.

Tu peux aussi partir de l'image scratch. Celle-ci est une image complètement vide. Pratique si tu dois faire des binaires, comme par exemple développer une application en Golang (Go)

Entre les deux, tu as les images Distroless de Google : elles n'embarquent que le runtime de ton langage et ses dépendances système, sans shell ni package manager. Moins de binaires, c'est moins de CVE remontées par ton scanner et une surface d'attaque réduite. Pour NodeJS, c'est par exemple gcr.io/distroless/nodejs24-debian12 (~53mo compressée en amd64, basée sur Debian donc glibc, là où Alpine utilise musl). Son tag :nonroot tourne directement avec un user non privilégié (uid 65532).

Pas de shell en Distroless

L'ENTRYPOINT de l'image est déjà node : ton CMD ne contient que le script à lancer, par exemple CMD ["index.js"]. Et sans shell, oublie le docker exec -it <container> sh pour débugger : utilise le tag :debug (ou :debug-nonroot), qui embarque un shell busybox.

Multi-stage

Tu peux encore optimiser la taille de ton image en utilisant le multi-staging. Le but est de composer ton Dockerfile en une multitude d'étapes. Cela permet de n'embarquer dans ton image finale que ce dont tu as besoin comme tools, framework et binaire. Tu évites ainsi tout cache des package managers quand tu ajoutes une extension, ou autre fichier temporaire que tu aurais pu oublier.

On repprend l'example d'une application React servi par un ExpressJS :

FROM node:24-alpine AS client-builder
WORKDIR /app
COPY client/package*.json .
RUN npm ci
COPY client/. ./
RUN npm run build


FROM node:24-alpine AS server-builder
WORKDIR /app
COPY server/package*.json .
RUN npm ci --omit=dev
COPY server/. ./


FROM node:24-alpine AS server
ENV NODE_ENV=production
WORKDIR /app
COPY --from=client-builder /app/dist ./dist
COPY --from=server-builder /app/index.js index.js
COPY --from=server-builder /app/openapi.yaml openapi.yaml
COPY --from=server-builder /app/node_modules ./node_modules

USER node

EXPOSE 5000

CMD ["node", "/app/index.js"]
  • L1-6 : Un premier stage client-builder qui permet d'installer les dépendances et de build notre application en production
  • L9-13 : Un second stage server-builder qui permet d'installer que les dépendances de prod pour servir le front
  • L16-22 : On sélectionne que le strict nécessaire pour le client-builder, à savoir le dossier /dist. On récupère dans le server-builder seulement le index.js qui est le serveur Express, son openapi.yaml (chargé au démarrage par le validateur OpenAPI, sans lui chaque requête part en erreur 500) et ses dépendances de prod.
  • L24 : On bascule sur le user non privilégié node, comme pour le backend
  • L26 : On ouvre le port 5000 où tourne notre application
  • L28 : On sert ton app React via ExpressJS

Variante Distroless

Tu peux remplacer le stage final par une image Distroless. Plus besoin de USER : le tag :nonroot s'en charge.

FROM gcr.io/distroless/nodejs24-debian12:nonroot AS server
ENV NODE_ENV=production
WORKDIR /app
COPY --from=client-builder /app/dist ./dist
COPY --from=server-builder /app/index.js index.js
COPY --from=server-builder /app/openapi.yaml openapi.yaml
COPY --from=server-builder /app/node_modules ./node_modules

EXPOSE 5000

CMD ["index.js"]

Note

Pas besoin pour le client-builder de définir à la main la récupération des dépendances de prod. Celles-ci sont transpilées et packagées dans le build par ton bundler (Vite, webpack, etc.).

Pour aller plus loin