Où finissent les variables d’environnement d’un conteneur

Démarrez un conteneur avec -e DB_PASSWORD=hunter2 et lancez docker inspect dessus :

1
2
docker run -d --name api -e DB_PASSWORD=hunter2 nginx:alpine
docker inspect --format '{{json .Config.Env}}' api
1
["DB_PASSWORD=hunter2","PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]

Le mot de passe reste là en clair, lisible par quiconque a accès au socket Docker, par docker exec api env, et par n’importe quel processus qui tourne dans le conteneur. Ce n’est pas un bug : c’est exactement à ça que servent les variables d’environnement. Le problème, c’est de les utiliser pour des valeurs qui ne devraient jamais être aussi visibles.

Où définir une variable d’environnement Docker : build time ou run time

Quatre mécanismes, trois durées de vie. L’endroit où vous définissez une variable détermine combien de temps elle vit et qui peut la relire :

MécanismeDéfini oùReste dans l’image ?Visible avec docker inspect ?
ARG dans le DockerfileAu build uniquementNon (sauf si copié dans ENV)Non, mais enregistré dans l’historique de build
ENV dans le DockerfileAu buildOui, intégré dans chaque couche suivanteOui
-e / --env sur docker runAu démarrage du conteneurNonOui
--env-file sur docker runAu démarrage du conteneurNonOui

Au démarrage, vous les passez une par une, ou depuis un fichier :

1
2
3
4
5
# Une par une, répétable
docker run -e NODE_ENV=production -e PORT=3000 my-api

# Depuis un fichier, une paire KEY=VALUE par ligne, sans guillemets
docker run --env-file .env.production my-api

.env.production ressemble à ça :

1
2
3
NODE_ENV=production
PORT=3000
LOG_LEVEL=info

--env-file devient le meilleur choix dès que vous dépassez deux ou trois variables : la commande de démarrage reste lisible et les valeurs se retrouvent à un seul endroit que vous pouvez comparer avec un diff.

Variables d’environnement dans Docker Compose : environment, env_file et .env

Compose propose trois emplacements pour les variables, et deux sont des fichiers presque homonymes. .env et env_file: font des travaux complètement différents.

  • .env à la racine du projet est lu par Compose lui-même, pour substituer les placeholders ${VARIABLE} dans compose.yaml. Il n’atteint jamais le conteneur, sauf si vous le référencez aussi sous environment:.
  • env_file: liste des fichiers dont le contenu est injecté dans l’environnement du conteneur, exactement comme --env-file sur docker run.
  • environment: définit les variables directement dans le fichier Compose, en ligne.
1
2
3
4
5
6
7
services:
  api:
    image: my-api
    environment:
      - NODE_ENV=production
    env_file:
      - .env.api

Quand une variable est définie à plus d’un endroit, Compose la résout dans cet ordre, du plus prioritaire au moins prioritaire : un -e passé à docker compose run, puis environment:, puis env_file:, puis le ENV déjà intégré à l’image. Si NODE_ENV apparaît à la fois dans environment: et dans .env.api, c’est la valeur d’environment: qui l’emporte.

Pourquoi les variables d’environnement ne conviennent pas aux secrets

Aucun des mécanismes ci-dessus ne cache une valeur à qui a accès au conteneur ou à l’hôte :

  • docker inspect affiche chaque variable au démarrage, comme montré plus haut.
  • docker exec <conteneur> env les affiche depuis l’intérieur.
  • Un processus enfant hérite de tout l’environnement, y compris un debugger, un crash reporter, ou une dépendance que vous n’avez pas auditée.
  • Tout ce qui vide son environnement au démarrage envoie la valeur dans votre agrégateur de logs, et un nombre surprenant de frameworks font exactement ça quand le debug logging est activé.
  • Les variables d’environnement définies au build avec ENV sont permanentes : docker history --no-trunc my-api affiche la valeur exacte, et elle reste dans l’image sur chaque registry où vous la poussez.

Rien de tout ça ne demande qu’un attaquant compromette le conteneur. L’accès que beaucoup de gens ont déjà suffit : le socket Docker, les logs de la CI, le registry d’images.

Comment fonctionnent les secrets Docker : la valeur est montée comme fichier

Arrêtez de confier les identifiants au conteneur comme variables d’environnement et montez-les comme fichiers. Compose le fait de lui-même, sans Swarm :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

Compose monte db_password.txt sur /run/secrets/db_password dans le conteneur, en lecture seule et nulle part ailleurs : cette valeur n’apparaît jamais dans docker inspect, dans docker exec ... env, ni dans l’image. Le suffixe _FILE est une convention déjà prise en charge par les images officielles de Postgres, MySQL et MongoDB : au démarrage, le script d’entrypoint lit le fichier au lieu d’attendre la valeur directement.

Si votre application ne prend pas en charge cette convention, lisez le fichier vous-même au démarrage : une ligne dans la plupart des langages, par exemple open('/run/secrets/db_password').read().strip() en Python.

Sur un cluster Swarm, le secret équivalent est créé et distribué par l’orchestrateur plutôt que par un fichier local :

1
2
echo "supersecret" | docker secret create db_password -
docker service create --name db --secret db_password postgres:16

Swarm stocke le secret chiffré au repos et en transit, et le monte dans un filesystem en mémoire sur chaque réplique : il n’est jamais écrit sur la couche modifiable du conteneur.

Garder les secrets hors de l’image au build

ARG et ENV dans un Dockerfile posent le même problème un cran plus tôt : une valeur passée comme argument de build reste enregistrée dans l’historique de build de l’image, même si vous ne la transformez jamais en ENV.

1
2
3
FROM node:20-slim
ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN} && npm install
1
2
docker build --build-arg NPM_TOKEN=npm_abc123 -t my-api .
docker history --no-trunc my-api | grep npm_abc123

Cette commande docker history la retrouve. Le token disparaît du filesystem final si vous ne faites pas de COPY du fichier de configuration plus loin, mais il reste lisible en permanence dans les métadonnées de l’image, qui voyagent avec elle sur chaque registry.

Le flag --secret de BuildKit évite ça en montant la valeur dans une seule étape RUN, comme fichier en mémoire qui ne devient jamais une couche :

1
2
3
4
# syntax=docker/dockerfile:1
FROM node:20-slim
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN} && npm install
1
docker buildx build --secret id=npm_token,src="$HOME/.npm_token" -t my-api .

npm_token n’existe que pour la durée de cette instruction RUN et n’est enregistré nulle part où docker history pourrait le voir.

Quand utiliser une variable d’environnement, quand utiliser un secret

SituationUtilisez
Configuration non sensible (port, log level, feature flag)-e, --env-file, ou environment:/env_file: de Compose
Mot de passe de base de données, clé API, clé TLS au runtimeUn secret Docker, monté comme fichier
Token d’authentification nécessaire seulement pendant docker build--secret de BuildKit avec RUN --mount=type=secret
Valeur intégrée à l’image intentionnellement (version de l’app, commit de build)ARG copié dans ENV : c’est correct, ce n’est pas un secret

Une seule question tranche : ça vous dérangerait que cette valeur fuite ? Si oui, elle ne passe pas par -e, --env-file, environment:, ni par un ARG/ENV de Dockerfile. Elle passe par un secret monté comme fichier. Les réglages que votre application affiche volontiers dans ses propres logs restent de simples variables d’environnement, plus faciles à surcharger selon l’environnement.

Allez donc vérifier. Lancez docker inspect --format '{{json .Config.Env}}' sur les conteneurs qui tournent en ce moment, et lisez le résultat comme le diff d’une pull request. Tout ce que vous n’aimeriez pas voir relu en public doit rejoindre un bloc secrets: dans votre fichier Compose, avec une variable _FILE à la place du mot de passe. Si le fichier doit survivre au conteneur plutôt que venir du contexte de build, placez-le sur un volume Docker.

Articles associés