Chapitre 13.5 — Gérer les secrets
⏱️ TL;DR — Un secret (mot de passe de base, clé d’API, token, clé privée) ne doit jamais entrer dans git — ni dans le code, ni dans un
.envcommité, ni dans un workflow. Committé une fois, un secret est compromis pour toujours (l’historique git le garde) : la seule réponse est de le révoquer immédiatement. Les secrets vivent ailleurs, selon leur usage : le.envsur le serveur (hors du dépôt, permissions600), les secrets GitHub (${{ secrets.XXX }}) pour la CI, et le service systemd qui les charge viaEnvironmentFile. On distingue les secrets serveur / CI / build-time / runtime, on applique le moindre privilège (une clé de déploiement en lecture seule, un token à portée réduite), on les fait tourner (rotation), et on survole les gestionnaires (Vault, SOPS, age). C’est le chapitre sécurité qui protège tout le reste.
🎯 Objectifs
- Comprendre pourquoi un secret ne doit jamais être commité, et quoi faire s’il l’a été.
- Placer chaque secret au bon endroit :
.envserveur, secrets GitHub,EnvironmentFilesystemd. - Distinguer secrets serveur, CI, build-time et runtime.
- Appliquer le moindre privilège et la rotation aux clés et tokens.
- Savoir qu’il existe des gestionnaires de secrets (Vault, SOPS, age) et quand y penser.
La règle numéro un : jamais dans git
Git est fait pour garder l’histoire. C’est sa qualité — et c’est exactement ce qui rend un secret commité impossible à effacer. Supprimer un mot de passe dans un commit ultérieur ne l’enlève pas : il reste dans tous les commits précédents, consultable par quiconque a le dépôt (git log -p). Sur un dépôt public, des bots automatisés scannent GitHub en permanence et trouvent une clé d’API en quelques minutes après le push.
La première ligne de défense, c’est le .gitignore. Tout fichier qui contient des secrets y figure, avant le premier commit du projet :
# .gitignore — à la racine du projet
.env # le fichier d'environnement local ET serveur
.env.* # .env.local, .env.production, etc.
!.env.example # SAUF le modèle sans valeurs (voir plus bas)
*.pem # clés privées, certificats
*_key # clés SSH (deploy_key, id_ed25519…)Ce qu’on commite à la place, c’est un .env.example : la liste des variables attendues, avec des valeurs vides ou factices. Il documente ce que l’app a besoin de connaître, sans livrer la moindre vraie valeur :
# .env.example — commité, sert de modèle. AUCUNE vraie valeur.
DATABASE_URL=postgres://user:password@localhost:5432/formacampus
JWT_SECRET=change-me
SMTP_PASSWORD=change-me⚠️ Piège — Le secret commité « juste pour tester », qu’on « nettoiera plus tard ». Il n’y a pas de « plus tard » : dès le
git push, il est diffusé et doit être considéré comme fuité. Le supprimer dans un commit suivant ne sert à rien — l’historique le conserve. Le réflexe correct n’est jamais « je le retire du prochain commit », c’est « je révoque ce secret maintenant ». Le nettoyage de l’historique vient après, et ne remplace pas la révocation.
🔒 Sécurité — Si un secret a déjà été commité : révoque-le, point. Change le mot de passe de base, régénère la clé d’API côté fournisseur, retire la clé SSH de
authorized_keys, invalide le token. Un secret exposé n’est plus un secret, quoi que tu fasses ensuite de l’historique git. Réécrire l’historique (git filter-repo, BFG) est utile pour empêcher de futures fuites du dépôt, mais ne dé-compromet pas un secret déjà lu par un bot. Ordre impératif : 1. révoquer et remplacer, 2. nettoyer l’historique, 3. comprendre comment il est arrivé là pour que ça ne se reproduise pas.
Où vivent les secrets, alors ?
Puisqu’ils ne vont pas dans git, il faut les ranger ailleurs — et l’endroit dépend de qui en a besoin et quand.
Le .env sur le serveur
Pour l’app qui tourne en prod, les secrets runtime (URL de base avec mot de passe, clés d’API) vivent dans un fichier .env présent sur le serveur uniquement, jamais dans le dépôt. Avec le schéma releases du 13.4, ce fichier est dans shared/ et relié dans chaque release — il survit ainsi aux déploiements.
Le point critique, ce sont ses permissions. Un fichier de secrets doit être lisible par son seul propriétaire :
# Le .env n'appartient qu'à l'utilisateur deploy et n'est lisible que par lui
chown deploy:deploy /var/www/formacampus/shared/.env
chmod 600 /var/www/formacampus/shared/.env # rw pour le propriétaire, RIEN pour les autres
ls -l /var/www/formacampus/shared/.env
# -rw------- 1 deploy deploy ... .env <- personne d'autre ne peut le lirechmod 600 retire toute permission au groupe et aux autres : même un autre utilisateur du serveur ne peut pas lire les secrets. (Rappel des permissions Unix en Partie 3.)
🐚 Au terminal — Pour vérifier d’un coup d’œil qu’aucun fichier de secrets n’est trop ouvert dans le dossier partagé :
find /var/www/formacampus/shared -name ".env*" -exec ls -l {} \;
# Chaque .env doit afficher -rw------- (600). Tout ce qui montre r pour groupe/autres est à corriger.Les secrets GitHub pour la CI
La CI (13.3) a besoin de secrets à elle : la clé SSH de déploiement, éventuellement un token de registre. Ils vivent dans les secrets GitHub (Settings → Secrets and variables → Actions), chiffrés, injectés dans le workflow via ${{ secrets.XXX }}, et masqués dans les logs. Le workflow ne voit jamais la valeur en clair dans le fichier — seulement la référence.
L’EnvironmentFile de systemd
Comment l’app en prod reçoit-elle les variables du .env ? Si tu la lances comme un service systemd (Partie 9), le unit file charge le fichier via la directive EnvironmentFile :
# /etc/systemd/system/formacampus-web.service (extrait)
[Service]
User=deploy
EnvironmentFile=/var/www/formacampus/shared/.env # injecte chaque ligne comme variable d'env
ExecStart=/usr/bin/node /var/www/formacampus/current/server.jsÀ chaque démarrage, systemd lit le fichier et fournit ces variables à l’app, sans qu’elles apparaissent nulle part dans le dépôt. On peut aussi poser des variables inline avec Environment=, mais pour des secrets, EnvironmentFile (avec un fichier en 600) est préférable : les secrets ne traînent pas dans le unit file, lui-même souvent moins protégé.
⚠️ Piège — Croire qu’une variable
NEXT_PUBLIC_*(Next.js) est un bon endroit pour un secret. Non : tout ce qui est préfixéNEXT_PUBLIC_est inliné dans le bundle JavaScript envoyé au navigateur — donc public par construction. Une clé d’API secrète mise là est visible par n’importe quel visiteur dans le code source de la page. Les secrets restent côté serveur (variables sans ce préfixe, lues par le back), jamais exposés au client.
Quatre familles de secrets à ne pas confondre
Tous les secrets ne se ressemblent pas. Les classer évite de les mettre au mauvais endroit :
| Famille | Exemple | Où il vit | Qui le lit |
|---|---|---|---|
| Serveur (runtime) | mot de passe de base, clé d’API | .env sur le serveur (600), via EnvironmentFile | l’app en prod |
| CI | clé SSH de déploiement, token de registre | secrets GitHub | le workflow |
| Build-time | token pour télécharger un paquet privé pendant le build | secret CI, le temps du build | l’étape de build |
| Runtime | tout ce dont l’app a besoin en marche | .env serveur | le process de l’app |
La distinction build-time vs runtime est subtile et importante. Un secret build-time (ex. un token pour installer un paquet npm privé) n’est utile que pendant la compilation ; il ne doit pas se retrouver dans l’artefact final ni sur le serveur de prod. Un secret runtime (ex. le mot de passe de la base) n’est pas nécessaire au build : il ne doit vivre que sur le serveur, au moment de l’exécution. Confondre les deux, c’est soit exposer un secret dans un artefact, soit le distribuer plus large que nécessaire.
💡 Réflexe — Avant de placer un secret, pose-toi deux questions : qui en a besoin (l’app en prod ? la CI ? l’étape de build ?) et quand (à l’exécution ? seulement pendant le build ?). La réponse te donne l’endroit exact — et t’évite de coller un secret runtime dans la CI ou un secret de build sur le serveur de prod. Le bon secret, au bon endroit, pour la bonne durée.
Moindre privilège et rotation
Deux principes rendent une fuite moins grave quand elle arrive (et elle arrive toujours un jour).
Le moindre privilège. Chaque secret ne doit donner accès qu’au strict nécessaire. Une deploy key GitHub peut être configurée en lecture seule : la CI n’a pas besoin d’écrire dans le dépôt pour déployer. Un token d’accès doit avoir la portée la plus étroite (scope) : un token qui ne sert qu’à lire un registre de paquets ne doit pas pouvoir supprimer des dépôts. Côté serveur, la clé SSH de déploiement peut être restreinte à une seule commande dans authorized_keys :
# Dans /home/deploy/.ssh/authorized_keys : cette clé ne peut QUE lancer deploy.sh
command="/var/www/formacampus/deploy.sh",no-port-forwarding,no-pty ssh-ed25519 AAAA... github-actions-deployAinsi, même si la clé de déploiement fuite, un attaquant ne peut que relancer le script de déploiement — pas ouvrir un shell, pas rebondir ailleurs. Le dégât potentiel est borné.
La rotation. Un secret n’est pas éternel. On le change régulièrement (rotation planifiée) et immédiatement en cas de doute (départ d’un collaborateur, fuite suspectée, machine compromise). Une bonne gestion des secrets rend la rotation indolore : puisqu’un secret n’est référencé qu’à un seul endroit (le .env serveur, ou le secret GitHub), le changer se fait en une opération, sans chasse aux occurrences dispersées dans le code.
🔒 Sécurité — Génère une clé dédiée par usage : une paire pour le déploiement CI, distincte de ta clé SSH personnelle, distincte des clés d’autres services. L’intérêt est la compartimentation : révoquer la clé de déploiement (parce qu’elle a fuité) n’impacte ni ton accès admin ni les autres automatismes. Une clé unique et partout réutilisée transforme une fuite locale en compromission globale. Une clé par usage transforme la même fuite en incident contenu, réparable par une seule révocation.
Les gestionnaires de secrets (survol)
Quand le nombre de secrets, d’environnements ou de personnes grandit, gérer des .env à la main montre ses limites. Des outils dédiés existent — cités ici pour que tu saches vers quoi te tourner, sans les détailler :
- HashiCorp Vault — un serveur de secrets centralisé : les apps demandent leurs secrets à Vault au démarrage (authentifiées), avec des secrets dynamiques (identifiants de base générés à la volée, à durée de vie limitée) et une traçabilité complète des accès. Puissant, mais c’est une infra à opérer.
- SOPS (Mozilla) — chiffre des fichiers de secrets (YAML, JSON,
.env) pour pouvoir les stocker dans git de façon chiffrée : seuls ceux qui ont la clé de déchiffrement peuvent les lire. On garde le versionnement sans exposer les valeurs. - age — un outil de chiffrement de fichiers simple et moderne, souvent utilisé sous SOPS ou seul pour chiffrer un
.envavant de le transporter.
Pour un VPS unique et une petite équipe, un .env en 600 sur le serveur plus les secrets GitHub pour la CI suffisent amplement. Les gestionnaires deviennent utiles à l’échelle : beaucoup de services, plusieurs environnements, rotation fréquente, audit exigé.
📚 La doc — Chaque outil a sa doc de référence : Vault , SOPS et age sont documentés sur leurs dépôts respectifs. Côté plateforme, la doc GitHub sur les secrets détaille les secrets de dépôt, d’environnement et d’organisation, et les bonnes pratiques (masquage, limites de taille, environnements protégés).
🧭 Sur FormaCampus — Le partage des secrets de FormaCampus suit exactement cette logique. Les secrets runtime (URL Postgres avec mot de passe, clés d’API mail, secret JWT) vivent dans
shared/.envsur le VPS, enchmod 600, chargé par le service systemd viaEnvironmentFile— hors du dépôt, où un.env.exampledocumente seulement les noms des variables. Les secrets CI (la clé SSH de déploiement, en lecture seule et restreinte au seuldeploy.shcôté serveur) sont des secrets GitHub, lus par le workflow via${{ secrets.SSH_KEY }}. Le jour où un ancien prestataire est parti, l’équipe a fait tourner la clé de déploiement et le secret JWT en deux opérations, sans toucher au code. Et quand un stagiaire a un jour poussé un.envpar mégarde, le réflexe a été immédiat : révoquer et régénérer les secrets concernés, puis nettoyer l’historique — jamais l’inverse.
Ce qu’il faut retenir
Un secret ne va jamais dans git : .gitignore le tient dehors, un .env.example documente les noms sans les valeurs. S’il a fuité, on révoque d’abord, on nettoie l’historique ensuite. Chaque secret vit à l’endroit qui correspond à qui le lit et quand : .env serveur en 600 chargé par EnvironmentFile pour l’app (runtime), secrets GitHub pour la CI, en distinguant build-time et runtime. On applique le moindre privilège (deploy key en lecture seule, token à portée réduite, clé restreinte à une commande) et la rotation (planifiée et en cas de doute), avec une clé dédiée par usage pour compartimenter. À l’échelle, on passe à un gestionnaire de secrets (Vault, SOPS, age). Bien gérés, les secrets protègent tout ce que les chapitres précédents ont automatisé.
✏️ Exercices
Exercice 1 — Le secret commité. Un collègue réalise qu’il a poussé un fichier .env contenant le mot de passe de la base de prod sur un dépôt GitHub public il y a une heure. Il propose de faire un commit qui supprime le fichier. Que lui réponds-tu, et dans quel ordre agir ?
✅ Solution
Supprimer le fichier dans un nouveau commit ne suffit pas : le mot de passe reste dans l’historique git et, sur un dépôt public, il a très probablement déjà été récupéré par des bots (ils scannent GitHub en continu). Le secret doit être considéré comme compromis. Ordre d’action : 1. Révoquer et remplacer — changer immédiatement le mot de passe de la base de prod (et tout autre secret du fichier), et mettre à jour le .env serveur. 2. Nettoyer l’historique — réécrire l’historique (git filter-repo ou BFG) pour retirer le fichier de tous les commits, et forcer les collaborateurs à re-cloner. 3. Prévenir la récidive — ajouter .env au .gitignore, mettre en place un scan de secrets. La révocation passe avant le nettoyage : nettoyer l’historique ne dé-compromet pas un secret déjà lu.
Exercice 2 — Range chaque secret. Pour chacun, dis où il doit vivre : (A) le mot de passe Postgres que l’API utilise en prod ; (B) la clé SSH que GitHub Actions utilise pour se connecter au VPS ; (C) un token qui ne sert qu’à télécharger un paquet npm privé pendant le build en CI ; (D) le secret JWT que l’app signe à l’exécution.
✅ Solution
(A) .env sur le serveur (dans shared/, en chmod 600), chargé par systemd via EnvironmentFile — c’est un secret runtime, lu par l’app en prod. (B) Secret GitHub (${{ secrets.SSH_KEY }}) — secret CI, et la clé doit être dédiée et à privilèges minimaux. (C) Secret GitHub également, mais c’est un secret build-time : il ne sert que pendant le build en CI et ne doit pas se retrouver dans l’artefact ni sur le serveur de prod. (D) .env sur le serveur — secret runtime, jamais exposé au client (surtout pas via NEXT_PUBLIC_). Règle : qui le lit et quand déterminent l’endroit.
🧠 Quiz de révision
1. Pourquoi supprimer un secret dans un commit ultérieur ne suffit-il pas ?
Parce que git conserve tout l’historique : le secret reste présent dans les commits antérieurs (git log -p le retrouve). Sur un dépôt public, il a de plus probablement déjà été récupéré par des bots. La seule réponse valable est de révoquer et remplacer le secret ; le nettoyage de l’historique vient après et ne dé-compromet pas un secret déjà exposé.
2. Quelles permissions pour un .env sur le serveur, et pourquoi ?
.env sur le serveur, et pourquoi ?chmod 600 : lecture/écriture pour le seul propriétaire (l’utilisateur deploy), aucun droit pour le groupe ni les autres. Ainsi, même un autre utilisateur du serveur ne peut pas lire les secrets. Un .env lisible par tous serait une fuite locale évidente.
3. Où l’app en prod récupère-t-elle ses secrets, et comment systemd les fournit-il ?
Dans un .env sur le serveur (hors dépôt, en 600). Le service systemd le charge via la directive EnvironmentFile dans le unit file : à chaque démarrage, systemd lit le fichier et injecte chaque ligne comme variable d’environnement du process. Les secrets n’apparaissent donc jamais dans le dépôt ni, idéalement, dans le unit file lui-même.
4. Quelle est la différence entre un secret build-time et un secret runtime ?
Un secret build-time n’est utile que pendant la compilation (ex. un token pour installer un paquet privé) et ne doit pas se retrouver dans l’artefact final ni sur le serveur. Un secret runtime est nécessaire à l’exécution de l’app (ex. mot de passe de base) et vit sur le serveur, pas dans le build. Les confondre expose un secret trop largement ou l’embarque là où il ne devrait pas être.
5. Que signifie « moindre privilège » appliqué à une clé de déploiement ?
Que la clé ne doit pouvoir faire que le strict nécessaire : une deploy key en lecture seule, un token à portée réduite, et côté serveur une clé SSH restreinte à une seule commande (command="…" dans authorized_keys, sans shell ni port-forwarding). Ainsi, si la clé fuite, le dégât est borné : un attaquant ne peut, au pire, que relancer le déploiement, pas ouvrir un shell ni rebondir. On génère aussi une clé dédiée par usage pour que sa révocation n’impacte rien d’autre.
Fin de la Partie 13. Ton déploiement est désormais automatique, testé, sans coupure, réversible et sécurisé. Reste à savoir, en continu, si tout tourne bien : Partie 14 — Observer & superviser — logs, métriques, monitoring et alerting.