Aller au contenu

Docker Compose : sortir les secrets de .env avec SOPS et age

··3201 mots·16 mins
Cryptolab.re
Auteur
Cryptolab.re
Cryptolab est un blog personnel où je documente mes expérimentations techniques : infra, self-hosting, réseau, crypto et projets parfois inutiles, souvent instructifs.
Sommaire

Un fichier .env ignoré par Git reste un fichier en clair. Dans une stack Docker Compose, il finit facilement dans une copie de migration, une sauvegarde, une archive de support ou, un jour, l’historique du dépôt.

SOPS et age permettent de corriger une partie du problème sans déployer un gestionnaire de secrets centralisé. Le principe est simple : le dépôt contient un fichier chiffré, les clés privées restent hors de Git et le serveur ne déchiffre que les valeurs nécessaires au déploiement.

Docker Compose reçoit ensuite ces valeurs sous forme de fichiers montés dans /run/secrets/. Les mots de passe ne passent plus par environment: et ne finissent plus dans la configuration du conteneur.

Cette méthode convient bien à un homelab, à quelques VPS ou à une petite infrastructure pilotée par Git. SOPS reste un outil de chiffrement de fichiers, pas un gestionnaire de secrets centralisé, et ne protège pas un hôte déjà compromis.

En bref
#

  • .gitignore évite normalement un commit accidentel, mais ne chiffre pas le fichier .env.
  • SOPS chiffre les valeurs d’un document YAML, JSON, dotenv ou INI tout en conservant une structure exploitable dans Git.
  • age fournit les identités et destinataires utilisés ici pour protéger la clé de données SOPS.
  • Une clé privée age ne doit jamais entrer dans le dépôt, une image de conteneur ou une sauvegarde non chiffrée.
  • Docker Compose monte un secret autorisé sous /run/secrets/<nom> dans le conteneur.
  • Avec Compose hors Swarm, un secret issu d’un fichier reste techniquement un bind mount. Ce n’est pas un coffre chiffré géré par Docker.
  • Le fichier déchiffré existe nécessairement sur l’hôte au moment du déploiement. Il faut donc contrôler son emplacement, ses permissions et sa sauvegarde.
  • Une rotation n’est terminée qu’après la recréation ou le rechargement des services qui consomment le secret.

Pourquoi .env ne suffit pas pour les secrets Docker Compose
#

Prenons une stack classique :

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

Et son fichier .env :

POSTGRES_PASSWORD=un-mot-de-passe-qui-ne-devrait-pas-etre-ici

Ajouter .env à .gitignore reste nécessaire, mais ne traite qu’un scénario : l’ajout du fichier au prochain commit.

Cela ne protège pas contre :

  • une copie du répertoire vers une autre machine ;
  • une sauvegarde insuffisamment protégée ;
  • une archive envoyée pour diagnostiquer un incident ;
  • un mauvais réglage de permissions sur le serveur ;
  • l’affichage de la configuration résolue par certains outils ;
  • un secret déjà présent dans l’historique Git.

Les variables injectées dans le conteneur posent aussi un problème de diffusion. Elles font partie de sa configuration et peuvent être récupérées via l’API Docker par toute personne ayant les droits correspondants. Ce point ne crée pas à lui seul une nouvelle frontière de sécurité, car l’accès au socket Docker donne déjà des privilèges très élevés.

Il augmente néanmoins le nombre d’endroits où le secret peut apparaître : inspection, logs de debug, rapports d’erreur ou sous-processus.

Pour les valeurs non sensibles, .env reste parfaitement acceptable : nom de domaine, fuseau horaire, version d’image, port publié ou identifiant de base. L’objectif n’est pas de bannir le format, mais d’en sortir les mots de passe, jetons et clés privées.

Comment SOPS et age protègent le dépôt
#

SOPS est un éditeur de fichiers chiffrés. Pour les formats structurés comme YAML, il laisse les noms de clés lisibles et chiffre leurs valeurs séparément.

Un fichier peut donc conserver cette structure dans Git :

postgres_password: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
app_secret: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
sops:
  age:
    - recipient: age1...
      enc: |
        -----BEGIN AGE ENCRYPTED FILE-----
        ...

SOPS génère une clé de données pour le document, chiffre les valeurs avec cette clé, puis protège la clé de données pour chacun des destinataires configurés. Ici, ces destinataires sont des clés publiques age.

La distinction est importante :

  • la clé publique age, appelée recipient, sert à chiffrer et peut être commitée ;
  • la clé privée age, appelée identity, permet de déchiffrer et doit rester protégée ;
  • plusieurs destinataires peuvent accéder au même fichier sans partager la même clé privée.

Je préfère créer au minimum deux identités : une pour le poste d’administration et une pour le serveur de déploiement. Copier la même clé privée sur toutes les machines facilite l’installation, mais rend la révocation et l’attribution des accès beaucoup moins propres.

Ce que cette méthode ne protège pas
#

Le modèle de menace doit rester réaliste.

SituationProtection apportée
Dépôt Git ou miroir voléOui, si aucune identité age n’est compromise
Backup du dépôt récupéréOui, pour le fichier SOPS
Commit accidentel du fichier chiffréSans conséquence directe
Fichier déchiffré mal protégé sur le serveurNon
Compromission root de l’hôte DockerNon
Compromission d’un conteneur autorisé à lire le secretNon pour ce secret
Rotation automatique ou identifiants dynamiquesNon

SOPS protège surtout les secrets au repos dans Git et pendant leur distribution. Une fois le secret fourni à une application, cette application doit pouvoir le lire. Un attaquant qui contrôle le processus, le conteneur ou l’hôte peut généralement le lire aussi.

Les identifiants temporaires, la révocation centralisée, les politiques par workload et la journalisation de chaque lecture demandent une autre architecture. Ce sujet dépasse le périmètre de cette méthode.

Installer age et SOPS
#

Sur Debian 12, Ubuntu 22.04 et les versions suivantes, age est disponible dans les dépôts :

sudo apt update
sudo apt install age

Vérifiez la version réellement installée :

age --version
age-keygen --version

Pour SOPS, utilisez de préférence le paquet de votre distribution lorsqu’il est suffisamment récent. Le projet fournit aussi des binaires pour Linux sur sa page de releases.

Exemple avec SOPS 3.13.3 pour Linux AMD64, version disponible au moment de la rédaction :

sops_version="3.13.3"
sops_arch="amd64"
download_dir="$(mktemp -d)"

curl -fL \
  -o "$download_dir/sops" \
  "https://github.com/getsops/sops/releases/download/v${sops_version}/sops-v${sops_version}.linux.${sops_arch}"

curl -fL \
  -o "$download_dir/checksums.txt" \
  "https://github.com/getsops/sops/releases/download/v${sops_version}/sops-v${sops_version}.checksums.txt"

(
  cd "$download_dir"
  cp sops "sops-v${sops_version}.linux.${sops_arch}"
  sha256sum --check checksums.txt --ignore-missing
)

sudo install -m 0755 "$download_dir/sops" /usr/local/bin/sops
sops --version

Le contrôle SHA-256 détecte une corruption ou un mauvais téléchargement. Comme le binaire et le fichier de sommes proviennent du même emplacement, il ne constitue pas à lui seul une vérification indépendante de l’éditeur. Les releases SOPS fournissent également un bundle Sigstore permettant de vérifier la signature du fichier de sommes avec Cosign.

Adaptez sops_arch à l’architecture de la machine. Ne remplacez pas aveuglément la version par latest dans un script de production. La même règle vaut pour les conteneurs : un tag Docker latest ne garantit pas un déploiement reproductible.

Créer les identités age
#

Identité du poste d’administration
#

SOPS cherche par défaut les identités age dans ~/.config/sops/age/keys.txt sous Linux lorsque XDG_CONFIG_HOME n’est pas défini.

install -d -m 700 ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt

La commande affiche le destinataire public :

Public key: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Il peut aussi être recalculé depuis l’identité :

age-keygen -y ~/.config/sops/age/keys.txt

Identité du serveur
#

Sur le serveur Docker :

sudo install -d -m 700 /etc/sops/age
sudo age-keygen -o /etc/sops/age/keys.txt
sudo chmod 600 /etc/sops/age/keys.txt
sudo age-keygen -y /etc/sops/age/keys.txt

Copiez uniquement le destinataire public vers le poste d’administration. La clé privée du serveur n’a aucune raison de revenir dans le dépôt.

Conservez une copie de récupération de chaque identité privée dans un emplacement distinct : gestionnaire de mots de passe acceptant les pièces jointes, support hors ligne chiffré ou procédure de secours équivalente. Sans l’une des identités autorisées, le fichier SOPS est irrécupérable.

La sauvegarde ne doit pas annuler le modèle de sécurité. Une clé age copiée en clair dans le même bucket que le dépôt chiffré ne protège pas grand-chose.

Définir les destinataires dans .sops.yaml
#

À la racine du dépôt de la stack, créez .sops.yaml :

creation_rules:
  - path_regex: '(^|/)secrets\.sops\.yaml$'
    age:
      - age1destinatairedupostedadministrationxxxxxxxxxxxxxxxxxxxx
      - age1destinataireduserveurdedeploiementxxxxxxxxxxxxxxxxxxx

Ce fichier ne contient que des clés publiques. Il peut et devrait être versionné avec la stack.

La règle limite son application aux fichiers nommés secrets.sops.yaml. Cela évite de chiffrer un autre YAML par erreur et rend la convention visible pour les personnes qui relisent le dépôt.

Vérifiez le contenu avant d’aller plus loin :

git diff -- .sops.yaml

Créer le fichier de secrets chiffré
#

Depuis le poste d’administration :

sops edit secrets.sops.yaml

SOPS ouvre un fichier temporaire déchiffré dans l’éditeur configuré, puis chiffre les valeurs au moment de l’enregistrement. Ajoutez par exemple :

postgres_password: remplacer-par-un-secret-aleatoire

Après fermeture de l’éditeur, le fichier sur disque doit contenir des blocs ENC[...] et une section sops:.

Contrôlez-le avant le commit :

sed -n '1,20p' secrets.sops.yaml
git diff -- secrets.sops.yaml
sops decrypt secrets.sops.yaml >/dev/null

La dernière commande vérifie que votre identité peut relire le document sans afficher son contenu dans le terminal.

Il vaut mieux utiliser sops edit que déchiffrer le document en place. Cette commande réduit la durée de vie de la copie en clair et évite d’oublier un fichier temporaire dans le répertoire de travail.

Vous pouvez maintenant commiter :

git add .sops.yaml secrets.sops.yaml
git commit -m "chore: chiffre les secrets de la stack"

Monter les secrets dans Docker Compose
#

Voici une stack PostgreSQL minimale :

services:
  db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
    secrets:
      - postgres_password
    volumes:
      - postgres-data:/var/lib/postgresql/data

secrets:
  postgres_password:
    file: /run/compose-secrets/example/postgres_password

volumes:
  postgres-data:

Compose monte le fichier source sous /run/secrets/postgres_password dans le conteneur. L’image officielle PostgreSQL comprend la convention POSTGRES_PASSWORD_FILE et lit le contenu de ce fichier pendant son initialisation.

Cette convention _FILE n’est pas une fonctionnalité générique de Compose. Elle doit être prise en charge par l’image ou l’application. Pour une application qui accepte directement un chemin de configuration, utilisez ce chemin. Si elle n’accepte que des variables d’environnement, un wrapper d’entrée peut lire le fichier, mais le secret reviendra alors dans l’environnement du processus.

Autre nuance importante : Docker Compose implémente les secrets issus de file: avec un bind mount. Les attributs uid, gid et mode de la syntaxe longue sont ignorés dans ce cas. Il faut régler les permissions et, si nécessaire, le propriétaire sur le fichier source de l’hôte en fonction de l’UID réellement utilisé dans le conteneur.

L’exemple PostgreSQL fonctionne parce que son script d’entrée peut lire le secret avant de lancer le serveur sous son utilisateur final. Ce comportement ne doit pas être supposé pour toutes les images.

Déployer sans déchiffrer tout le fichier
#

Le serveur n’a pas besoin d’écrire tout le document en clair. SOPS sait extraire une valeur précise d’un document YAML.

Créez /srv/stacks/example/render-secrets.sh :

#!/usr/bin/env bash
set -euo pipefail

umask 077

stack_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
encrypted_file="$stack_dir/secrets.sops.yaml"
identity_file="/etc/sops/age/keys.txt"
secret_dir="/run/compose-secrets/example"

export SOPS_AGE_KEY_FILE="$identity_file"

install -d -m 700 "$secret_dir"

render_secret() {
  local key="$1"
  local destination="$2"
  local temporary

  temporary="$(mktemp "$secret_dir/.${destination}.XXXXXX")"

  if ! sops decrypt --extract "[\"${key}\"]" \
    "$encrypted_file" > "$temporary"; then
    rm -f -- "$temporary"
    return 1
  fi

  chmod 600 "$temporary"
  mv -f -- "$temporary" "$secret_dir/$destination"
}

render_secret postgres_password postgres_password

Puis protégez et exécutez le script :

sudo chmod 750 /srv/stacks/example/render-secrets.sh
sudo /srv/stacks/example/render-secrets.sh
sudo find /run/compose-secrets/example \
  -maxdepth 1 -type f -printf '%m %u:%g %f\n'

La sortie attendue ressemble à ceci :

600 root:root postgres_password

Ne vérifiez pas les fichiers avec cat dans un terminal enregistré. Pour confirmer qu’une valeur n’est pas vide sans l’afficher :

sudo test -s /run/compose-secrets/example/postgres_password

Validez ensuite la stack et démarrez-la :

cd /srv/stacks/example
sudo docker compose config --quiet
sudo docker compose up -d
sudo docker compose ps

L’option --quiet vérifie la configuration sans imprimer la configuration résolue. Elle évite aussi de prendre l’habitude d’envoyer un docker compose config complet dans des logs de CI ou une demande de support.

Stocker les secrets déchiffrés dans /run
#

Sur une distribution Linux classique utilisant systemd, /run est un système de fichiers temporaire vidé au redémarrage. C’est intéressant pour limiter les secrets persistants en clair, mais cela impose une contrainte : les fichiers doivent être recréés avant que les conteneurs tentent de repartir.

Il ne suffit pas d’ajouter restart: unless-stopped et d’espérer que le script sera exécuté à temps. Au redémarrage, Docker peut essayer de relancer un conteneur alors que son fichier source n’existe pas encore.

Deux stratégies sont raisonnables :

  1. rendre les secrets avec une unité systemd ordonnée avant le démarrage des conteneurs ;
  2. conserver les fichiers déchiffrés sous /etc/compose-secrets/<stack> avec des permissions strictes, si la simplicité et la disponibilité au boot priment.

La seconde option laisse les secrets sur le disque. Elle reste acceptable sur un serveur correctement administré, idéalement avec chiffrement du volume, si le modèle de menace porte surtout sur Git et les sauvegardes du dépôt.

Si /run est retenu, testez un vrai redémarrage. Vérifier uniquement docker compose up -d sur un serveur déjà démarré ne valide pas la séquence de boot.

Pour imposer l’ordre au démarrage, une unité de rendu peut être placée dans /etc/systemd/system/render-compose-secrets.service :

[Unit]
Description=Rendu des secrets SOPS pour les stacks Compose
After=local-fs.target
Before=docker.service

[Service]
Type=oneshot
ExecStart=/srv/stacks/example/render-secrets.sh
RemainAfterExit=yes

Ajoutez ensuite un drop-in à docker.service avec sudo systemctl edit docker.service :

[Unit]
Requires=render-compose-secrets.service
After=render-compose-secrets.service

Rechargez systemd sans redémarrer Docker en pleine production :

sudo systemctl daemon-reload
sudo systemd-analyze verify \
  /etc/systemd/system/render-compose-secrets.service

Le drop-in rend le démarrage de Docker dépendant du rendu. Une identité manquante ou un fichier SOPS invalide bloque donc volontairement le moteur au boot, au lieu de démarrer des conteneurs avec des secrets absents. Cette dépendance globale est stricte : si plusieurs stacks utilisent ce modèle, le service de rendu doit les traiter toutes, ou être remplacé par une organisation systemd plus fine.

Appliquez ce changement pendant une fenêtre de maintenance, puis redémarrez réellement la machine et vérifiez :

sudo systemctl status render-compose-secrets.service
sudo systemctl status docker.service
sudo test -s /run/compose-secrets/example/postgres_password
sudo docker compose -f /srv/stacks/example/compose.yaml ps

Vérifier la migration depuis .env
#

Une migration réussie ne se résume pas à la présence du fichier SOPS.

Vérifier la configuration Compose
#

sudo docker compose config --quiet
sudo docker compose config --images

La définition du service ne doit plus contenir POSTGRES_PASSWORD, API_TOKEN ou une autre valeur sensible injectée directement.

Vérifier le montage dans le conteneur
#

sudo docker compose exec db \
  sh -c 'test -s /run/secrets/postgres_password'

Cette commande confirme la présence du fichier sans afficher sa valeur.

Auditer les fichiers restants
#

Depuis la racine de la stack :

find . -maxdepth 2 -type f \
  \( -name '.env' -o -name '*.env' -o -name '*secret*' \) \
  -print

Relisez chaque résultat. Un .env peut rester pour les valeurs non sensibles, mais il ne doit plus contenir les secrets migrés.

Pour vérifier si .env a déjà été suivi par Git :

git log --all -- .env
git ls-files --error-unmatch .env

La seconde commande retourne une erreur si le fichier n’est pas suivi, ce qui est le résultat attendu.

Si un secret a été commité, le chiffrer aujourd’hui ne répare pas l’historique. Il faut d’abord révoquer ou remplacer le secret. La réécriture de l’historique peut ensuite réduire l’exposition résiduelle, mais elle ne garantit pas la suppression des clones, forks, caches ou sauvegardes existants.

Modifier et faire tourner un secret SOPS
#

Pour modifier une valeur :

sops edit secrets.sops.yaml

Après le commit et le déploiement du nouveau fichier chiffré :

sudo /srv/stacks/example/render-secrets.sh
cd /srv/stacks/example
sudo docker compose up -d --force-recreate nom-du-service
sudo docker compose ps

Remplacez nom-du-service par le service qui consomme réellement le secret. La recréation est volontaire : Compose monte ici un fichier de l’hôte et l’application peut ne lire son contenu qu’au démarrage. Remplacer le fichier source ne prouve donc pas que le processus utilise la nouvelle valeur.

L’exemple PostgreSQL de ce guide demande une précaution supplémentaire. POSTGRES_PASSWORD_FILE sert à initialiser le mot de passe lorsque le répertoire de données est vide. Recréer le conteneur avec un volume existant ne modifie pas le mot de passe du rôle dans PostgreSQL.

Pour une base déjà initialisée, utilisez la procédure de changement de mot de passe du moteur, puis mettez à jour les clients. Un simple --force-recreate db ne réalise pas la rotation.

Pour un mot de passe de base de données, l’ordre dépend de l’application :

  1. créer ou appliquer le nouveau mot de passe côté base ;
  2. mettre à jour le fichier SOPS ;
  3. rendre le nouveau secret sur l’hôte ;
  4. recréer ou recharger le client ;
  5. vérifier les connexions ;
  6. retirer l’ancien accès lorsqu’une période de transition est possible.

Il n’existe pas d’ordre universel permettant une rotation sans coupure. Certaines applications exigent un redémarrage, d’autres acceptent deux identifiants pendant la transition, et d’autres encore ne relisent jamais leurs secrets.

Ajouter ou retirer une identité age
#

Ajoutez le nouveau destinataire public dans .sops.yaml, puis synchronisez les clés autorisées :

sops updatekeys secrets.sops.yaml

Pour un départ d’administrateur ou la perte d’une clé, retirez son destinataire de .sops.yaml, puis exécutez dans cet ordre :

sops updatekeys secrets.sops.yaml
sops rotate --in-place secrets.sops.yaml

updatekeys modifie les destinataires capables de déchiffrer la clé de données. rotate génère ensuite une nouvelle clé de données et rechiffre le document.

Cela ne suffit pas si l’ancienne identité a déjà permis de lire les secrets. Dans ce cas, il faut aussi renouveler les mots de passe, jetons et clés applicatives contenus dans le fichier.

Sauvegarder pour pouvoir restaurer
#

Une stack protégée par SOPS nécessite au minimum :

  • le dépôt Git contenant .sops.yaml, secrets.sops.yaml et compose.yaml ;
  • au moins une identité privée age encore autorisée ;
  • la procédure de rendu des secrets ;
  • la sauvegarde des volumes et bases applicatives ;
  • une procédure de rotation si une identité est perdue ou volée.

Le test de restauration est court et sans secret affiché :

SOPS_AGE_KEY_FILE=/chemin/vers/identite-de-secours \
  sops decrypt secrets.sops.yaml >/dev/null

Faites ce test depuis une machine qui ne possède pas déjà l’identité principale. Sinon, vous risquez de valider la mauvaise clé sans vous en rendre compte.

Quand SOPS suffit
#

SOPS avec age est un bon compromis lorsque :

  • l’infrastructure est décrite dans Git ;
  • le nombre de serveurs reste limité ;
  • les secrets sont principalement statiques ;
  • les changements passent déjà par une procédure de déploiement ;
  • une courte recréation des conteneurs est acceptable.

La solution devient moins confortable lorsque plusieurs dizaines de workloads doivent s’authentifier, que les accès doivent expirer automatiquement ou qu’un audit centralisé est nécessaire. Empiler des scripts SOPS pour reproduire les fonctions d’un gestionnaire de secrets finit généralement par déplacer la complexité plutôt que la supprimer.

Conclusion
#

Sortir les secrets de .env ne consiste pas seulement à changer d’extension.

Le dépôt doit contenir des valeurs chiffrées, les identités privées doivent être séparées, le serveur doit produire des fichiers avec des permissions cohérentes et Compose ne doit accorder chaque secret qu’au service qui en a besoin.

Pour une petite infrastructure, SOPS et age couvrent correctement la distribution chiffrée sans ajouter un service central dont il faudrait assurer le stockage, la disponibilité et la restauration. La limite reste claire : dès que le serveur déchiffre un secret pour une application, la sécurité dépend de nouveau de cet hôte et de cette application.

Le dernier contrôle utile n’est donc pas git status. C’est une restauration sur une machine propre, suivie d’une rotation réellement prise en compte par les conteneurs.

Sources
#

Articles connexes

Docker Compose : arrêtez de mettre latest dans vos fichiers

Le tag latest est confortable. On écrit un compose.yml, on lance docker compose up -d, et le service démarre. Pas besoin de choisir une version de PostgreSQL, Redis, Traefik, Gitea, Vaultwarden ou n’importe quelle application auto-hébergée. Le problème, c’est que latest ne veut pas dire “dernière version stable adaptée à mon environnement”. Ça veut seulement dire : “ce tag pointe vers quelque chose dans le registre au moment où Docker le résout”. Ce quelque chose peut changer sans que votre fichier Compose change. Pour une stack de test, ce n’est pas très grave. Pour une base de données, un reverse proxy exposé, un service d’authentification ou une application avec des volumes persistants, c’est une mauvaise convention d’exploitation. Le vrai sujet n’est pas Docker. C’est la reproductibilité.

K3s ou Docker Compose : pourquoi je garde les deux dans mon homelab

··2248 mots·11 mins
J’ai longtemps gardé tout mon homelab sous Docker Compose. Pas par rejet de Kubernetes : Compose faisait le travail, les volumes étaient faciles à retrouver et je savais remettre un service en route sans relire une pile de manifests. K3s est arrivé plus tard, quand j’ai voulu uniformiser les déploiements et préparer l’ajout d’autres machines. Je n’ai pas tout migré pour autant. Aujourd’hui, les deux cohabitent encore. C’est volontaire. Pour trancher, je regarde surtout la panne : avec lequel des deux vais-je retrouver les données et remettre le service en route sans improviser ?