Chapitre 920 minIntermédiaire
Construire et publier une image Docker dans la CI
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- écrire un Dockerfile multi-étapes adapté à la production ;
- construire, tester et publier une image avec GitHub Actions ;
- choisir des étiquettes d'image fixes et traçables ;
- construire une image dans GitLab CI avec Docker-in-Docker.
4 exercices corrigés
Construire une image Docker dans la CI, c'est confier au pipeline la fabrication de l'image à partir du Dockerfile : il la construit sur une machine neuve, l'étiquette avec le commit ou le numéro de version, la teste, l'analyse et la pousse dans un registre. Cette image unique est ensuite déployée en recette puis en production. Ce chapitre écrit un Dockerfile adapté à la CI, construit et teste l'image dans GitHub Actions, la publie dans le registre de GitHub avec les actions officielles de Docker, puis fait de même avec GitLab CI. Les bases de Docker (images, Dockerfile, Compose) sont dans le cours Conteneurs et Docker, qui aborde aussi Docker dans la CI/CD côté infrastructure.
Pourquoi livrer une image
Une image Docker contient l'application et tout ce dont elle a besoin pour tourner : la bonne version de Node.js, les bibliothèques système, les dépendances installées (les bases de Docker). Livrer une image plutôt que des fichiers règle trois problèmes d'un coup :
- la même chose partout : l'image testée dans la CI est celle qui tourne en recette et en production, octet pour octet ;
- un déploiement simple : sur le serveur, plus besoin d'installer Node.js ni de lancer
npm ci; on récupère l'image et on la démarre ; - un retour arrière immédiat : l'image de la version précédente est toujours dans le registre, il suffit de la redémarrer (chapitre 12).
Un Dockerfile pour la CI
# syntax=docker/dockerfile:1
# Étape 1 : installer les dépendances de production
FROM node:24-slim AS dependances
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Étape 2 : l'image finale, sans outils de développement
FROM node:24-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=dependances /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "src/server.js"]
| Choix | Pourquoi |
|---|---|
node:24-slim plutôt que node:24 | Une image de base plus petite (Debian réduite) : moins de paquets, donc moins de failles et un téléchargement plus rapide |
| Construction en deux étapes (multi-stage) | L'image finale ne contient que ce qui sert à l'exécution |
COPY package.json package-lock.json avant COPY src | Docker met en cache chaque instruction : tant que les dépendances ne changent pas, npm ci n'est pas rejoué |
npm ci --omit=dev | Pas de Jest, d'ESLint ni de Prettier en production |
USER node | Le processus ne tourne pas en root : une faille dans l'application donne moins de pouvoir à l'attaquant |
HEALTHCHECK | Docker vérifie que l'API répond ; l'image slim n'a pas curl, d'où l'appel fetch de Node.js |
CMD ["node", "src/server.js"] | La forme tableau lance Node.js directement : il reçoit les signaux d'arrêt de Docker et s'arrête proprement |
Le fichier .dockerignore, à côté du Dockerfile, exclut ce qui ne doit pas partir dans le contexte de construction :
node_modules
.git
.github
.env
.env.*
coverage
reports
*.md
Construire et tester l'image dans la CI
Docker est préinstallé sur les runners Ubuntu de GitHub. Avant de publier quoi que ce soit, on peut construire l'image et vérifier qu'elle démarre :
jobs:
tester-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Construire l'image
run: docker build -t sorbier-frais:test .
- name: Démarrer le conteneur
run: docker run -d --name api -p 3000:3000 -e DB_HOST=exemple.invalid sorbier-frais:test
- name: Test de fumée
run: |
curl --fail --silent --show-error --retry 10 --retry-delay 2 --retry-all-errors \
http://127.0.0.1:3000/health
- name: Journaux du conteneur en cas d'échec
if: failure()
run: docker logs api
Ce job vérifie ce que les tests unitaires ne voient pas : le Dockerfile construit, l'application démarre dans l'image, toutes ses dépendances sont présentes. La route /health de sorbier-frais répond même sans base de données ; une route qui vérifie aussi la base (/health/ready) se teste avec un service MySQL.
Choisir un registre
Un registre stocke les images et les distribue aux serveurs.
| Registre | Adresse | Authentification dans la CI |
|---|---|---|
| GitHub Container Registry | ghcr.io/proprietaire/image | GITHUB_TOKEN avec packages: write, aucun secret à créer |
| Docker Hub | docker.io/compte/image | Jeton d'accès Docker Hub enregistré comme secret |
| Registre de GitLab | registry.gitlab.com/groupe/projet | CI_REGISTRY_USER et CI_REGISTRY_PASSWORD, fournis automatiquement |
| Registres des clouds | AWS ECR, Azure ACR, Google Artifact Registry | OIDC, de préférence (chapitre 8) |
| Harbor | Auto-hébergé dans l'entreprise | Compte robot |
Pour un projet sur GitHub, ghcr.io est le plus simple : le jeton du workflow suffit. Une image publiée est privée par défaut ; le serveur qui la récupère doit s'authentifier, ou on la rend publique dans les réglages du paquet.
Étiqueter les images
Une étiquette (tag) est un nom donné à une image : ghcr.io/sorbier/sorbier-frais:2.4.0. Une même image peut porter plusieurs étiquettes.
| Étiquette | Exemple | Mobile ou fixe | Usage |
|---|---|---|---|
| Empreinte du commit | sha-3f9c2e1 | Fixe | Savoir exactement quel code tourne, déployer en recette |
| Version sémantique complète | 2.4.0 | Fixe (par convention) | Déployer une version publiée en production |
| Version partielle | 2.4, 2 | Mobile : suit le dernier correctif | Pour ceux qui acceptent les mises à jour mineures |
| Branche | main | Mobile | La dernière construction de main |
latest | latest | Mobile | Commodité pour essayer ; jamais pour déployer |
| Empreinte du contenu (digest) | @sha256:9b2f… | Immuable, garanti par le registre | La référence la plus sûre pour la production |
Le workflow complet : construire, étiqueter, publier
name: Image Docker
on:
push:
branches: [main]
tags: ['v*']
pull_request:
permissions:
contents: read
env:
IMAGE: ghcr.io/sorbier/sorbier-frais
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
- name: Préparer Buildx
uses: docker/setup-buildx-action@v4
- name: Se connecter au registre
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Calculer les étiquettes
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE }}
tags: |
type=sha
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Construire et publier
uses: docker/build-push-action@v7
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
| Étape | Rôle |
|---|---|
permissions: packages: write | Autorise le jeton à pousser dans ghcr.io, pour ce job seulement |
docker/setup-buildx-action | Prépare Buildx, le moteur de construction moderne de Docker (cache, plusieurs architectures) |
docker/login-action | Se connecte au registre ; ignorée pour les pull requests, qui ne publient rien |
docker/metadata-action | Calcule les étiquettes selon l'événement et ajoute des étiquettes OCI (source, commit, date) |
docker/build-push-action | Construit, et pousse si push vaut true |
cache-from et cache-to: type=gha | Garde les couches construites dans le cache de GitHub Actions : une construction sans changement de dépendances prend quelques secondes |
Les étiquettes produites selon l'événement :
| Événement | Étiquettes |
|---|---|
| Pull request n° 42 | pr-42 et sha-… (construites, pas poussées) |
push sur main au commit 3f9c2e1 | main, sha-3f9c2e1 |
Tag v2.4.0 | 2.4.0, 2.4, latest, sha-… |
Pour un tag de version, docker/metadata-action ajoute aussi latest par défaut (réglage flavor: latest=auto). Ce n'est pas gênant tant qu'on ne déploie jamais latest ; pour ne pas la produire du tout, ajoute flavor: latest=false dans le with: de l'étape.
L'adresse de l'image est écrite en minuscules dans env.IMAGE : un registre refuse les majuscules, et le nom d'un compte GitHub peut en contenir.
Analyser l'image avant de la déployer
Après la construction, un scanner d'images (Trivy, Grype, Docker Scout) compare les paquets de l'image aux bases de vulnérabilités (analyse des images). On le règle pour faire échouer le pipeline sur les failles critiques corrigeables, et on publie le rapport comme artefact. Si tu utilises une action tierce pour cela, épingle-la sur une empreinte de commit : c'est justement un scanner de ce type dont les étiquettes ont été détournées en 2026.
Avec GitLab CI
GitLab construit l'image dans un conteneur Docker qui parle à un service Docker-in-Docker (docker:dind) :
image-docker:
stage: build
image: docker:29-cli
services:
- docker:29-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
script:
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
image-version:
stage: build
image: docker:29-cli
services:
- docker:29-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
script:
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
rules:
- if: $CI_COMMIT_TAG
DOCKER_TLS_CERTDIR active la connexion chiffrée entre le client et le service Docker. Le mot de passe passe par --password-stdin pour ne pas apparaître dans la liste des processus. Docker-in-Docker demande un runner en mode privilégié : c'est le cas des runners partagés de GitLab.com, mais l'équipe qui gère un runner interne peut préférer un outil de construction sans démon Docker, comme Buildah.
Les pièges classiques
| Symptôme | Cause | Correction |
|---|---|---|
| La construction envoie « 850 Mo de contexte » et dure longtemps | node_modules et .git partent dans le contexte | Un .dockerignore |
npm ci est rejoué à chaque construction | COPY . . avant npm ci : le moindre changement de code invalide le cache | Copier package*.json, npm ci, puis le code |
exec format error au démarrage sur le serveur | Image construite sur un Mac à processeur ARM, serveur en x86-64 | Construire dans la CI (runners x86-64), ou --platform linux/amd64, ou une image multi-architecture |
denied: permission_denied: write_package au push | Le job n'a pas packages: write | Ajouter la permission au job |
| Une image différente en recette et en production | L'image est reconstruite pour la production | Construire une fois, promouvoir la même image (chapitre 11) |
| Un scanner signale 200 failles | Image de base complète et ancienne | Image slim, reconstruction régulière, Dependabot sur le Dockerfile |
Exercices
Exercice 1 sur 4 Corriger un Dockerfile Réussi À revoir
Trouve les cinq problèmes de ce Dockerfile destiné à la production.
FROM node:latest
WORKDIR /app
COPY . .
ENV DB_PASSWORD=Sorbier2026!
RUN npm install
EXPOSE 3000
CMD npm start
Voir la réponseMasquer la réponse de l'exercice 1
node:latest: la version change sans prévenir et l'image de base est complète. Utiliser une version fixe et réduite :node:24-slim.COPY . .avant l'installation : chaque modification du code rejoue l'installation des dépendances, et sans.dockerignore,node_modules,.gitet.envpartent dans l'image. Copier d'abordpackage.jsonetpackage-lock.json, installer, puis copier le code, avec un.dockerignore.ENV DB_PASSWORD=…: un secret inscrit dans l'image, lisible par quiconque la récupère. Le fournir au démarrage du conteneur, et changer ce mot de passe.npm install: versions non garanties et dépendances de développement incluses. Utilisernpm ci --omit=dev.- Le conteneur tourne en
root: ajouterUSER node.
Bonus : CMD npm start (forme texte) lance un shell, puis npm, puis Node.js, qui reçoit mal le signal d'arrêt ; préférer CMD ["node", "src/server.js"].
Tu avais trouvé ?
Exercice 2 sur 4 Quelles étiquettes ? Réussi À revoir
Avec le workflow complet de ce chapitre, quelles étiquettes sont poussées dans le registre dans chaque cas ?
- Hugo ouvre la pull request n° 57.
- La pull request est fusionnée ; le commit de fusion commence par
a41d0c9. - Lina pousse le tag
v2.5.0sur ce même commit.
Voir la réponseMasquer la réponse de l'exercice 2
- Aucune : l'image est construite (on vérifie que le
Dockerfilefonctionne), maispushvautfalsepour une pull request. Les étiquettes calculées,pr-57etsha-…, ne sont pas poussées. mainetsha-a41d0c9.2.5.0,2.5,latestetsha-a41d0c9: pour un tag de version, l'action ajoutelatestpar défaut (flavor: latest=auto). C'est le même commit : l'étiquettesha-a41d0c9désigne la même image.
Tu avais trouvé ?
Exercice 3 sur 4 Lire un échec de publication Réussi À revoir
Le job image échoue sur l'étape Construire et publier :
#12 exporting to image
#12 pushing layers 0.4s done
#12 ERROR: failed to push ghcr.io/sorbier/sorbier-frais:main: denied: permission_denied: write_package
ERROR: failed to build: failed to solve: failed to push ghcr.io/sorbier/sorbier-frais:main: denied: permission_denied: write_package
Error: buildx failed with: ERROR: failed to build: failed to solve: failed to push ghcr.io/sorbier/sorbier-frais:main: denied: permission_denied: write_package
Le workflow commence par permissions: contents: read et le job n'a pas de bloc permissions. Explique et corrige.
Voir la réponseMasquer la réponse de l'exercice 3
La construction a réussi ; c'est l'envoi au registre qui est refusé : write_package signifie que le jeton n'a pas le droit d'écrire un paquet. Le bloc permissions du workflow ne donne que contents: read, donc packages vaut none pour tous les jobs. On ajoute au job :
permissions:
contents: read
packages: write
Si l'erreur persiste, vérifier dans les réglages du paquet sur GitHub que le dépôt a bien accès au paquet (Manage Actions access), ce qui arrive quand le paquet a été créé à la main ou par un autre dépôt.
Tu avais trouvé ?
Exercice 4 sur 4 « Ça marche sur mon Mac » Réussi À revoir
Hugo construit l'image sur son MacBook à puce Apple, la pousse à la main, et la démarre sur le serveur de recette (processeur x86-64). Le conteneur s'arrête aussitôt ; docker logs affiche exec /usr/local/bin/docker-entrypoint.sh: exec format error. Explique, et dis pourquoi le pipeline évite ce problème.
Voir la réponseMasquer la réponse de l'exercice 4
L'image a été construite pour l'architecture du Mac (linux/arm64) ; le serveur en linux/amd64 ne peut pas exécuter ses binaires, d'où exec format error. Construite par le pipeline sur un runner x86-64, l'image a l'architecture du serveur. De plus, aucune image ne doit être poussée à la main : seules les images construites et testées par le pipeline vont dans le registre, avec une étiquette qui renvoie au commit. Si l'équipe a des serveurs des deux architectures, Buildx construit une image multi-architecture (platforms: linux/amd64,linux/arm64).
Tu avais trouvé ?