Ouvrez un job deploy-staging et un job deploy-prod dans le même pipeline : souvent, on y trouve deux étapes docker build qui se ressemblent trait pour trait. Même Dockerfile, même checkout, une ligne d’écart dans le YAML. Ça ressemble à de la cohérence. C’est en réalité l’inverse : le pipeline construit l’image deux fois, une par environnement, et rien ne garantit que les deux builds produisent les mêmes octets. Ce qui a passé la suite de tests en staging et ce qui tourne en production sont, techniquement, deux artefacts distincts qui partagent un Dockerfile.

Ce que veut dire « build once »

La correction n’est pas un achat d’outillage, c’est un changement d’ordonnancement : construire l’image exactement une fois, la stocker, puis la faire circuler entre environnements en la ré-étiquetant, jamais en la reconstruisant depuis la source. En vocabulaire OCI, une image est adressée par son digest, un hash SHA-256 de son manifeste. Deux builds à partir d’une source identique peuvent atterrir sur des digests différents, parce qu’un résolveur de dépendances a choisi un correctif plus récent, qu’une image de base a bougé sous un tag flottant, ou qu’un horodatage s’est glissé dans une couche. Le digest est la seule réponse honnête à la question « est-ce bien la même image » : s’il correspond, c’est prouvé ; sinon, aucune ressemblance de Dockerfile n’y change quoi que ce soit.

Promouvoir, dans ce modèle, n’est jamais un rebuild. C’est copier ou ré-étiqueter le même digest dans l’espace de nommage du registre suivant :

# .github/workflows/build.yml — on construit une fois, on capture le digest
- name: Build and push
  id: build
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: registry.example.com/app:${{ github.sha }}

- name: Record digest for later promotion
  run: echo "digest=${{ steps.build.outputs.digest }}" >> "$GITHUB_OUTPUT"
# promote.sh — exécuté au déploiement staging, puis à nouveau, inchangé, au déploiement prod
DIGEST="sha256:1e9c...a41f"
crane copy registry.example.com/app@"$DIGEST" registry.example.com/app:staging
# plus tard, même digest, aucun nouveau build :
crane copy registry.example.com/app@"$DIGEST" registry.example.com/app:prod

docker buildx imagetools create fait le même travail si vous préférez rester dans le CLI Docker. Dans les deux cas, les manifestes de déploiement de chaque environnement référencent un digest, ou au minimum un tag basé sur le commit qui n’a jamais été réécrit, jamais :latest.

Le piège : un rebuild qui change discrètement ce qui part en prod

Le mode de défaillance que le build-once évite est silencieux par nature : rien ne plante, le second build produit juste quelque chose de légèrement différent du premier. Deux causes classiques.

Une image de base flottante. FROM node:22 résout vers ce que pointe node:22 sur le registre aujourd’hui. Reconstruisez le même Dockerfile une semaine plus tard, vous obtenez une couche de base différente, avec ses propres bibliothèques patchées et, à l’occasion, ses propres régressions.

Une résolution de dépendances non verrouillée. Une étape de build qui lance npm install plutôt que npm ci, ou pip install -r requirements.txt sans hashes, recalcule le graphe de dépendances au moment du build. Un correctif publié entre le build de staging et le rebuild de prod change ce qui atterrit réellement dans l’image, sans que rien ne le signale.

Épinglez l’image de base par digest (FROM node:22@sha256:...), commitez le lockfile, et installez exactement depuis lui (npm ci, pip install --require-hashes). Ça referme l’écart entre « le Dockerfile est identique » et « l’image est identique », qui est précisément ce dont dépend le build-once.

Le test qui le prouve

L’affirmation « on déploie ce qu’on a testé » est falsifiable en une commande. Comparez le digest qui tourne en production avec celui qu’a réellement exercé la CI :

kubectl get pods -n prod -l app=my-app \
  -o jsonpath='{.items[0].status.containerStatuses[0].imageID}'

Si ce digest n’est pas exactement celui que votre suite de tests a validé, le pipeline ne fait pas ce que son nom laisse croire, aussi verts que soient les checks.

Le comportement propre à chaque environnement doit bien atterrir quelque part : simplement, jamais dans le build. Configuration, feature toggles et secrets se lisent au démarrage du conteneur, via des variables d’environnement, une config montée, ou un service de configuration, jamais figés dans l’image à la construction. Cette séparation est aussi ce qui rend cohérente l’Infrastructure as Code d’un environnement à l’autre : le même artefact, décrit par les mêmes manifestes, ne diffère que par les valeurs injectées autour.

Ce qu’il faut vérifier dans votre propre pipeline

Cherchez dans vos fichiers de workflow combien de fois docker build (ou équivalent) apparaît par mise en production. La bonne réponse est une fois. Si c’est davantage, tout ce qui suit ce premier build, chaque test, chaque scan, chaque validation, atteste d’un artefact qu’une étape ultérieure ne livrera pas réellement. C’est exactement le genre d’écart que la conception d’un pipeline CI/CD est censée fermer, et c’est l’un des premiers points à auditer quand on industrialise une chaîne CI/CD : avant de toucher aux runners ou au cache, vérifier si le pipeline construit une fois, ou s’il ment sur ce point.