fr

GitHub Actions pour builder et déployer des images Docker : guide complet

Comment fonctionnent les GitHub Actions, buildx et BuildKit, les stratégies de déploiement, la gestion des secrets, les registres Docker, le cache de layers et l'optimisation des builds multi-stage.


Comment fonctionnent les GitHub Actions

Le modèle événementiel

GitHub Actions est une plateforme CI/CD construite sur une architecture événementielle. La chaîne fondamentale est :

Événement  →  Workflow  →  Job(s)  →  Step(s)  →  Action ou commande shell

Un événement est une activité spécifique dans un repository qui déclenche un workflow :

  • Événements du repo : push, pull_request, issues, release, create, delete, etc. Beaucoup supportent un filtrage par types (ex. pull_request: types: [opened, synchronize, reopened]).
  • Planifiés : syntaxe cron POSIX via on.schedule (intervalle minimum : 5 minutes).
  • Manuels : workflow_dispatch (avec des inputs typés : choice, boolean, number, string, environment) et via l’API REST.
  • Appels de workflows réutilisables : workflow_call pour composer des workflows.
  • Chaînage : workflow_run se déclenche quand un autre workflow se termine.

Les événements peuvent être filtrés par :

  • Branches/tags : branches, branches-ignore, tags, tags-ignore (avec des patterns glob).
  • Chemins : paths, paths-ignore (ex. paths: ['**.js']). Limité aux 300 premiers fichiers modifiés et 1 000 commits dans le diff.

Un workflow est un processus automatisé configurable défini dans un fichier YAML dans .github/workflows/. Un repo peut avoir plusieurs workflows.

Un job est un ensemble de steps qui s’exécutent sur le même runner. Par défaut les jobs tournent en parallèle. Les dépendances entre jobs se déclarent avec le mot-clé needs.

Un step est une unité de travail individuelle au sein d’un job. Les steps s’exécutent séquentiellement sur le même runner. Chaque step est soit une commande shell (via run), soit une action réutilisable (via uses). Les steps partagent le filesystem du runner.

Une action est une unité de code pré-définie et réutilisable (ex. actions/checkout clone le repo, actions/setup-node installe Node.js). Elles viennent du GitHub Marketplace, du même repo, d’autres repos publics, ou d’images Docker.

L’environnement d’exécution : les runners

Un runner est un serveur (VM ou conteneur) qui exécute les jobs. Chaque job tourne sur un runner fraîchement provisionné — un environnement propre à chaque fois.

GitHub-hosted vs self-hosted

AspectGitHub-hostedSelf-hosted
GestionEntièrement géré par GitHubVous gérez la machine
ProvisionnementVM neuve par jobMachine persistante
OS disponiblesUbuntu, Windows, macOSN’importe quel OS avec le runner installé
Limite de temps par job6 heures5 jours
CoûtMinutes incluses par plan (gratuit pour les repos publics)Gratuit à utiliser, vous payez le hardware

Specs des runners standard (repos publics)

RunnerCPURAMSSD
Linux (standard)4 cores16 GB14 GB
Linux arm644 cores16 GB14 GB
Windows4 cores16 GB14 GB
macOS (Intel)4 cores14 GB14 GB
macOS (Apple Silicon)3 cores (M1)7 GB14 GB

Pour les repos privés, les specs Linux/Windows x64 sont réduites : 2 cores, 8 GB RAM.

Les runners GitHub-hosted incluent des logiciels pré-installés (Git, Docker sur Linux, runtimes de langages, outils de build). La liste complète est dans le repo actions/runner-images.

Les larger runners (plans Team et Enterprise Cloud) offrent plus de CPU, des GPU, des IPs statiques et des images custom.

À quoi un runner a-t-il accès ?

Workspace

  • GITHUB_WORKSPACE : répertoire de travail par défaut où actions/checkout clone le repo.
  • Chaque job a un workspace frais. Les steps au sein du même job partagent le filesystem.
  • RUNNER_TEMP : répertoire temporaire nettoyé avant et après chaque job.

GITHUB_TOKEN

Un token généré automatiquement, de courte durée, disponible dans chaque workflow :

  • Accessible via ${{ secrets.GITHUB_TOKEN }} ou ${{ github.token }}.
  • Scopé au repo qui exécute le workflow.
  • Permissions configurables au niveau workflow et job via la clé permissions.
  • Scopes disponibles : actions, checks, contents, deployments, id-token, issues, packages, pages, pull-requests, security-events, statuses — chacun à read, write, ou none.
  • Rate limit API : 1 000 requêtes/heure/repo.

Secrets

  • Stockés chiffrés (Libsodium sealed box) au niveau organisation, repo, ou environnement.
  • Accessibles via ${{ secrets.SECRET_NAME }}.
  • Jamais affichés dans les logs (automatiquement masqués).
  • Disponibles pour le runner uniquement pendant l’exécution.

Variables d’environnement

Variables custom à trois scopes : workflow (env:), job (jobs.<id>.env:), step (steps[*].env:). Précédence : Step > Job > Workflow.

Variables par défaut clés :

VariableDescription
GITHUB_WORKSPACERépertoire de checkout
GITHUB_SHASHA du commit déclencheur
GITHUB_REFRef de branche ou tag
GITHUB_REPOSITORYowner/repo
GITHUB_RUN_IDID unique du run
RUNNER_OSLinux, Windows, ou macOS
RUNNER_ARCHX86, X64, ou ARM64

Artifacts

Fichiers persistés après la fin d’un workflow (outputs de build, résultats de tests, logs). Upload via actions/upload-artifact, download via actions/download-artifact. Partageables entre jobs via needs:.

Limites de stockage par plan (par mois) :

PlanStockage
Free500 MB
Pro1 GB
Team2 GB
Enterprise50 GB

Cache

Cache des dépendances entre workflow runs via actions/cache ou le caching intégré des actions setup-*.

  • Clé de cache : jusqu’à 512 caractères, avec restore-keys pour le matching partiel.
  • Taille limite : 10 GB par repo par défaut.
  • Éviction : entrées non accédées depuis 7 jours supprimées ; quand le repo atteint sa limite, les entrées les plus anciennes sont évincées.
  • Scope : un workflow peut restaurer des caches de la branche courante, de la branche par défaut (main), et (pour les PRs) de la branche de base. Il ne peut pas accéder aux caches des branches enfants ou sœurs.

Réseau

  • Accès internet sortant complet (HTTP/HTTPS, SSH, etc.).
  • Infrastructure Azure.
  • ICMP bloqué (ping et traceroute ne fonctionnent pas).
  • /etc/hosts bloque les pools de minage de crypto et les sites malveillants connus.

Relations entre jobs

Parallélisme (par défaut)

Les jobs tournent en parallèle par défaut. Trois jobs sans dépendances démarrent simultanément.

Dépendances (needs)

jobs:
  build:
    runs-on: ubuntu-latest
    steps: [...]
  test:
    needs: build # attend que 'build' réussisse
    runs-on: ubuntu-latest
    steps: [...]
  deploy:
    needs: [build, test] # attend les deux
    runs-on: ubuntu-latest
    steps: [...]

Un job avec needs attend que tous les jobs listés réussissent. Pour exécuter même en cas d’échec, utiliser if: always().

Les outputs d’un job peuvent être passés aux jobs dépendants via jobs.<id>.outputs.

Stratégie matrix

Exécute le même job avec plusieurs combinaisons de variables :

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]
  fail-fast: true # annule les restants si un échoue (défaut : true)
  max-parallel: 4 # limite les jobs simultanés

Ceci crée 6 jobs (2 OS × 3 Node). Maximum : 256 jobs matrix par workflow.

Limites de jobs concurrents par plan

PlanTotal concurrentLimite macOS
Free205
Pro405
Team605
Enterprise50050

Contrôle de concurrence

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

Garantit qu’un seul workflow/job avec le même groupe de concurrence tourne à la fois.

Structure YAML d’un workflow

# ─── Clés de premier niveau ─────────────────────────────
name: CI Pipeline # Nom affiché dans l'onglet Actions
run-name: Deploy by @${{ github.actor }} # Nom custom pour chaque run

on: # Déclencheur(s)
  push:
    branches: [main, "release/**"]
    paths: ["src/**"]
  pull_request:
    branches: [main]
    types: [opened, synchronize]
  schedule:
    - cron: "30 5 * * 1-5" # Jours de semaine à 05:30 UTC
  workflow_dispatch:
    inputs:
      environment:
        type: choice
        options: [staging, production]

permissions: # Permissions GITHUB_TOKEN (global workflow)
  contents: read
  pull-requests: write

env: # Variables d'environnement globales
  NODE_ENV: production

defaults: # Shell/working-directory par défaut
  run:
    shell: bash
    working-directory: ./app

concurrency: # Empêcher les runs en double
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

# ─── Jobs ────────────────────────────────────────────────
jobs:
  build:
    name: Build Application
    runs-on: ubuntu-latest # Choix du runner
    timeout-minutes: 30 # Timeout du job
    permissions: # Permissions token au niveau job
      contents: read
    environment: staging # Environnement de déploiement
    env: # Variables d'env du job
      BUILD_TYPE: release
    outputs: # Passer des données aux jobs dépendants
      version: ${{ steps.ver.outputs.version }}

    strategy: # Stratégie matrix
      matrix:
        node: [18, 20]
        os: [ubuntu-latest, windows-latest]

    container: # Exécuter les steps dans un conteneur Docker
      image: node:18
      ports:
        - 8080:80

    services: # Conteneurs sidecar (bases de données, etc.)
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432

    steps:
      - name: Checkout code
        uses: actions/checkout@v4 # Utiliser une action publiée

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with: # Paramètres d'entrée pour l'action
          node-version: ${{ matrix.node }}
          cache: npm

      - name: Install dependencies
        run: npm ci # Commande shell

      - name: Get version
        id: ver # ID du step pour référencer les outputs
        run: echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT"

      - name: Run tests
        run: npm test
        env: # Variables d'env du step
          CI: true
        continue-on-error: true # Ne pas faire échouer le job si ce step échoue
        timeout-minutes: 10

      - name: Conditional step
        if: github.ref == 'refs/heads/main'
        run: echo "On main branch"

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 5

  deploy:
    name: Deploy
    needs: build # Attend que 'build' réussisse
    runs-on: ubuntu-latest
    if: github.event_name == 'push'

    steps:
      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: build-output

      - name: Use output from build
        run: echo "Deploying version ${{ needs.build.outputs.version }}"

Expressions : ${{ <expression> }} avec opérateurs ==, !=, &&, ||, !.

Contextes disponibles : github, env, vars, secrets, inputs, job, jobs, steps, matrix, needs, strategy, runner.

Fonctions intégrées : contains(), startsWith(), endsWith(), format(), hashFiles(), toJSON(), fromJSON(), always(), success(), failure(), cancelled().

Références d’actions (uses) :

  • Action publiée : actions/checkout@v4
  • SHA spécifique : actions/checkout@a81bbbf...
  • Image Docker : docker://alpine:3.18
  • Action locale : ./path/to/action

Workflows réutilisables : appelés via jobs.<id>.uses: owner/repo/.github/workflows/file.yml@ref. Nesting jusqu’à 4 niveaux. Passer des inputs via with: et des secrets via secrets: (ou secrets: inherit).

Limites clés résumées

RessourceLimite
Temps d’exécution d’un job (GitHub-hosted)6 heures
Temps d’exécution d’un job (self-hosted)5 jours
Durée d’un workflow run35 jours
Temps d’attente en queue avant annulation24 heures
Jobs matrix par workflow256
Cache par repo10 GB
Éviction du cacheAprès 7 jours sans accès
Rate limit API GITHUB_TOKEN1 000 req/h/repo

Buildx et BuildKit

Qu’est-ce que BuildKit ?

BuildKit est le moteur de build d’images conteneurs de nouvelle génération, développé par le projet Moby (le projet open-source derrière Docker). Il remplace entièrement l’ancien backend de docker build. BuildKit est le builder par défaut depuis Docker Desktop et Docker Engine v23.0+.

À la base, BuildKit utilise un format LLB (Low-Level Build) — un graphe de dépendances content-addressable qui sert de représentation binaire intermédiaire. Un composant frontend (typiquement le frontend Dockerfile, distribué comme image conteneur) convertit les Dockerfiles en instructions LLB.

Qu’est-ce que buildx ?

Buildx est un plugin CLI Docker (docker buildx) qui expose toutes les fonctionnalités de BuildKit via une interface familière type docker build. La relation clé :

Buildx utilise toujours BuildKit. Buildx est la couche CLI, BuildKit est le moteur d’exécution.

AspectLegacy docker builddocker buildx build
BackendBuilder legacy (séquentiel)BuildKit (graphe concurrent)
Multi-plateformeNon supportéSupport complet (QEMU, cross-compilation, nœuds multiples)
Export/import de cacheNon supportéRegistry, local, S3, GHA, inline, Azure
Drivers de buildDocker daemon seulementdocker, docker-container, kubernetes, remote
Instances de builderUn seul par défautMultiples builders isolés
ParallélismeExécution séquentielle des layersÉtapes indépendantes totalement concurrentes
Stages non utilisésConstruits quand mêmeAutomatiquement ignorés
Tracking du cacheHeuristiques sur timestampsChecksums content-addressable

Fonctionnalités clés de buildx

Builds multi-plateforme

Les images multi-plateforme utilisent une manifest list pointant vers plusieurs manifests spécifiques à une architecture. Quand on pull, le registre sélectionne automatiquement la bonne variante pour l’hôte.

docker buildx build --platform linux/amd64,linux/arm64 -t myimage:latest --push .

Trois stratégies :

  1. Émulation QEMU — Le plus simple à mettre en place. Aucun changement au Dockerfile requis. Attention : peut être significativement plus lent pour les tâches intensives en calcul. Docker Desktop embarque QEMU ; sur Linux :

    docker run --privileged --rm tonistiigi/binfmt --install all
  2. Nœuds natifs multiples — Ajouter des nœuds hardware réels à un builder avec --append. Chaque nœud build nativement pour son architecture. Meilleure performance, plus d’infrastructure à gérer.

  3. Cross-compilation — Utilise les build args prédéfinis (BUILDPLATFORM, TARGETPLATFORM, TARGETOS, TARGETARCH). Pin les images de base avec FROM --platform=$BUILDPLATFORM. Évite totalement l’émulation. Spécifique au langage (ex. GOOS/GOARCH en Go).

Backends de cache

BuildKit supporte six backends de stockage de cache :

BackendDescriptionIdéal pour
InlineEmbarque les métadonnées de cache dans l’image de sortieWorkflows simples single-image
RegistryStocke le cache comme image séparée dans un registreCI/CD multi-branches avec cache partagé
LocalÉcrit le cache sur le filesystem localDéveloppement, builds single-machine
GitHub Actions (gha)Utilise l’API cache native de GHAPipelines GitHub Actions
S3Stockage S3 AWSDéploiements enterprise cloud-native
Azure Blob StorageStockage AzureDéploiements enterprise Azure
docker buildx build --push -t registry/image:tag \
  --cache-from type=registry,ref=registry/cache-image \
  --cache-to type=registry,ref=registry/cache-image,mode=max .

Le mode min (défaut) stocke uniquement le cache des layers finaux. Le mode max stocke le cache de tous les layers intermédiaires — plus gros mais indispensable pour les builds multi-stage.

Drivers de build

DriverFonctionnementQuand l’utiliser
dockerUtilise la bibliothèque BuildKit du daemon DockerDéfaut ; builds simples single-plateforme
docker-containerLance un conteneur BuildKit isolé via DockerBuilds multi-plateforme, export de cache, isolation CI/CD
kubernetesCrée des pods BuildKit dans un cluster K8sBuilds distribués à l’échelle enterprise
remoteSe connecte à un daemon BuildKit externeBuildKit opéré indépendamment (infra de build partagée)
docker buildx create --name mybuilder --driver docker-container --use
docker buildx ls          # lister les builders (* marque l'actif)
docker buildx inspect

Alternatives à buildx

OutilSans Docker Daemon ?Sans Dockerfile ?Idéal pour
Buildx/BuildKitBesoin du daemon (ou driver K8s)Non (utilise les Dockerfiles)Usage général, multi-plateforme
Kaniko (Google)OuiNonLegacy (archivé juin 2025)
Podman/Buildah (Red Hat)Oui (daemonless)Optionnel (Buildah)Environnements rootless/daemonless
Jib (Google)OuiOuiProjets Java/JVM
ko (Google)OuiOuiProjets Go
NixOuiOuiBuilds reproductibles bit-for-bit
pack (Cloud Native Buildpacks)Besoin de DockerOuiÉquipes plateforme, polyglotte
Melange + apko (Chainguard)OuiOuiSécurité supply-chain

Kaniko : construit des images dans des conteneurs/pods K8s sans daemon Docker. Archivé par Google depuis juin 2025. Pour les nouveaux projets, BuildKit avec le driver Kubernetes est le remplacement recommandé.

Podman/Buildah : moteur de conteneur daemonless et rootless par design. Buildah offre un contrôle plus fin sur les layers — peut committer plusieurs changements en un seul layer.

Jib : construit des images optimisées pour Java directement depuis Maven ou Gradle, sans Docker installé. Sépare intelligemment dépendances, ressources et classes en layers distincts.

ko : builder d’images OCI natif pour Go. Pas de Dockerfile. Produit des images minimales (base distroless + binaire Go). Idéal pour Go + Kubernetes.

Nix : traite les builds comme des fonctions pures de leurs entrées. Images conteneurs reproductibles bit-for-bit.

pack : transforme le code source en images OCI automatiquement via des buildpacks. Auto-détecte le langage et le framework. Projet CNCF, origine Heroku.

Buildx dans GitHub Actions

Docker fournit une suite d’actions officielles qui fonctionnent ensemble :

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # 1. (Optionnel) Checkout — uniquement si contexte local
      - uses: actions/checkout@v4

      # 2. (Optionnel) QEMU pour l'émulation cross-plateforme
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      # 3. Configurer buildx
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # 4. Se connecter au registre
      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      # 5. Build et push
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          push: true
          platforms: linux/amd64,linux/arm64
          tags: user/app:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

docker/setup-buildx-action : crée et boot une instance de builder BuildKit. Utilise le driver docker-container par défaut, ce qui permet les builds multi-plateforme et l’export de cache.

docker/build-push-action : exécute le docker buildx build en utilisant le builder configuré par setup-buildx-action. Inputs clés : context, file, push, load, tags, platforms, build-args, secrets, cache-from, cache-to.

Comment elles fonctionnent ensemble :

  1. setup-buildx-action crée un builder driver docker-container et le rend disponible.
  2. build-push-action détecte le builder actif et l’utilise avec toutes les capacités BuildKit.
  3. setup-qemu-action (si inclus) enregistre les handlers QEMU pour émuler les architectures non-natives.
  4. login-action stocke les credentials du registre pour le push.

Le backend de cache GHA (type=gha) s’intègre directement avec l’infrastructure de cache native de GitHub Actions, évitant le besoin d’un stockage externe.


Déployer : du code mergé à la production

Jusqu’ici on a vu comment GitHub Actions construit des images Docker. Mais construire une image ne sert à rien si elle reste dans un registre. La deuxième moitié du pipeline — le déploiement — pose des questions fondamentalement différentes : comment mettre à jour une application en production sans interrompre le service ? Comment s’assurer qu’on peut revenir en arrière si quelque chose se passe mal ? Comment empêcher un déploiement accidentel en production un vendredi soir ?

Cette section part de la vue d’ensemble (comment structurer un pipeline complet) et descend progressivement dans les détails : les stratégies de déploiement, les outils de GitHub pour contrôler qui déploie quoi et quand, et les différentes approches selon l’infrastructure cible.

Anatomie d’un pipeline CI/CD

Avant de parler de stratégies de déploiement, il faut comprendre la structure générale d’un pipeline. Un pipeline CI/CD se découpe en deux phases distinctes :

  • CI (Continuous Integration) : vérifier que le code est correct. C’est la partie lint, tests, build de l’image.
  • CD (Continuous Deployment/Delivery) : amener le code vérifié en production. C’est la partie déploiement.

La séparation est importante parce que les deux phases n’ont pas les mêmes contraintes. Le CI doit être rapide et donner du feedback au développeur. Le CD doit être fiable et contrôlé — on ne veut pas la même permissivité pour “lancer les tests sur une PR” et “déployer en production”.

Un pipeline typique enchaîne ces étapes :

lint → test → build image → push au registre → deploy staging → smoke tests → deploy production

Chaque flèche est une gate : si l’étape échoue, le pipeline s’arrête. On ne construit pas d’image si les tests échouent. On ne déploie pas en production si les smoke tests sur staging ne passent pas.

Smoke test ? Le terme vient de l’électronique : quand on branche un circuit pour la première fois, si ça fume, c’est qu’il y a un problème fondamental. Un smoke test est un test rapide et superficiel qui vérifie que les fonctionnalités essentielles marchent après un déploiement : l’app répond en HTTP 200, la page de login s’affiche, l’API retourne une réponse valide sur un endpoint clé, la connexion à la base de données fonctionne. On ne teste pas tout en détail — on détecte les pannes grossières (l’app ne démarre pas, le serveur renvoie des 500, une variable d’environnement manque) avant d’aller plus loin.

Quelques principes structurants :

Build once, deploy many. L’image construite à l’étape “build” est la même qui sera déployée en staging, puis en production. On ne rebuild jamais entre environnements. Cela garantit qu’on teste exactement ce qu’on va déployer — pas une version recompilée qui pourrait différer subtilement.

Fail fast. Les étapes les plus rapides et les plus susceptibles d’échouer passent en premier. Le linting prend quelques secondes et attrape les erreurs évidentes ; les tests unitaires prennent une minute ; le build de l’image prend plusieurs minutes. Inutile de construire une image de 2 GB si un import est cassé.

Pipeline PR vs pipeline deploy. Sur une pull request, on exécute lint + test seulement — le but est de donner du feedback rapide au développeur. Le pipeline complet (build + push + deploy) ne se déclenche qu’au merge sur main.

Voici ce que ça donne dans un workflow GitHub Actions :

name: CI/CD Pipeline
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      - run: npm test

  # Tout ce qui suit ne tourne que sur main, pas sur les PRs
  build-and-push:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    needs: test
    outputs:
      image-tag: ${{ github.sha }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: |
            ghcr.io/org/myapp:${{ github.sha }}
            ghcr.io/org/myapp:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy-staging:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    steps:
      - name: Deploy
        run: ./deploy.sh staging ${{ needs.build-and-push.outputs.image-tag }}

  smoke-tests:
    needs: deploy-staging
    runs-on: ubuntu-latest
    steps:
      - run: ./smoke-test.sh https://staging.example.com

  deploy-production:
    needs: smoke-tests
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    steps:
      - name: Deploy
        run: ./deploy.sh production ${{ needs.build-and-push.outputs.image-tag }}

Points à noter : le tag d’image (github.sha) est passé entre les jobs via outputs et needs — on ne le recalcule pas, on ne rebuild pas. Et le if sur build-and-push fait que les PRs ne déclenchent que lint et test.

Les environments GitHub : contrôler qui déploie quoi

Dans le workflow ci-dessus, les jobs deploy-staging et deploy-production déclarent un environment. Ce n’est pas cosmétique — les environments sont un mécanisme de GitHub pour ajouter des gardes-fous autour des déploiements.

Un environment se configure dans Settings > Environments du repository. On y définit :

Des règles de protection qui conditionnent l’exécution du job :

  • Reviewers requis — Jusqu’à 6 personnes ou équipes doivent approuver avant que le job puisse s’exécuter. Concrètement, quand le pipeline atteint le job de déploiement, il se met en pause et envoie une notification aux reviewers. Le pipeline ne reprend qu’après approbation. L’option “prevent self-review” empêche la personne qui a poussé le code de s’auto-approuver.
  • Timer d’attente — Un délai (en minutes) avant que le job puisse s’exécuter, même après approbation. Utile pour laisser le temps de réagir en cas de doute.
  • Restriction par branches — Seules certaines branches peuvent déclencher un déploiement vers cet environment. Par exemple, seul main peut déployer en production, mais n’importe quelle branche feature/* peut déployer en staging.

Des secrets et variables scopés à l’environnement. Staging et production ont typiquement des URLs de base de données, des clés API et des credentials différents. Plutôt que de gérer ça avec des conditionnels dans le workflow (if env == production then ...), on définit un secret DATABASE_URL dans chaque environment avec des valeurs différentes. Le workflow reste identique — c’est l’environment qui injecte les bonnes valeurs. Et ces secrets ne sont accessibles qu’une fois les règles de protection passées : un job en staging ne verra jamais les secrets de production.

En pratique, la configuration minimale recommandée :

  • Un environment staging sans reviewers requis (déploiement automatique au merge sur main).
  • Un environment production avec au moins un reviewer requis et une restriction sur la branche main.

Comment remplacer l’ancienne version par la nouvelle

Maintenant qu’on sait quand et qui déclenche un déploiement, reste la question du comment. Quand on déploie une nouvelle version d’une application, il faut bien basculer de l’ancienne à la nouvelle. Cette transition est le moment critique : c’est là que les choses peuvent casser. Il existe quatre grandes stratégies, chacune avec un compromis différent entre simplicité, vitesse et sécurité.

Recreate : le plus simple

On arrête toutes les instances de l’ancienne version, puis on démarre les nouvelles. Pendant la transition, l’application est indisponible.

C’est la stratégie à utiliser quand le downtime n’est pas un problème : outils internes, environnements de dev/test, jobs de traitement batch qui ne servent pas de trafic utilisateur. Son avantage est sa simplicité absolue : pas de souci de compatibilité entre versions, pas de routage de trafic à gérer, un état propre à chaque déploiement.

Rolling update : le défaut raisonnable

Plutôt que de tout arrêter d’un coup, les instances sont remplacées progressivement — quelques-unes à la fois. Si on a 10 instances, on en arrête 2, on démarre 2 nouvelles, on vérifie qu’elles sont saines (via un health check — une URL que l’application expose pour dire “je suis prête”), puis on passe aux 2 suivantes, et ainsi de suite.

C’est la stratégie par défaut dans Kubernetes (le type RollingUpdate des Deployments) et dans la plupart des orchestrateurs de conteneurs (AWS ECS, etc.). Elle ne nécessite aucune infrastructure supplémentaire et offre un déploiement sans interruption de service.

La contrepartie : pendant le déploiement, l’ancienne et la nouvelle version coexistent et servent du trafic simultanément. Si la nouvelle version change un format d’API ou un schéma de base de données de manière incompatible, ça peut poser problème. Il faut donc maintenir une compatibilité ascendante entre versions successives — par exemple, ajouter un champ avant de l’utiliser, plutôt que de le renommer d’un coup.

L’autre inconvénient : le rollback (retour à la version précédente) n’est pas instantané. Il faut relancer un rolling update en sens inverse, ce qui prend le même temps que le déploiement initial.

Blue-green : le rollback instantané

On maintient deux environnements identiques, appelés par convention “blue” et “green”. À tout moment, l’un sert le trafic réel (disons blue), l’autre est inactif (green). Pour déployer :

  1. On déploie la nouvelle version sur l’environnement inactif (green).
  2. On teste green complètement — tests d’intégration, smoke tests, vérification manuelle si besoin.
  3. On bascule le trafic de blue vers green (typiquement en changeant un DNS, un load balancer, ou une règle de routage).
  4. Si quelque chose ne va pas, on rebascule sur blue en quelques secondes.

L’avantage majeur est le rollback instantané : blue est toujours là, intacte, prête à reprendre le trafic. C’est la stratégie de choix pour les applications critiques où chaque minute de panne coûte cher.

L’inconvénient est le coût : on maintient en permanence le double de l’infrastructure. Et les migrations de base de données demandent une coordination particulière, puisque les deux environnements partagent souvent la même base de données — il faut que le schéma soit compatible avec les deux versions pendant la transition.

Canary : le test en conditions réelles

Le déploiement canary (du nom des canaris dans les mines de charbon, qui servaient de sentinelles) expose la nouvelle version à un petit sous-ensemble du trafic réel — par exemple 5% des requêtes — avant de la généraliser.

Le processus est progressif :

  1. On déploie la nouvelle version sur une fraction des instances (ou on configure un routage pondéré).
  2. On observe les métriques : taux d’erreur, latence, utilisation mémoire.
  3. Si tout est stable, on augmente progressivement le pourcentage : 5% → 25% → 50% → 100%.
  4. Si les métriques dégradent, on redirige tout le trafic vers l’ancienne version.

Cette stratégie offre la validation la plus fiable puisqu’on teste avec du vrai trafic. Mais elle a un coût d’entrée : il faut un mécanisme de routage de trafic pondéré (un service mesh comme Istio ou Linkerd, ou un ingress controller configuré pour le split de trafic) et une infrastructure de monitoring suffisamment mature pour détecter rapidement les anomalies. Sans métriques fiables, un canary est inutile — on ne saurait pas quand augmenter le pourcentage ni quand faire marche arrière.

Quelle stratégie choisir ?

CritèreRecreateRollingBlue-GreenCanary
DowntimeOuiNonNonNon
Coût infrastructurex1x1x2x1 + routage
Vitesse de rollbackLenteLenteInstantanéeRapide
ComplexitéMinimaleFaibleMoyenneÉlevée
PrérequisAucunHealth checksLoad balancer configurableService mesh + monitoring

En pratique : rolling update par défaut — c’est le choix raisonnable qui convient à la majorité des cas. Blue-green quand le rollback instantané est critique (paiement, auth). Canary quand on a une infrastructure d’observabilité mature et un trafic suffisant pour que les métriques aient du sens.

Où déployer : les différentes cibles

La stratégie de déploiement (rolling, blue-green, etc.) est un concept abstrait. Concrètement, le “comment” dépend de l’infrastructure sur laquelle tourne l’application. Voici les cas les plus courants et comment GitHub Actions s’y intègre.

Kubernetes (EKS, GKE, AKS, self-hosted)

Kubernetes est un orchestrateur de conteneurs : il gère le cycle de vie des conteneurs sur un cluster de machines. Quand on dit “déployer sur Kubernetes”, on met à jour un objet Deployment pour pointer vers la nouvelle image, et Kubernetes se charge d’appliquer la stratégie choisie (rolling update par défaut).

- name: Configure kubectl
  uses: azure/setup-kubectl@v3
- name: Set context
  uses: azure/k8s-set-context@v3
  with:
    kubeconfig: ${{ secrets.KUBECONFIG }}
- name: Deploy
  run: |
    kubectl set image deployment/myapp \
      myapp=ghcr.io/org/myapp:${{ github.sha }}
    kubectl rollout status deployment/myapp --timeout=300s

La commande kubectl set image change le tag de l’image dans le Deployment. kubectl rollout status attend que Kubernetes ait fini de remplacer les anciens pods par les nouveaux, et échoue si le timeout est dépassé (utile pour faire échouer le pipeline en cas de problème).

Pour l’authentification, on utilise OIDC (décrit dans la section Secrets) via aws-actions/configure-aws-credentials pour Amazon EKS ou google-github-actions/auth pour Google GKE, plutôt que de stocker un kubeconfig long-lived dans les secrets.

Les services managés les plus courants : Amazon EKS, Google GKE, Azure AKS. Ils fournissent le control plane Kubernetes ; vous gérez les workloads.

AWS ECS (Elastic Container Service)

ECS est l’orchestrateur de conteneurs propre à AWS — une alternative à Kubernetes, plus simple mais spécifique à l’écosystème AWS. On définit une task definition (qui décrit les conteneurs à lancer) et un service (qui maintient N instances de cette task).

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789:role/deploy-role
    aws-region: us-east-1
- name: Deploy to ECS
  uses: aws-actions/amazon-ecs-deploy-task-definition@v1
  with:
    task-definition: task-def.json
    service: my-service
    cluster: my-cluster
    wait-for-service-stability: true

wait-for-service-stability: true fait que le step attend que le service ait fini sa mise à jour (rolling update par défaut dans ECS) avant de rendre la main.

GCP Cloud Run

Cloud Run est un service serverless de Google Cloud : on lui donne une image Docker et il gère tout le reste (scaling, load balancing, TLS). Pas de cluster à gérer, pas de Kubernetes à comprendre. On paie à l’utilisation.

- name: Authenticate to GCP
  uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
    service_account: ${{ secrets.SA_EMAIL }}
- name: Deploy to Cloud Run
  uses: google-github-actions/deploy-cloudrun@v2
  with:
    service: my-service
    region: us-central1
    image: gcr.io/${{ secrets.GCP_PROJECT }}/myapp:${{ github.sha }}

Cloud Run gère automatiquement le rolling update : les nouvelles instances reçoivent du trafic seulement quand elles sont prêtes, les anciennes sont drainées progressivement. Il supporte aussi nativement le traffic splitting (canary) via la CLI gcloud.

VPS via SSH

Pour les cas plus simples — un serveur unique ou une petite infra sans orchestrateur — on peut déployer en SSH. Le pattern classique : le CI construit et pousse l’image au registre, puis se connecte au serveur pour puller et relancer les conteneurs.

- name: Deploy via SSH
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.SSH_HOST }}
    username: ${{ secrets.SSH_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      cd /opt/myapp
      docker compose pull
      docker compose up -d --remove-orphans

L’important est de toujours puller des images pré-construites plutôt que cloner le repo et builder sur le serveur. Builder en production, c’est consommer des ressources qui devraient servir le trafic, et ça introduit un risque d’échec de build qui laisse l’application dans un état intermédiaire.

L’approche GitOps : séparer le CI du CD

Les exemples ci-dessus montrent un pattern “push” : le CI construit l’image, puis pousse activement le déploiement vers l’infrastructure cible. Ça fonctionne, mais il existe une autre philosophie appelée GitOps, particulièrement répandue dans l’écosystème Kubernetes.

L’idée centrale de GitOps : on ne déploie jamais directement depuis le CI. À la place, l’état désiré de l’infrastructure est déclaré dans un repository Git dédié (le “repo GitOps”), sous forme de manifests Kubernetes (YAML). Un opérateur installé dans le cluster surveille ce repo en permanence et réconcilie l’état réel du cluster avec ce qui est décrit dans Git.

Le flux devient :

Push sur main
  → CI : lint, test, build, push image au registre
  → CI : met à jour le tag d'image dans le repo GitOps (un commit automatique)
  → L'opérateur détecte le nouveau commit dans le repo GitOps
  → L'opérateur applique les changements au cluster

L’avantage est que Git devient la source de vérité unique : on sait exactement ce qui tourne en production en regardant le repo GitOps. Les rollbacks sont un simple git revert. L’audit est intégré (l’historique Git). Et le CI n’a jamais besoin d’accès direct au cluster — il ne fait que modifier un fichier YAML dans un repo.

Les deux outils dominants :

ArgoCD fournit un dashboard web riche, la gestion multi-cluster, un système de RBAC enterprise, et de la SSO. Le CI fait un commit dans le repo GitOps, ArgoCD détecte le changement et synchronise le cluster :

# Dernière étape du CI — après le push de l'image
- name: Update image tag in GitOps repo
  run: |
    git clone https://x-access-token:${{ secrets.GITOPS_TOKEN }}@github.com/org/gitops-repo.git
    cd gitops-repo/apps/myapp/overlays/staging
    kustomize edit set image myapp=ghcr.io/org/myapp:${{ github.sha }}
    git add . && git commit -m "Update myapp to ${{ github.sha }}"
    git push

FluxCD est plus léger : pas de dashboard séparé, configuration entièrement via des CRDs Kubernetes (des objets YAML natifs K8s). Il peut même surveiller directement le registre d’images et détecter automatiquement les nouveaux tags sans que le CI ait besoin de committer dans le repo GitOps.

Le choix entre les deux dépend du contexte : ArgoCD quand on a besoin de visibilité (dashboards, multi-cluster, RBAC), FluxCD quand on préfère une approche légère et automation-first. Dans les deux cas, la bonne pratique est de séparer le repo de code source du repo de manifests GitOps :

gitops-repo/
  apps/
    myapp/
      base/                  # Manifests K8s de base (Kustomize)
        deployment.yaml
        service.yaml
        kustomization.yaml
      overlays/
        staging/             # Surcharges pour staging
          kustomization.yaml
        production/          # Surcharges pour production
          kustomization.yaml
  infrastructure/
    cert-manager/
    ingress-nginx/
    monitoring/

Identifier ses images : pourquoi :latest est un piège

Quel que soit le mode de déploiement, il y a une question transversale : comment identifier de manière fiable l’image qu’on déploie ? La tentation est d’utiliser le tag :latest — après tout, c’est “la dernière version”. Mais :latest est un tag mutable : il est écrasé à chaque push. Cela cause des problèmes concrets :

  • Déploiements non-déterministes — Sur Kubernetes, si un pod redémarre et pull :latest, il peut obtenir une image différente de celle des autres pods du même Deployment. L’application se retrouve avec des instances hétérogènes.
  • Rollback impossible — Si :latest est le seul tag, l’image précédente est perdue dès qu’on en pousse une nouvelle. Impossible de revenir en arrière.
  • Ambiguïté de cache — Le runtime du conteneur peut utiliser un :latest local qui ne correspond plus au :latest du registre, selon la politique de pull configurée (imagePullPolicy).
  • Pas de traçabilité — En cas d’incident, impossible de déterminer quel commit exact tourne en production.

La pratique recommandée est le dual-tag : chaque image reçoit un tag immutable lié au commit (le SHA Git) et, pour les releases, un tag sémantique lisible par un humain :

tags: |
  ghcr.io/org/myapp:${{ github.sha }}
  ghcr.io/org/myapp:v1.4.2
Type de tagExempleRôle
Git SHAa1b2c3dImmutable, unique, traçable au commit exact
Version sémantiquev1.4.2Lisible, utile pour les changelogs et la communication
Combiné1.4.2-ga1b2c3dLe meilleur des deux : version + traçabilité
latestlatestAcceptable en dev local pour la commodité, à bannir partout ailleurs

L’action docker/metadata-action automatise la génération de ces tags à partir du contexte Git :

- name: Docker meta
  id: meta
  uses: docker/metadata-action@v5
  with:
    images: ghcr.io/org/myapp
    tags: |
      type=sha,prefix=
      type=semver,pattern={{version}}
      type=ref,event=branch

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    labels: ${{ steps.meta.outputs.labels }}

Certains registres (AWS ECR, Harbor) permettent d’activer l’immutabilité des tags : une fois un tag poussé, il ne peut plus être écrasé. C’est une garantie au niveau infrastructure que v1.4.2 référencera toujours exactement la même image.


Gestion des secrets

Les secrets GitHub Actions

Les secrets GitHub Actions sont chiffrés au repos (Libsodium sealed box) et ne sont déchiffrés qu’au démarrage d’un workflow run. Ils sont automatiquement masqués dans les logs — si la valeur d’un secret apparaît dans l’output, GitHub la remplace par ***.

Trois niveaux de secrets

NiveauScopeQui peut créerNotes
RepositoryTous les workflows du repoUtilisateurs avec accès writeLe plus courant
EnvironmentScopé à un environnement spécifique (ex. production, staging)Accès admin requisPeut exiger des reviewers avant l’exécution du job
OrganizationPartagé entre plusieurs repos avec politiques d’accès configurablesAdmins de l’organisationPas dispo pour les repos privés sur GitHub Free

Référencer les secrets

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: En input d'action
        uses: some-action@v1
        with:
          api-key: ${{ secrets.API_KEY }}

      - name: En variable d'environnement
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: ./deploy.sh

Points de sécurité importants

  • Les secrets des repos forkés ne sont PAS passés aux workflows (sauf GITHUB_TOKEN).
  • Les workflows réutilisables n’héritent pas automatiquement des secrets — il faut les passer explicitement ou utiliser secrets: inherit.
  • Éviter de passer des secrets via les arguments de ligne de commande (visibles via /proc ou le monitoring de processus). Utiliser des variables d’environnement ou stdin.
  • Taille max d’un secret : 48 KB. Pour des payloads plus gros, utiliser le chiffrement GPG ou l’encodage Base64 avec un passphrase stocké comme secret séparé.

Passer des secrets aux builds Docker

Construire une image Docker nécessite souvent des secrets : un token NPM pour accéder à un registre privé, des credentials AWS pour télécharger un asset depuis S3, une clé SSH pour cloner un repo Git privé. Le réflexe naturel est de passer ces secrets en build arguments (ARG). C’est une erreur de sécurité.

Pourquoi --build-arg est dangereux

Les build arguments (ARG) et variables d’environnement (ENV) persistent dans l’image finale. Chaque instruction Dockerfile crée un layer, et les layers sont empilés pour former l’image. Un ARG utilisé dans un RUN est enregistré dans les métadonnées de ce layer. Un attaquant qui a accès à l’image peut les récupérer trivialement :

docker history --no-trunc <votre-image>

Même si on supprime un fichier contenant un secret dans une instruction ultérieure (RUN rm /tmp/credentials), ça ne change rien : le fichier existe toujours dans le layer précédent, qui fait partie de l’image. L’architecture en layers de Docker est append-only — on ne peut pas effacer rétroactivement le contenu d’un layer antérieur.

# NE FAITES PAS CECI — le secret finit dans l'image
ARG AWS_SECRET_ACCESS_KEY
RUN aws s3 cp s3://my-bucket/data /app/data

Docker intègre d’ailleurs un check de build (SecretsUsedInArgOrEnv) qui avertit quand un ARG ou ENV contient un nom qui ressemble à un secret.

Les secret mounts BuildKit

BuildKit résout ce problème avec les secret mounts : un mécanisme qui rend un secret disponible uniquement en mémoire pendant l’exécution d’une instruction RUN spécifique. Le secret n’est jamais écrit dans un layer, n’apparaît pas dans docker history, et disparaît dès que l’instruction RUN se termine.

Le principe repose sur deux côtés :

  • Côté CLI (celui qui lance le build) : on déclare les secrets à rendre disponibles, avec un identifiant.
  • Côté Dockerfile (celui qui consomme les secrets) : on monte le secret dans l’instruction RUN qui en a besoin, via --mount=type=secret.

Le secret transite de la CLI au Dockerfile via un canal sécurisé de BuildKit, sans jamais toucher le filesystem de l’image.

Consommer un secret comme fichier

Par défaut, un secret monté est accessible comme fichier dans /run/secrets/<id>. C’est le mode le plus courant :

# syntax=docker/dockerfile:1
FROM python:3.12

COPY requirements.txt .
RUN --mount=type=secret,id=pip_conf,target=/etc/pip.conf \
    pip install -r requirements.txt

Ici, le fichier pip.conf (qui contient par exemple l’URL d’un index privé avec credentials) est monté à /etc/pip.conf le temps du pip install. pip le lit normalement, installe les dépendances, et le fichier disparaît. L’image finale contient les paquets installés mais aucune trace du pip.conf.

Si on ne précise pas target, le secret est monté à /run/secrets/<id> :

RUN --mount=type=secret,id=aws_creds \
    AWS_SHARED_CREDENTIALS_FILE=/run/secrets/aws_creds \
    aws s3 cp s3://my-bucket/model.bin /app/model.bin
Consommer un secret comme variable d’environnement

Certains outils attendent un secret dans une variable d’environnement plutôt que dans un fichier. Le paramètre env monte le contenu du secret directement dans une variable, pour la durée du RUN :

RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN \
    npm ci

La variable NPM_TOKEN existe le temps du npm ci puis disparaît. Elle n’est pas dans l’image.

Monter une clé SSH

Pour cloner un repo Git privé pendant le build, BuildKit propose un type de mount dédié pour SSH. Plutôt que copier une clé privée dans l’image (et risquer de la fuiter), on monte l’agent SSH :

# syntax=docker/dockerfile:1
FROM node:20-alpine
RUN --mount=type=ssh \
    git clone git@github.com:org/private-repo.git /src/

Côté CLI, on passe --ssh default pour rendre l’agent SSH local disponible au build.

Comment ça se passe dans GitHub Actions

Dans un workflow GitHub Actions, les secrets sont stockés côté GitHub (chiffrés, masqués dans les logs — voir la section précédente). Il faut les injecter dans le build Docker via l’action docker/build-push-action, qui expose un input secrets prévu à cet effet.

Le mécanisme est le même que la CLI docker build --secret, mais adapté au contexte GitHub Actions : on passe les secrets sous la forme id=valeur, et l’action les transmet à BuildKit.

Voici plusieurs scénarios concrets, chacun avec le Dockerfile et le workflow associé.

Exemple 1 : installer des paquets NPM depuis un registre privé

Beaucoup de projets Node.js utilisent un registre NPM privé (GitHub Packages, Artifactory, Verdaccio) pour des paquets internes. npm ci a besoin d’un token d’authentification pour y accéder.

Le Dockerfile :

# syntax=docker/dockerfile:1
FROM node:20-alpine AS build
WORKDIR /app

COPY package.json package-lock.json .npmrc ./

# Le token est monté comme variable d'env le temps du npm ci
# .npmrc référence ${NPM_TOKEN} — npm le résout automatiquement
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN \
    npm ci

COPY . .
RUN npm run build

# --- Image finale : pas de token, pas de .npmrc, pas de devDependencies ---
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

Le .npmrc associé (commité dans le repo, sans secret) :

//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
@myorg:registry=https://npm.pkg.github.com

Le workflow :

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          secrets: |
            npm_token=${{ secrets.NPM_TOKEN }}

La ligne secrets: | passe le secret au build. L’id (npm_token) correspond à celui déclaré dans le --mount=type=secret,id=npm_token du Dockerfile.

Exemple 2 : télécharger un asset privé depuis S3 pendant le build

Certains builds ont besoin de fichiers stockés dans S3 — un modèle de machine learning, un binaire propriétaire, des données de configuration. Il faut des credentials AWS pour y accéder, mais elles ne doivent surtout pas finir dans l’image.

Le Dockerfile :

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
WORKDIR /app

RUN pip install awscli

# Les deux secrets sont montés comme variables d'env le temps du RUN
RUN --mount=type=secret,id=aws_access_key,env=AWS_ACCESS_KEY_ID \
    --mount=type=secret,id=aws_secret_key,env=AWS_SECRET_ACCESS_KEY \
    aws s3 cp s3://my-bucket/models/v3.bin /app/model.bin

COPY . .
RUN pip install -r requirements.txt

# --- Image finale : le modèle est là, les credentials AWS non ---
FROM python:3.12-slim
WORKDIR /app
COPY --from=build /app /app
CMD ["python", "serve.py"]

Le workflow :

- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
    secrets: |
      aws_access_key=${{ secrets.AWS_ACCESS_KEY_ID }}
      aws_secret_key=${{ secrets.AWS_SECRET_ACCESS_KEY }}

On peut passer autant de secrets que nécessaire — chaque ligne du bloc secrets: | déclare un secret avec son id et sa valeur.

Exemple 3 : monter un fichier de configuration complet

Parfois le secret n’est pas une simple valeur mais un fichier entier : un pip.conf, un settings.xml Maven, un fichier de credentials GCP. L’input secret-files de build-push-action permet de monter un fichier plutôt qu’une valeur scalaire.

Le Dockerfile (projet Python avec index privé) :

# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app

COPY requirements.txt .

# pip.conf monté à son emplacement standard le temps de l'install
RUN --mount=type=secret,id=pip_conf,target=/etc/pip.conf \
    pip install --no-cache-dir -r requirements.txt

COPY . .
CMD ["python", "app.py"]

Le workflow :

- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
    secret-files: |
      pip_conf=./pip.conf

Ici on utilise secret-files au lieu de secrets. La valeur est un chemin vers un fichier local dans le workspace du runner. Ce fichier peut lui-même avoir été généré par un step précédent à partir de secrets GitHub :

- name: Generate pip.conf
  run: |
    cat > pip.conf << EOF
    [global]
    index-url = https://${{ secrets.PYPI_USER }}:${{ secrets.PYPI_TOKEN }}@pypi.mycompany.com/simple/
    EOF

- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
    secret-files: |
      pip_conf=./pip.conf
Exemple 4 : cloner un repo Git privé pendant le build

Pour les dépendances qui ne passent pas par un package manager (un repo Git privé référencé en sous-module, un outil interne), BuildKit permet de monter un agent SSH.

Le Dockerfile :

# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src

# Configurer Git pour utiliser SSH au lieu de HTTPS pour le domaine GitHub
RUN git config --global url."git@github.com:".insteadOf "https://github.com/"

# L'agent SSH est monté le temps du go mod download
RUN --mount=type=ssh \
    go mod download

COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

FROM gcr.io/distroless/static-debian12
COPY --from=build /app/server /app/server
ENTRYPOINT ["/app/server"]

Le workflow — on utilise ssh-agent pour charger la clé, puis on la passe à buildx via ssh :

- uses: webfactory/ssh-agent@v0.9.0
  with:
    ssh-private-key: ${{ secrets.DEPLOY_KEY }}

- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
    ssh: default=${{ env.SSH_AUTH_SOCK }}

L’input ssh de build-push-action passe le socket de l’agent SSH au builder, qui le rend disponible aux instructions RUN --mount=type=ssh du Dockerfile.

Combiner secret mounts et multi-stage

Les secret mounts et les builds multi-stage se complètent. Le secret mount est la protection primaire : il garantit que le secret n’est jamais écrit dans un layer. Le multi-stage ajoute une couche de séparation : même les outils qui ont consommé le secret (le CLI AWS, le fichier .npmrc, etc.) ne sont pas dans l’image finale — seul le dernier stage est expédié.

C’est pour ça que tous les exemples ci-dessus utilisent un multi-stage : les secrets sont consommés dans un stage build, et l’image finale ne contient que les artifacts copiés via COPY --from=build.

Attention : mode=max et les layers intermédiaires en cache

Le multi-stage protège l’image finale — celle qui est poussée au registre et déployée. Mais si on utilise --cache-to mode=max (recommandé plus haut pour optimiser les cache hits sur les multi-stage), tous les layers intermédiaires sont exportés dans le cache, y compris ceux des stages de build.

En fonctionnement normal, ce n’est pas un problème : les secret mounts ne sont pas écrits dans les layers, donc il n’y a rien à fuiter dans le cache.

Mais si un bug dans le Dockerfile écrit accidentellement un secret sur le filesystem normal (par exemple une commande qui dump un token dans un fichier temporaire), ce fichier se retrouve dans un layer intermédiaire. Avec mode=max, ce layer est exporté dans le cache. Et le cache est stocké quelque part accessible : un registre (type=registry), le cache GitHub Actions (type=gha, accessible à quiconque peut lancer un workflow sur le repo), un bucket S3. Dans ce scénario, le multi-stage ne protège plus rien côté cache — la surface d’attaque est la même qu’avec un single-stage.

Concrètement, ça veut dire que le secret mount reste la seule vraie protection. Le multi-stage protège l’image livrée, mais pas le cache. Ce n’est pas une raison d’abandonner le multi-stage (il protège toujours contre la distribution de l’image), ni d’abandonner mode=max (les gains de cache sont réels). C’est une raison de ne jamais compter sur le multi-stage comme filet de sécurité pour les secrets — toute la rigueur doit être dans le --mount=type=secret.

Gestionnaires de secrets externes

HashiCorp Vault

- name: Import Secrets from Vault
  uses: hashicorp/vault-action@v2
  with:
    url: https://vault.example.com:8200
    token: ${{ secrets.VAULT_TOKEN }}
    secrets: |
      secret/data/ci/aws accessKey | AWS_ACCESS_KEY_ID ;
      secret/data/ci/aws secretKey | AWS_SECRET_ACCESS_KEY

Vault supporte aussi l’authentification OIDC avec GitHub Actions, éliminant le besoin de stocker un token Vault comme secret GitHub.

AWS Secrets Manager

- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/github-actions
    aws-region: us-east-1
- uses: aws-actions/aws-secretsmanager-get-secrets@v1
  with:
    secret-ids: |
      my-app/prod/db-creds

GCP Secret Manager

- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: projects/123/locations/global/workloadIdentityPools/my-pool/providers/my-provider
    service_account: my-sa@my-project.iam.gserviceaccount.com
- uses: google-github-actions/get-secretmanager-secrets@v2
  with:
    secrets: |-
      db-password:my-project/db-password

1Password

- uses: 1password/load-secrets-action@v2
  with:
    export-env: true
  env:
    OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
    AWS_ACCESS_KEY_ID: op://DevOps/AWS/access-key-id
    AWS_SECRET_ACCESS_KEY: op://DevOps/AWS/secret-access-key

Doppler

- uses: dopplerhq/secrets-fetch-action@v1
  with:
    doppler-token: ${{ secrets.DOPPLER_TOKEN }}
    doppler-project: my-app
    doppler-config: production

Doppler fournit du versioning pour les secrets, la synchronisation en temps réel, et des workflows collaboratifs.

OIDC : l’authentification sans credentials

GitHub Actions a un fournisseur OIDC intégré à https://token.actions.githubusercontent.com. Le flux :

 Workflow GitHub Actions      Fournisseur OIDC GitHub      Fournisseur cloud
         |                              |                          |
         |--- 1. Demande JWT token ---->|                          |
         |<-- 2. Retourne JWT signé ----|                          |
         |                                                         |
         |--- 3. Présente le JWT -------------------------------->|
         |<-- 4. Retourne credentials cloud temporaires ----------|
         |                                                         |
         |--- 5. Accède aux ressources cloud avec creds temp --->|
  1. Le job demande un JWT au fournisseur OIDC de GitHub.
  2. GitHub retourne un JWT signé contenant des claims sur l’identité du workflow.
  3. Le workflow présente ce JWT au fournisseur cloud.
  4. Le fournisseur cloud valide la signature et les claims, puis émet un token d’accès de courte durée valide uniquement pour la durée du job.
  5. Le workflow utilise les credentials temporaires pour accéder aux ressources cloud.

Claims JWT disponibles pour les politiques de confiance

ClaimDescriptionExemple
subSujet (le plus important pour le trust)repo:octo-org/octo-repo:environment:prod
repositoryNom complet du repoocto-org/octo-repo
repository_ownerOrganisation ou utilisateurocto-org
environmentEnvironnement de déploiementproduction
refRef Gitrefs/heads/main
shaSHA du commitabc123...
workflowFichier workflowdeploy.yml
actorUtilisateur déclencheuroctocat
runner_environmentType de runnergithub-hosted

Configuration OIDC avec AWS

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write # REQUIS pour OIDC
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-role
          aws-region: us-east-1
      # Pas de AWS_ACCESS_KEY_ID ou AWS_SECRET_ACCESS_KEY nécessaire !
      - run: aws s3 ls

Côté AWS, créer un IAM OIDC identity provider pointant vers https://token.actions.githubusercontent.com et un rôle IAM avec une trust policy qui restreint le claim sub :

{
  "Effect": "Allow",
  "Principal": {
    "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
  },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main",
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
    }
  }
}

Configuration OIDC avec GCP

- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/github-pool/providers/github-provider
    service_account: deploy@my-project.iam.gserviceaccount.com

Pourquoi OIDC est supérieur

Approche traditionnelleApproche OIDC
Credentials long-lived stockés comme secrets GitHubAucun credential long-lived nulle part
Rotation manuelle requiseAutomatique — tokens expirent après chaque job
Accès large (quiconque a le secret)Fine-grained (restreint par repo, branche, environnement)
Risque de fuite de credentialsRien à fuiter
Synchronisation de secrets entre environnementsTrust configuré une seule fois côté cloud

Bonnes pratiques de sécurité

Moindre privilège :

  • Utiliser des secrets d’environnement avec reviewers requis pour la production.
  • Restreindre les secrets d’organisation aux seuls repos qui en ont besoin.
  • Avec OIDC, contraindre les trust policies à des repos, branches et environnements spécifiques via le claim sub.
  • Utiliser permissions: dans les workflows pour restreindre GITHUB_TOKEN aux seuls scopes nécessaires.

Rotation :

  • OIDC élimine la rotation pour les credentials cloud — les tokens sont éphémères.
  • Les gestionnaires de secrets externes (Vault, AWS Secrets Manager) supportent la rotation automatique.

Ne jamais hardcoder :

  • Jamais de secrets dans le code source, les Dockerfiles, ou les fichiers de config CI.
  • Jamais de ARG ou ENV pour les secrets dans les Dockerfiles — utiliser --mount=type=secret.
  • Jamais de secrets en arguments de ligne de commande. Utiliser les variables d’environnement ou l’injection par fichier.

Prévention de l’injection de scripts :

# VULNÉRABLE — input utilisateur injecté directement dans le shell
- run: echo "Title: ${{ github.event.pull_request.title }}"

# SÛR — utiliser une variable d'environnement intermédiaire
- env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "Title: $PR_TITLE"

Sécurité fork et PR :

  • Les secrets ne sont PAS exposés aux workflows déclenchés depuis des forks.
  • Attention avec pull_request_target : il s’exécute dans le contexte du repo base avec accès aux secrets, mais checkout le code du fork — exploitable.

Pinner les actions par SHA :

# Bon : pinné à un SHA de commit
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608
# Risqué : un tag peut être déplacé
uses: actions/checkout@v4

Arbre de décision rapide

Besoin d'un secret en CI/CD ?

├── C'est un credential cloud (AWS/GCP/Azure) ?
│   └── OUI → Utiliser OIDC (aucun secret stocké)

├── C'est utilisé pendant un build Docker ?
│   └── OUI → Utiliser BuildKit --mount=type=secret
│             JAMAIS ARG/ENV

├── C'est utilisé à travers plusieurs repos ?
│   └── OUI → Secret d'organisation ou gestionnaire externe (Vault, 1Password, Doppler)

├── C'est spécifique à un environnement (prod vs staging) ?
│   └── OUI → Secret d'environnement avec règles de protection

└── Sinon
    └── Secret de repository, référencé via ${{ secrets.NAME }}

Registres Docker

Comment fonctionne un registre

Un registre de conteneurs est un service centralisé qui stocke, distribue et gère des images conteneur. Les registres implémentent la spécification OCI Distribution, qui définit une API HTTP standard pour le push et le pull.

Structure d’une image (OCI Image Spec) :

Les images sont composées de trois éléments arrangés en un Merkle DAG (graphe acyclique dirigé) :

  1. Index (niveau supérieur) : pointe vers plusieurs manifests d’image, permettant les images multi-plateforme (ex. linux/amd64, linux/arm64).
  2. Manifest : liste la config de l’image et un ensemble ordonné de blobs de layer. Chaque référence est un Content Descriptor contenant le media type, le digest, et la taille en octets.
  3. Layers : changesets du filesystem (archives tar) empilés pour produire le filesystem final. Chaque layer représente des ajouts, modifications, ou suppressions de fichiers.

Mécanisme push/pull :

  1. Push : le client upload chaque blob de layer individuellement (vérifié par digest), puis upload le manifest qui référence ces blobs.
  2. Pull : le client récupère le manifest par tag ou digest, puis télécharge uniquement les layers qu’il n’a pas déjà localement (déduplication par hash de contenu).

Tags vs digests

PropriétéTagDigest
FormatChaîne lisible (ex. v1.2.3, latest)Hash SHA-256 (ex. sha256:eedaff45e3c7...)
MutabilitéMutable — peut être réassigné à un manifest différentImmutable — réfère toujours au même contenu
Cas d’usageCommodité, versioningReproductibilité, sécurité supply-chain

Bonne pratique : pinner les images par digest dans les Dockerfiles de production pour la reproductibilité ; utiliser les tags pour la lisibilité humaine.

Comparatif des registres

RegistreTypeTier gratuitRate limitsPoints forts
Docker HubPublic/Privé1 repo privé, public illimitéNon-auth : 100 pulls/6h ; Personnel : 200 pulls/6h ; Payant : illimitéRegistre par défaut, plus grand écosystème, Images Officielles
GitHub Container Registry (ghcr.io)Public/PrivéGratuit pour les images publiques10 GB par layer, timeout upload 10 minIntégration native GitHub Actions via GITHUB_TOKEN, permissions granulaires
AWS ECRPrivé/Public500 MB/mois privé ; 50 GB/mois publicPas de rate limit pour les users authentifiésIntégration IAM, scan d’images, lifecycle policies, réplication cross-region
GCP Artifact RegistryPrivéPay-as-you-goPas de limites duresMulti-format (Docker, Maven, npm, etc.), géo-redundance par défaut
Azure ACRPrivéTiers Basic/Standard/PremiumThroughput dépendant du tierGéo-réplication (Premium), ACR Tasks pour builds in-registry
Harbor (self-hosted)PrivéGratuit (Apache 2.0)Self-managedScan de vulnérabilités, RBAC, LDAP/OIDC, réplication basée sur des politiques, CNCF graduated

Cache Docker et optimisation des builds

Comment fonctionnent les layers Docker

Chaque instruction dans un Dockerfile crée un layer dans l’image finale. Les layers sont cachés par hash de contenu — si l’instruction et ses inputs n’ont pas changé, le layer caché est réutilisé.

Règle critique : l’invalidation du cache est en cascade. Quand le cache d’un layer est invalidé, tous les layers suivants doivent aussi être reconstruits, même si leur contenu serait identique. C’est pourquoi l’ordre des instructions est crucial.

Déclencheurs d’invalidation du cache :

Type d’instructionMéthode de vérification
COPY, ADD, RUN --mount=type=bindChecksums des métadonnées de fichiers (contenu + permissions ; mtime n’est pas considéré)
RUNComparaison de la chaîne de commande uniquement ; ne détecte pas les changements upstream (ex. nouvelles versions de paquets)
Build secretsNe font pas partie du cache ; les changements de secrets n’invalident pas le cache

Piège : RUN apk add curl utilisera les résultats cachés indéfiniment sauf si vous bustez explicitement le cache avec --no-cache ou --no-cache-filter.

Les backends de cache BuildKit

En CI/CD, où les environnements de build sont éphémères, les backends de cache externes sont essentiels.

Inline

Embarque les métadonnées de cache directement dans l’image de sortie.

docker buildx build --push -t registry/image:tag \
  --cache-to type=inline .

# Import au build suivant :
docker buildx build --push -t registry/image:tag \
  --cache-from type=registry,ref=registry/image:tag .

Simple à mettre en place mais ne scale pas bien avec les multi-stage builds. Supporte uniquement le mode min.

Registry

Stocke le cache comme image séparée dans un registre.

docker buildx build --push -t registry/image:tag \
  --cache-to type=registry,ref=registry/cache-image:tag,mode=max \
  --cache-from type=registry,ref=registry/cache-image:tag .

Séparation complète entre cache et image. Supporte mode=max pour les multi-stage. Fonctionne entre équipes distribuées.

GitHub Actions Cache (type=gha)

Utilise l’API Cache de GitHub nativement — le choix naturel pour les pipelines GitHub Actions.

docker buildx build --push -t registry/image:tag \
  --cache-to type=gha,mode=max \
  --cache-from type=gha .

BuildKit upload/download les blobs de cache via l’API REST de cache GitHub, authentifié automatiquement via les variables ACTIONS_CACHE_URL et ACTIONS_RUNTIME_TOKEN du runner.

Limité à 10 GB par repo. Les entrées sont évincées par âge et patterns d’accès (LRU).

S3

docker buildx build --push -t registry/image:tag \
  --cache-to type=s3,region=us-east-1,bucket=my-cache-bucket,name=myapp \
  --cache-from type=s3,region=us-east-1,bucket=my-cache-bucket,name=myapp .

Pattern multi-source en CI

Pattern critique pour maximiser les cache hits :

docker buildx build --push -t registry/image:latest \
  --cache-from type=registry,ref=registry/cache:$BRANCH \
  --cache-from type=registry,ref=registry/cache:main \
  --cache-to type=registry,ref=registry/cache:$BRANCH,mode=max .

Import le cache de la branche courante d’abord, avec fallback sur main. Maximise les cache hits pour les feature branches.

mode=min vs mode=max

  • min (défaut) : exporte uniquement les layers présents dans l’image finale. Cache plus petit, import/export plus rapide.
  • max : exporte tous les layers intermédiaires (y compris les stages de build). Cache plus gros, taux de hit plus élevé — essentiel pour les builds multi-stage.

Builds multi-stage

Les builds multi-stage utilisent plusieurs instructions FROM dans un seul Dockerfile. Chaque FROM commence un nouveau stage. On copie sélectivement les artifacts entre stages avec COPY --from, en laissant derrière tout ce qui n’est pas nécessaire dans l’image finale.

# Stage 1 : Build
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

# Stage 2 : Production
FROM gcr.io/distroless/static-debian12
COPY --from=build /app/server /app/server
ENTRYPOINT ["/app/server"]

L’image de build peut faire 1 GB+ ; l’image finale peut faire < 10 MB. Les outils de build, le code source, et les artifacts intermédiaires ne sont jamais livrés en production.

Nommer les stages et COPY --from

FROM node:20 AS deps
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

FROM node:20 AS builder
COPY --from=deps /node_modules ./node_modules
COPY . .
RUN yarn build

FROM nginx:alpine
COPY --from=builder /dist /usr/share/nginx/html

On peut aussi copier depuis des images externes :

COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf

Utiliser --target pour des stages spécifiques

# Construire uniquement le stage "builder" (utile pour test/debug)
docker buildx build --target builder -t myapp:dev .

BuildKit ne construit que les stages dont la cible dépend, en ignorant les stages non liés.

Ordre des instructions pour un cache optimal

Mauvais — tout changement de fichier source invalide l’installation des dépendances :

COPY . .
RUN npm install
RUN npm run build

Bon — les dépendances sont cachées séparément du code source :

COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

Ce pattern fonctionne pour tout écosystème :

LangageFichiers de dépendances d’abordCommande d’installPuis copier le source
Node.jspackage.json, package-lock.jsonnpm ciCOPY . .
Pythonrequirements.txt ou pyproject.tomlpip install -r requirements.txtCOPY . .
Gogo.mod, go.sumgo mod downloadCOPY . .
RustCargo.toml, Cargo.lockcargo fetchCOPY . .
Javapom.xml ou build.gradlemvn dependency:resolveCOPY . .

Cache mounts pour les gestionnaires de paquets

Les cache mounts persistent les caches des package managers entre les builds sans les ajouter aux layers d’image. C’est l’optimisation la plus impactante pour les builds avec beaucoup de dépendances.

# Node.js
RUN --mount=type=cache,target=/root/.npm \
    npm ci

# Python
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

# Go
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o /app .

# Rust
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git/db \
    --mount=type=cache,target=/app/target \
    cargo build --release

# apt-get (note : sharing=locked est requis)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && apt-get install -y --no-install-recommends curl

Parallélisation des stages dans BuildKit

BuildKit parallélise automatiquement les stages indépendants. Si deux stages ne dépendent pas l’un de l’autre, ils se construisent en concurrence :

FROM golang:1.23 AS backend
WORKDIR /src
COPY backend/ .
RUN go build -o /app/api .

FROM node:20 AS frontend
WORKDIR /src
COPY frontend/ .
RUN npm ci && npm run build

# Les deux stages ci-dessus se construisent en parallèle !
FROM alpine:3.20
COPY --from=backend /app/api /app/api
COPY --from=frontend /src/dist /app/static

BuildKit reconnaît que backend et frontend sont indépendants et les planifie en concurrence. Le builder legacy les traiterait séquentiellement.

Conseils d’optimisation

.dockerignore

Toujours créer un .dockerignore pour exclure les fichiers qui grossissent le contexte de build et causent une invalidation de cache inutile :

.git
node_modules
dist
*.md
.env
.env.*
**/*.log
__pycache__
.pytest_cache
.vscode
.idea

Minimiser les layers

Combiner les commandes RUN liées avec && :

RUN apt-get update && \
    apt-get install -y --no-install-recommends curl ca-certificates && \
    rm -rf /var/lib/apt/lists/*

Toujours combiner apt-get update avec apt-get install dans le même RUN — les séparer cause des problèmes de cache où l’index des paquets devient périmé.

Syntaxe heredoc pour la lisibilité :

RUN <<EOF
apt-get update
apt-get install -y --no-install-recommends curl
rm -rf /var/lib/apt/lists/*
EOF

Choix des images de base

Image de baseTailleShellPackage managerlibcIdéal pour
Alpine~5-7 MBOui (busybox)apkmuslConteneurs minimalistes ; bon équilibre taille/debuggabilité
Distroless (Google)~2-50 MBNonNonglibcProduction Java, Python, Node.js, .NET ; focus sécurité
Scratch0 bytesNonNonAucuneBinaires Go/Rust compilés statiquement ; taille absolument minimale
Debian slim~75 MBOuiaptglibcQuand vous avez besoin de compatibilité glibc + shell pour debug
Ubuntu~80 MBOuiaptglibcDéveloppement, compatibilité OS complète

Guide de décision :

  • Go/Rust (binaires statiques) : scratch ou distroless/static
  • Java/Node.js/Python : distroless pour la production, images spécifiques au langage pour les stages de build
  • Besoin d’un shell pour debug ? : Alpine ou Debian slim
  • Problèmes de compatibilité musl ? : Passer d’Alpine à Debian slim ou distroless

Exemple complet optimisé (Node.js)

# syntax=docker/dockerfile:1

# Stage 1 : Dépendances de production
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production

# Stage 2 : Build
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
RUN npm run build

# Stage 3 : Production
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]

Exemple CI complet (GitHub Actions avec buildx)

name: Build and Push
on:
  push:
    branches: [main, "feature/**"]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: |
            type=gha,scope=${{ github.ref_name }}
            type=gha,scope=main
          cache-to: type=gha,scope=${{ github.ref_name }},mode=max

Résumé des stratégies d’optimisation clés

  1. Ordonner les instructions du moins changeant au plus changeant (dépendances OS → dépendances langage → code source).
  2. Utiliser les builds multi-stage pour séparer les dépendances de build des dépendances de runtime.
  3. Utiliser les cache mounts (--mount=type=cache) pour les caches de package managers.
  4. Utiliser mode=max dans --cache-to pour cacher tous les layers intermédiaires, surtout pour les multi-stage.
  5. Importer depuis plusieurs sources de cache (branche courante + main) en CI.
  6. Utiliser .dockerignore pour empêcher les fichiers non pertinents de buster le cache.
  7. Choisir des images de base minimales appropriées au langage (scratch/distroless pour les binaires statiques, Alpine pour l’usage général).
  8. Pinner les images de base par digest pour la reproductibilité et la sécurité supply-chain.
  9. Exécuter en tant que non-root avec UID/GID explicites.

Sources

GitHub Actions

Docker / BuildKit / Buildx

OIDC et authentification cloud

Secrets managers

Stratégies de déploiement et registres