Secrets Docker dans les couches d'image : pourquoi supprimer le .env ne l'efface pas

Une image Docker est une pile de couches en lecture seule, et chaque instruction qui modifie des fichiers en ajoute une. Si un COPY place ton .env dans une couche, un RUN rm .env ultérieur ne fait que le masquer dans une nouvelle couche posée par-dessus. La couche d'origine contient toujours le fichier, et quiconque peut récupérer l'image (pull) peut l'en extraire. Les arguments de build et les valeurs ENV ont le même problème. La solution : garder les secrets hors du contexte de build, faire passer les secrets nécessaires au build par les montages de secrets de BuildKit, et donner les secrets d'exécution au conteneur au moment où il démarre.
Comment un fichier supprimé survit dans une image
Voici un Dockerfile qui a l'air prudent :
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci && npm run build
RUN rm -f .env
Trois couches comptent ici. COPY . . écrit tout le projet, .env compris, dans une couche. npm ci && npm run build ajoute les dépendances et le résultat du build dans une deuxième couche. rm -f .env ajoute une troisième couche qui enregistre la suppression.
Une couche n'est jamais modifiée après sa création. La documentation de Docker sur les storage drivers le dit sans détour : quand un fichier est supprimé dans une couche supérieure, il reste disponible dans la couche précédente. Le conteneur que tu lances ne voit pas le fichier, parce que la couche du dessus le masque. L'image que tu pousses (push) le transporte toujours, parce que la couche du COPY fait partie de l'image.
Il en va de même pour tout ce qui est écrit puis supprimé dans des instructions séparées : des clés SSH copiées pour cloner un repo privé, un .npmrc contenant un token de registre, des identifiants cloud utilisés pour télécharger des ressources.
ARG et ENV ne sont pas faits pour les secrets non plus
C'est tentant de passer un token en argument de build plutôt que dans un fichier :
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && npm ci
La documentation de Docker sur le build est explicite : les arguments de build et les variables d'environnement ne conviennent pas pour transmettre des secrets, parce qu'ils persistent dans l'image finale. Les valeurs définies avec ENV restent dans la configuration de l'image, lisibles par tout le monde. La référence du Dockerfile ajoute que les arguments de build sont visibles dans docker history et dans les attestations de provenance générées en mode max.
Docker repère désormais les cas les plus évidents. Ses vérifications de build (build checks) comprennent SecretsUsedInArgOrEnv, qui avertit quand le nom d'un ARG ou d'un ENV ressemble à un identifiant, avec un message indiquant que des données potentiellement sensibles ne devraient pas être utilisées dans des commandes ARG ou ENV. Ces vérifications tournent par défaut avec Buildx 0.15.0 ou plus récent et le frontend Dockerfile 1.8 ou plus récent. Elles se contentent d'afficher des avertissements, sauf si tu ajoutes la directive # check=error=true en haut du Dockerfile, qui fait alors échouer le build.
Vérifie ta propre image
Commence par les métadonnées, ça prend quelques secondes :
docker history --no-trunc myimage:latest
docker image inspect --format '{{json .Config.Env}}' myimage:latest
La première commande affiche l'instruction derrière chaque couche, y compris les valeurs des arguments de build utilisées par RUN. La deuxième affiche chaque valeur ENV figée dans la configuration de l'image.
Regarde ensuite à l'intérieur des couches elles-mêmes. docker save exporte l'image sous forme d'une archive tar contenant les archives des couches, que tu peux lister sans rien exécuter :
mkdir -p /tmp/img && docker save myimage:latest | tar -xf - -C /tmp/img
find /tmp/img -type f | while read -r f; do
tar -tf "$f" 2>/dev/null \
| grep -E '(^|/)(\.env[^/]*|\.npmrc|id_rsa|[^/]*\.pem|credentials[^/]*)$' \
| sed "s|^|$f: |"
done
Chaque ligne de sortie est un fichier d'apparence sensible présent dans une couche, qu'une couche suivante l'ait supprimé ou non. Les fichiers qui ne sont pas des archives de couche sont ignorés sans message.
Un scanner de secrets fait une version plus large du même travail. Trivy, par exemple, cherche des secrets dans les images par défaut, et trivy image --scanners secret myimage:latest le limite à cette recherche. Sa documentation précise qu'il essaie de détecter l'image de base et d'en sauter les couches : le scan reste rapide, mais tu dois quand même examiner ce qu'ajoutent tes propres couches.
Correctif 1 : garde les secrets hors du contexte de build
Le contexte de build, c'est tout ce que Docker envoie au builder, et COPY . . copie tout. Un fichier .dockerignore placé à côté du Dockerfile retire des fichiers avant même qu'ils n'atteignent le builder :
.env
.env.*
!.env.example
*.pem
*.key
.npmrc
.git
Avec ça en place, COPY . . ne peut pas embarquer ton fichier env local, même si quelqu'un en ajoute un plus tard. Exclure .git garde aussi ton historique de commits hors de l'image, avec tous les secrets qui y ont un jour été commités.
Correctif 2 : les secrets du build passent par les montages de secrets de BuildKit
Certains builds ont vraiment besoin d'un secret, par exemple un token pour installer des paquets privés. BuildKit peut le monter pour une seule instruction RUN sans l'écrire dans aucune couche :
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
COPY . .
RUN npm run build
docker build --secret id=npmrc,src=$HOME/.npmrc -t myimage:latest .
Le fichier n'existe que pendant l'exécution de ce RUN. Sans target, il est monté dans /run/secrets/<id>. Si l'outil attend une variable d'environnement plutôt qu'un fichier, l'option env expose le secret sous forme de variable pour cette seule instruction :
RUN --mount=type=secret,id=API_TOKEN,env=API_TOKEN ./scripts/download-assets.sh
docker build --secret id=API_TOKEN .
Avec seulement un id, comme dans cette deuxième commande, Docker prend la valeur dans la variable d'environnement du même nom, dans le shell qui lance le build.
Pour cloner des repos Git privés en SSH, RUN --mount=type=ssh combiné à docker build --ssh default transmet ton agent SSH au build au lieu de copier une clé dans l'image.
Les builds multi-étapes (multi-stage) aident à garder les outils de build hors de l'image finale, mais ils ne remplacent pas les montages de secrets. Les couches de l'étape précédente existent toujours dans le cache de build de la machine qui a lancé le build.
Correctif 3 : les secrets d'exécution se donnent à l'exécution
Les mots de passe de base de données, les clés d'API et les secrets de signature dont l'application a besoin pendant qu'elle tourne n'ont rien à faire dans l'image. Fournis-les au démarrage du conteneur : via les réglages de secrets de ta plateforme, docker run --env-file, ou les secrets de Compose, qui sont montés sous forme de fichiers :
services:
app:
image: myimage:latest
secrets: [db_password]
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./db_password.txt
Chaque secret apparaît dans le conteneur sous /run/secrets/<name>. La documentation de Compose préfère les fichiers aux variables d'environnement, parce que les variables d'environnement sont souvent accessibles à tous les processus et peuvent être affichées dans les logs quand on débogue des erreurs. Plusieurs images officielles, dont MySQL et PostgreSQL, lisent une variante _FILE de leurs variables de mot de passe précisément pour cette raison.
Si un secret est déjà parti dans une image
Si l'image a été poussée un jour sur un registre public, ou sur un registre privé que plus de monde peut lire qu'il ne faudrait, pars du principe que le secret a été copié. Supprimer le tag ou le repo n'annule pas les pulls déjà effectués.
- Fais tourner le secret chez le fournisseur et mets à jour chaque endroit où il est utilisé.
- Vérifie dans les journaux du fournisseur s'il y a eu une utilisation que tu ne reconnais pas depuis la publication de l'image.
- Corrige le Dockerfile avec les étapes ci-dessus, refais le build et relance la vérification des couches.
- Retire l'ancienne image du registre pour qu'elle ne soit plus récupérée.
Le même problème du « supprimé mais toujours là » vaut pour l'historique Git et pour les outils de code IA qui peuvent lire le dossier de ton projet ; comment .cursorignore gère les fichiers .env traite le second cas.
FAQ
Supprimer un fichier dans une couche Docker suivante l'efface-t-il de l'image ?
Non. Chaque couche est immuable : une suppression ajoute seulement une nouvelle couche qui masque le fichier. La couche précédente, avec le fichier dedans, fait toujours partie de l'image et peut être extraite avec docker save.
Les build args de Docker sont-ils sûrs pour les secrets ?
Non. La documentation de Docker dit que les arguments de build et les variables d'environnement persistent dans l'image finale. Utilise RUN --mount=type=secret avec docker build --secret pour tout ce que le build a besoin de sensible.
Comment vérifier qu'une image Docker ne contient pas de secrets ?
Lance docker history --no-trunc et docker image inspect pour voir les commandes de build et les valeurs d'environnement, puis exporte l'image avec docker save et liste chaque archive de couche à la recherche de noms de fichiers sensibles. Un scanner comme Trivy automatise cette recherche à travers les couches.