ITSkillsCenter
Cybersécurité

systemd secrets et environnement : tutoriel 2026

15 min de lecture

📍 Cluster : Sysadmin systemd. Articles frères : créer un service systemd, journalctl, timers vs cron.

Introduction

La gestion des secrets en production est l’un des points les plus délicats du déploiement applicatif sur VPS. Tokens d’API, mots de passe de base de données, clés JWT, certificats — tous doivent être disponibles à l’application au démarrage, mais jamais lisibles par un autre utilisateur du système, jamais loggés, jamais committés dans Git, et idéalement jamais en clair sur disque. Les pratiques classiques (variable d’env exportée dans ~/.bashrc, fichier .env à côté du code, EnvironmentFile dans le service systemd) ont chacune des limites de sécurité.

Depuis systemd 250 (août 2021, Debian 12, Ubuntu 22.04+, Rocky 9), une fonctionnalité moderne change la donne : LoadCredential et SetCredential. Les secrets sont fournis au service via un mécanisme dédié, isolés dans un répertoire /run/credentials/[unit]/ uniquement lisible par le service, et peuvent être chiffrés au repos avec une clé matérielle TPM 2.0 (credstore). Pour les VPS sans TPM, une solution intermédiaire combine LoadCredential avec un fichier .env stocké en chmod 0640 — déjà bien meilleur que les approches classiques. Ce tutoriel détaille les trois niveaux de sécurité (basique avec EnvironmentFile durci, intermédiaire avec LoadCredential et fichiers chiffrés au repos, avancé avec credstore TPM ou intégration Vault), avec exemples concrets et procédure de migration.

Prérequis

  • VPS Linux avec systemd ≥ 250 (Debian 12, Ubuntu 22.04 LTS+, Rocky/Alma 9)
  • Pour la section credstore TPM : VPS avec TPM 2.0 émulé ou physique (Hetzner Dedicated, certains Cloud)
  • Service systemd existant à sécuriser (cf. tutoriel frère « créer un service »)
  • Accès sudo
  • Niveau attendu : intermédiaire/avancé
  • Temps estimé : 30 à 50 minutes

Étape 1 — Niveau basique : EnvironmentFile durci

La méthode la plus courante reste EnvironmentFile, qui charge un fichier .env au démarrage du service. Mal configuré, ce fichier est lisible par tout utilisateur du VPS — ce qui exfiltre tous les secrets dès qu’un compte est compromis. Bien configuré, il offre déjà un niveau de sécurité acceptable pour la majorité des PME.

# Création sécurisée du fichier .env
sudo install -d -m 0750 -o root -g api-reservation /etc/api-reservation
sudo install -m 0640 -o root -g api-reservation /dev/null /etc/api-reservation/.env

# Édition (avec sudoedit, qui copie temporairement, édite, replace atomiquement)
sudo nano /etc/api-reservation/.env

# Contenu du fichier (format strict KEY=value, pas d'espaces autour de =)
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=apiuser
DATABASE_PASSWORD=motdepassepostgresfort
JWT_SECRET=cle-jwt-32-octets-base64
SMTP_PASSWORD=mailgun-api-key
STRIPE_SECRET_KEY=sk_live_...

# Vérifier les permissions
ls -la /etc/api-reservation/.env
# -rw-r----- 1 root api-reservation 312 Apr 27 14:30 /etc/api-reservation/.env

# Vérifier qu'un autre utilisateur ne peut PAS le lire
sudo -u nobody cat /etc/api-reservation/.env
# cat: /etc/api-reservation/.env: Permission denied   ← OK

# Dans le .service :
[Service]
EnvironmentFile=/etc/api-reservation/.env
User=api-reservation

Quatre principes-clés. Permissions 0640 root:groupe : root est propriétaire (write), groupe peut lire (où l’utilisateur du service est inclus), autres ne peuvent rien. Pas dans /opt/app/ : ne pas mélanger les secrets avec le code, qui peut être committé. Pas committé Git : ajouter *.env au .gitignore du repo de configuration. Backup chiffré : si vous backupez /etc/, chiffrer la sauvegarde car elle contient le .env. La limite : root du VPS lit toujours le fichier — quiconque obtient root peut tout exfiltrer. Pour aller plus loin, passer à LoadCredential.

Étape 2 — Niveau intermédiaire : LoadCredential

La directive LoadCredential (systemd 250+) charge un secret depuis un fichier source vers /run/credentials/[unit]/[name], accessible uniquement par le service. Cela isole les secrets du reste du système — ils n’apparaissent pas dans /proc/[pid]/environ (contrairement aux variables d’env classiques), ne fuient pas dans les logs systemd, et peuvent être lus uniquement après le ProtectHome/PrivateTmp activé.

# Stocker chaque secret dans un fichier individuel sous /etc/api-reservation/secrets/
sudo install -d -m 0700 -o root -g root /etc/api-reservation/secrets

echo -n "motdepassepostgresfort" | sudo tee /etc/api-reservation/secrets/db-password > /dev/null
echo -n "cle-jwt-32-octets-base64" | sudo tee /etc/api-reservation/secrets/jwt-secret > /dev/null
echo -n "sk_live_..." | sudo tee /etc/api-reservation/secrets/stripe-key > /dev/null

sudo chmod 0400 /etc/api-reservation/secrets/*
# Permissions ultra-strictes : seul root peut lire le fichier source

# Dans le .service :
[Service]
User=api-reservation
LoadCredential=db-password:/etc/api-reservation/secrets/db-password
LoadCredential=jwt-secret:/etc/api-reservation/secrets/jwt-secret
LoadCredential=stripe-key:/etc/api-reservation/secrets/stripe-key

# L'application les lit via la variable $CREDENTIALS_DIRECTORY définie automatiquement par systemd
ExecStart=/opt/api-reservation/bin/api-server

# Côté code Python par exemple :
# import os
# CREDENTIALS_DIR = os.environ['CREDENTIALS_DIRECTORY']  # /run/credentials/api-reservation.service
# with open(f'{CREDENTIALS_DIR}/db-password') as f:
#     db_password = f.read().strip()

Trois avantages concrets vs EnvironmentFile. Isolation : /run/credentials/api-reservation.service/ est créé dans une vue tmpfs privée du service, invisible aux autres processus du système (même root depuis l’extérieur ne voit rien dans /proc/[pid]/root/run/credentials/ sauf en cas de compromission majeure du namespace). Pas dans /proc/environ : les variables chargées via EnvironmentFile apparaissent dans /proc/[PID]/environ lisible par root et le user du service ; LoadCredential n’a pas ce problème. Adaptable à plusieurs sources : on peut combiner LoadCredential depuis fichier, depuis pipeline d’autre service, ou depuis credstore (étape suivante).

Étape 3 — Vérifier que les secrets sont bien isolés

Avant de passer en production, valider que les secrets ne fuient pas via les canaux classiques : /proc, dump systemd, logs, journal applicatif. Une vérification rigoureuse révèle souvent des fuites involontaires (l’app loggue accidentellement la connexion string complète au démarrage par exemple).

# Démarrer le service
sudo systemctl daemon-reload
sudo systemctl restart api-reservation

# Vérifier que les credentials sont chargés
sudo systemctl show api-reservation -p LoadCredential
# LoadCredential=db-password:/etc/api-reservation/secrets/db-password (...)

# Vérifier que /run/credentials/ contient les fichiers
sudo ls -la /run/credentials/api-reservation.service/
# Doit montrer : db-password, jwt-secret, stripe-key
# Permissions par défaut : 0400 root:root visible mais lecture refusée si pas le bon user

# Vérifier qu'aucun secret n'est dans /proc/PID/environ
PID=$(systemctl show api-reservation -p MainPID --value)
sudo cat /proc/$PID/environ | tr '\0' '\n' | grep -iE "password|secret|key"
# Doit retourner zéro ligne — les secrets ne sont PAS dans environ avec LoadCredential

# Vérifier les logs : aucun secret ne doit apparaître au démarrage
sudo journalctl -u api-reservation -n 100 | grep -iE "password|secret|key" | head
# Doit retourner zéro ligne idéalement

# Test négatif : un autre utilisateur non-privilégié essaie d'accéder
sudo -u nobody ls /run/credentials/api-reservation.service/ 2>&1
# Permission denied — OK

Si grep secret dans les logs trouve des occurrences, c’est l’app qui logue ses propres secrets — anti-pattern critique à corriger côté code applicatif. Beaucoup d’apps Python/Node loguent au démarrage la connexion string SQLAlchemy ou Mongoose qui contient le mot de passe. Ne jamais logguer la connexion string complète : extraire et logger uniquement host:port/database, masquer le password par ***.

Étape 4 — Niveau avancé : credstore avec TPM 2.0

Le mécanisme systemd-creds (systemd 250+, idéalement 252+) chiffre les secrets au repos avec une clé matérielle TPM 2.0. Le fichier sur disque est inutilisable sans le TPM du serveur — un attaquant qui dump le disque (snapshot, vol) ne peut pas déchiffrer. Cela s’applique aux VPS dotés d’un TPM (rare en cloud public, présent sur les serveurs dédiés Hetzner et certaines configurations OVH).

# Vérifier que le TPM est disponible
sudo systemd-creds has-tpm2
# yes (firmware), tpm2-tss installed
# OU "no" si pas de TPM — dans ce cas, fallback sur clé hôte non-TPM

# Chiffrer un secret au repos
echo -n "motdepassepostgresfort" | sudo systemd-creds encrypt --name=db-password \
    --tpm2-pcrs=7 - /etc/api-reservation/credstore/db-password.cred

# Le fichier .cred est chiffré, illisible sans le TPM
sudo cat /etc/api-reservation/credstore/db-password.cred
# Format binaire chiffré (commence par bytes magiques)

# Tester le déchiffrement (en dehors du service, pour vérifier)
sudo systemd-creds decrypt /etc/api-reservation/credstore/db-password.cred -
# motdepassepostgresfort (uniquement si TPM dispo)

# Dans le .service : utiliser LoadCredentialEncrypted au lieu de LoadCredential
[Service]
LoadCredentialEncrypted=db-password:/etc/api-reservation/credstore/db-password.cred
LoadCredentialEncrypted=jwt-secret:/etc/api-reservation/credstore/jwt-secret.cred

# Le service récupère le secret en clair dans /run/credentials/api-reservation.service/db-password
# Le fichier sur disque /etc/api-reservation/credstore/*.cred reste chiffré

# Variante : SetCredentialEncrypted directement dans le .service
# (le secret chiffré est inline dans le fichier .service, pas de fichier séparé)
[Service]
SetCredentialEncrypted=db-password: \
    Yk5SC3Kg7C3aS1bfgF... # base64 du chiffré

Sur les VPS sans TPM physique (Hetzner Cloud par exemple), systemd-creds peut quand même utiliser une clé d’hôte (/var/lib/systemd/credential.secret) générée au premier boot — meilleur que rien (un dump disque sans la clé est inutilisable), mais inférieur au TPM matériel (la clé est sur le même disque). Pour la majorité des cas PME, ce niveau intermédiaire est suffisant. Pour les exigences fortes (PCI-DSS, données médicales), passer à un VPS avec TPM ou à un secret manager externe (Vault, AWS Secrets Manager).

Étape 5 — Intégration avec HashiCorp Vault (alternative cloud-native)

Pour un parc de plusieurs VPS gérant beaucoup de secrets avec rotation, la centralisation devient nécessaire. HashiCorp Vault (open source, BSL 1.1 depuis 2023) est l’option la plus mature. Alternative récente : Infisical (open source MIT, plus simple, UI web claire). L’application récupère ses secrets au démarrage via une requête authentifiée vers Vault, plutôt que de les avoir sur le disque local.

# Architecture : Vault sur un VPS dédié (ou Coolify), apps client sur autres VPS

# Côté Vault : créer un secret
vault kv put secret/api-reservation \
    db-password=motdepassepostgresfort \
    jwt-secret=cle-jwt \
    stripe-key=sk_live_...

# Côté app : authentification via AppRole (recommandé pour services)
vault auth enable approle
vault write auth/approle/role/api-reservation \
    secret_id_ttl=720h \
    token_ttl=4h \
    token_policies=api-reservation-policy

# Récupérer role-id et secret-id (à stocker côté app)
vault read auth/approle/role/api-reservation/role-id
vault write -f auth/approle/role/api-reservation/secret-id

# Côté .service : un script wrapper qui récupère puis exporte
[Service]
ExecStartPre=/opt/api-reservation/bin/fetch-secrets.sh
ExecStart=/opt/api-reservation/bin/api-server
EnvironmentFile=/run/api-reservation-secrets.env

# /opt/api-reservation/bin/fetch-secrets.sh :
#!/bin/bash
set -euo pipefail
VAULT_TOKEN=$(vault write -field=token auth/approle/login \
    role_id=@/etc/api-reservation/role-id \
    secret_id=@/etc/api-reservation/secret-id)
VAULT_TOKEN=$VAULT_TOKEN vault kv get -format=json secret/api-reservation | \
    jq -r '.data.data | to_entries[] | "\(.key | ascii_upcase | gsub("-"; "_"))=\(.value)"' \
    > /run/api-reservation-secrets.env
chmod 0400 /run/api-reservation-secrets.env

Avec Vault, la rotation des secrets devient triviale : changer le mot de passe Postgres dans Vault, redémarrer le service api-reservation, le nouveau password est utilisé immédiatement. L’audit Vault enregistre qui a accédé à quel secret quand. Pour 1-2 VPS, l’overhead Vault (1 VPS dédié, configuration initiale ~2h) n’est pas justifié vs LoadCredential. Pour 5+ VPS et exigences compliance, Vault devient incontournable.

Étape 6 — Migration des secrets existants vers LoadCredential

Si vous avez un service en production avec EnvironmentFile, la migration vers LoadCredential se fait sans downtime en quelques étapes. Le principe : préparer la nouvelle structure en parallèle, modifier le .service, redémarrer le service, vérifier, puis nettoyer l’ancien fichier.env.

# 1. Préparer le nouveau dossier secrets
sudo install -d -m 0700 -o root -g root /etc/api-reservation/secrets

# 2. Migrer chaque variable du .env en fichier individuel
for line in $(sudo cat /etc/api-reservation/.env); do
    KEY=${line%%=*}
    VAL=${line#*=}
    KEY_LOWER=$(echo "$KEY" | tr 'A-Z_' 'a-z-')
    echo -n "$VAL" | sudo tee /etc/api-reservation/secrets/$KEY_LOWER > /dev/null
    sudo chmod 0400 /etc/api-reservation/secrets/$KEY_LOWER
done

# 3. Modifier le .service : commenter EnvironmentFile, ajouter LoadCredential
sudo nano /etc/systemd/system/api-reservation.service

# Avant :
#   EnvironmentFile=/etc/api-reservation/.env
# Après :
#   LoadCredential=db-password:/etc/api-reservation/secrets/database-password
#   LoadCredential=jwt-secret:/etc/api-reservation/secrets/jwt-secret
#   ...

# 4. Adapter le code applicatif pour lire $CREDENTIALS_DIRECTORY/
# au lieu d'os.environ['DB_PASSWORD']

# 5. Redémarrer
sudo systemctl daemon-reload
sudo systemctl restart api-reservation
sudo systemctl status api-reservation
# Vérifier que le service est bien actif

# 6. Une fois validé en prod, supprimer l'ancien .env
sudo shred -u /etc/api-reservation/.env

L’étape 4 (adaptation code) est la plus invasive : il faut modifier le code applicatif pour lire les fichiers $CREDENTIALS_DIRECTORY/[name] au lieu de os.environ['DB_PASSWORD']. Si l’app est tierce (image Docker non modifiable), cela peut être un blocage. Workaround : un script wrapper ExecStartPre qui lit les credentials et exporte des variables d’environnement temporaires dans /run, puis EnvironmentFile=/run/...env. Moins pur mais fonctionnel.

Erreurs fréquentes

ErreurCauseSolution
« systemd-creds: command not found »systemd version < 250Migrer vers Debian 12 / Ubuntu 22.04 LTS+ ; sur ancien systemd, rester sur EnvironmentFile durci
LoadCredential silencieusement ignorésystemd version < 247 ; ou path source non lisible par rootVérifier systemctl --version ; vérifier permissions du fichier source (root doit pouvoir lire)
App ne trouve pas $CREDENTIALS_DIRECTORYVariable pas exportée vers tous les shells (notamment si l’app passe par sh -c)Lire directement /run/credentials/[unit-name] ; le path est constant et prévisible
« systemd-creds decrypt failed: TPM error »TPM occupé, ou PCR registers ont changé (mise à jour BIOS)Re-encrypter avec les nouveaux PCR ; ou désactiver le binding TPM (moins secure)
Secrets logués au démarrage par l’appL’app dump sa config en clair au démarrageModifier le code pour masquer les secrets dans les logs ; ou filtrer côté journald avec une rule
EnvironmentFile lu mais variables videsEspaces autour du = ou quotes mal placésFormat strict KEY=value, sans espaces ; quotes uniquement si valeur contient des espaces

Adaptation au contexte ouest-africain

Trois aspects pratiques. Premièrement, niveau de sécurité réaliste : pour une PME ouest-africaine de 5-50 employés sans équipe sécurité dédiée, viser le niveau intermédiaire (LoadCredential + permissions strictes) est un excellent compromis. Le niveau avancé (TPM, Vault) ajoute une complexité opérationnelle qui peut introduire ses propres failles si mal géré (un Vault inaccessible bloque tous les services). La règle : sécurité proportionnelle à la valeur protégée et aux compétences disponibles.

Deuxièmement, gestion des secrets pour clients APIs locales : pour les intégrations Wave (Sénégal), Orange Money, MTN MoMo, les API keys sont fournies par les opérateurs lors de l’onboarding marchand. Ces clés sont aussi sensibles que les passwords Postgres : un attaquant peut détourner les paiements vers son propre compte. À traiter avec le même niveau de protection (LoadCredential ou Vault), jamais en clair dans un repo. Considérer aussi la rotation : Wave et PayDunya permettent la rotation depuis leur dashboard marchand — l’utiliser après tout incident sécurité même mineur.

Troisièmement, plan de continuité : si le VPS principal disparaît (panne hardware, incident hébergeur), comment redéployer les services avec leurs secrets ? La réponse : un backup chiffré des secrets sur un stockage externe (Hetzner Storage Box, S3 chiffré côté client avec rclone+gpg), avec procédure documentée pour restaurer sur un nouveau VPS. La clé de chiffrement du backup est conservée en lieu sûr (gestionnaire de mots de passe, coffre-fort physique, ou partagé via Shamir entre 2-3 personnes de confiance). Sans cette procédure, un incident majeur peut faire perdre tous les accès et nécessiter un reset complet de tous les services tiers (Stripe, Postgres, Brevo, SMS providers).

Tutoriels frères

FAQ

LoadCredential vs LoadCredentialEncrypted, quelle différence ?

LoadCredential charge le contenu d’un fichier source vers /run/credentials/ sans modification. Le fichier source est en clair (mais permissions strictes). LoadCredentialEncrypted charge un fichier chiffré (créé avec systemd-creds encrypt), le déchiffre avec la clé d’hôte (ou TPM), et rend le contenu en clair dans /run/credentials/. La différence : un dump du disque révèle les secrets avec LoadCredential, pas avec LoadCredentialEncrypted (s’il y a TPM ou clé hôte sur partition séparée).

Comment intégrer avec Coolify ?

Coolify gère ses propres « Environment Variables » stockées chiffrées dans sa base de données interne (chiffrement au repos avec une clé maître Coolify). Pour des services natifs Coolify, c’est suffisant et plus simple que LoadCredential. Pour des services systemd hors Coolify (apps custom à côté), utiliser LoadCredential. Pour combiner : Coolify + Vault est aussi possible (Coolify pour les déploiements, Vault pour les secrets sensibles partagés).

Que faire si un secret a été commité par accident dans Git ?

Considérer le secret comme compromis et le rotater immédiatement (changer le mot de passe Postgres, regénérer la clé JWT, révoquer puis remplacer la clé Stripe). Le secret restera dans l’historique Git même après un git rm — la rotation est l’unique parade. Pour le cleanup historique : git filter-repo ou BFG Repo-Cleaner sur un fork, mais c’est cosmétique — la rotation est l’action critique. Pour prévenir : git-secrets, pre-commit hook avec detect-secrets, scan régulier avec trufflehog.

Comment auditer quels services accèdent à quels secrets ?

Sur une approche LoadCredential locale : systemctl show --all --property=LoadCredential liste toutes les unités avec leurs credentials chargés. Pour un audit plus poussé, parcourir /run/credentials/ et corréler avec les services actifs. Avec Vault, l’audit est centralisé via vault audit enable file file_path=/var/log/vault-audit.log qui logge chaque accès avec timestamp, identité, secret demandé.

Pour aller plus loin

Mots-clés secondaires : LoadCredential, systemd-creds, secrets management, EnvironmentFile, Vault, Infisical, TPM 2.0, /run/credentials, hardening, secret rotation, dotenv sécurité.

Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité