J’ai 25 ExternalSecrets dans mon cluster RKE2 qui pointent vers Pulumi ESC. Des API keys, des mots de passe de base de données, des tokens OAuth. Le tout stocké dans un SaaS, récupéré par l’External Secrets Operator via un ClusterSecretStore.
Le problème : si Pulumi ferme, si je perds mon compte, ou si leur API tombe un samedi soir pendant que je redeploy un truc urgent, plus aucun secret n’arrive dans le cluster. Single point of failure.
J’ai regardé les alternatives. Infisical tourne dans un container Docker, donc même dépendance à un service qui peut tomber. OpenBao (le fork de Vault) demande un backend de stockage, du unsealing, de la HA - trop de complexité pour un homelab. Sealed Secrets chiffre en cluster mais ne fonctionne pas si on veut déchiffrer en local. Ce que je voulais : des secrets chiffrés directement dans Git, lisibles en structure, déchiffrables par ArgoCD.
Pourquoi SOPS + age
SOPS chiffre uniquement les valeurs d’un fichier YAML, pas les clés. Avec l’option encrypted_regex, je cible data et stringData - le reste du manifest reste lisible dans Git. On voit le nom du secret, le namespace, le type, mais pas les valeurs.
age remplace GPG. Une clé publique de 62 caractères pour chiffrer, une clé privée pour déchiffrer. Pas de keyring, pas de serveur de clés, pas de web of trust. Un fichier texte de deux lignes.
Zéro infra supplémentaire. Les secrets vivent dans le repo Git, chiffrés. La clé age se backup sur une clé USB, dans un password manager, sur papier. Git est distribué, donc les secrets le sont aussi.
L’image ArgoCD custom
J’avais déjà un Dockerfile custom pour le repo-server ArgoCD. Il embarque SOPS, helm-secrets, argocd-vault-plugin et kubectl. J’ai ajouté age. Voici le Dockerfile complet :
ARG ARGOCD_VERSION="v3.3.0"
FROM quay.io/argoproj/argocd:$ARGOCD_VERSION
ARG SOPS_VERSION="3.11.0"
ARG AGE_VERSION="1.3.1"
ARG HELM_SECRETS_VERSION="4.7.5"
ARG KUBECTL_VERSION="1.35.1"
ARG AVP_VERSION="1.17.0"
USER root
ENV HELM_PLUGINS="/home/argocd/.local/share/helm/plugins/"
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git bash ca-certificates wget gpg curl tar jq && \
rm -rf /var/lib/apt/lists/*
# SOPS
RUN curl -o /usr/local/bin/sops -L \
https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64 && \
chmod +x /usr/local/bin/sops
# age (chiffrement)
RUN curl -L -o /tmp/age.tar.gz \
https://github.com/FiloSottile/age/releases/download/v${AGE_VERSION}/age-v${AGE_VERSION}-linux-amd64.tar.gz && \
tar -xzf /tmp/age.tar.gz -C /tmp && \
mv /tmp/age/age /usr/local/bin/age && \
mv /tmp/age/age-keygen /usr/local/bin/age-keygen && \
chmod +x /usr/local/bin/age /usr/local/bin/age-keygen && \
rm -rf /tmp/age*
# kubectl
RUN curl -fsSL https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/amd64/kubectl \
-o /usr/local/bin/kubectl && chmod +x /usr/local/bin/kubectl
# argocd-vault-plugin
RUN curl -L -o /usr/local/bin/argocd-vault-plugin \
https://github.com/argoproj-labs/argocd-vault-plugin/releases/download/v${AVP_VERSION}/argocd-vault-plugin_${AVP_VERSION}_linux_amd64 && \
chmod +x /usr/local/bin/argocd-vault-plugin
ENV ARGOCD_USER_ID=999
USER $ARGOCD_USER_ID
# helm-secrets
RUN helm plugin install --version ${HELM_SECRETS_VERSION} \
https://github.com/jkroepke/helm-secrets
Premier piège : les binaires sont amd64. Mon nodeSelector global force certains pods sur des Raspberry Pi (arm64). Le repo-server s’est retrouvé schedulé sur un Pi, crash au démarrage. Le fix : un nodeSelector dédié dans les values Helm du repo-server pour le forcer sur un node amd64.
Le Config Management Plugin
ArgoCD ne sait pas déchiffrer SOPS nativement. Il faut un Config Management Plugin (CMP) - un sidecar dans le repo-server qui intercepte les manifests avant qu’ArgoCD les applique.
Le plugin est déclaré comme ConfigMap. Il scanne les fichiers YAML, détecte ceux qui contiennent sops:, les déchiffre, et passe le YAML en clair à ArgoCD :
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cmp-sops
namespace: kube-infra
data:
plugin.yaml: |
apiVersion: argoproj.io/v1alpha1
kind: ConfigManagementPlugin
metadata:
name: sops
spec:
generate:
command: [sh, -c]
args:
- |
for f in $(find . -name '*.yaml' -type f); do
if grep -q 'sops:' "$f"; then
sops --decrypt "$f"
else
cat "$f"
fi
echo "---"
done
Le sidecar CMP tourne dans le pod repo-server avec l’image custom. Voici l’extrait des helm values ArgoCD :
repoServer:
image:
repository: oci-storage.dc-tech.work/argo
tag: v3.3.1
nodeSelector:
kubernetes.io/hostname: um870 # forcer amd64, pas de Pi
env:
- name: SOPS_AGE_KEY_FILE
value: /app/config/age/age-key.txt
volumes:
- name: age-key
secret:
secretName: argocd-age-key
- name: cmp-sops
configMap:
name: argocd-cmp-sops
volumeMounts:
- name: age-key
mountPath: /app/config/age
readOnly: true
extraContainers:
- name: sops-plugin
command: [/var/run/argocd/argocd-cmp-server]
image: oci-storage.dc-tech.work/argo:v3.3.1
env:
- name: SOPS_AGE_KEY_FILE
value: /app/config/age/age-key.txt
securityContext:
runAsNonRoot: true
runAsUser: 999
volumeMounts:
- name: var-files
mountPath: /var/run/argocd
- name: plugins
mountPath: /home/argocd/cmp-server/plugins
- name: cmp-sops
mountPath: /home/argocd/cmp-server/config/plugin.yaml
subPath: plugin.yaml
- name: age-key
mountPath: /app/config/age
readOnly: true
- name: tmp
mountPath: /tmp
Le sidecar et le repo-server partagent le même volume age-key, monté depuis un Secret Kubernetes. La variable SOPS_AGE_KEY_FILE pointe vers /app/config/age/age-key.txt.
Comment ca marche
Chiffrer un secret
Le fichier .sops.yaml à la racine du dossier secrets définit la clé publique et le regex de chiffrement :
creation_rules:
- path_regex: \.yaml$
encrypted_regex: "^(data|stringData)$"
age: age10nk65gu2ex4lqzdrv7rvm22n3u4zk6c5cgxqkq5cj5n3e2kzmfmqnh078m
Je crée mon Secret YAML en clair :
apiVersion: v1
kind: Secret
metadata:
name: sops-test-secret
namespace: default
type: Opaque
stringData:
username: admin
password: mon-super-mot-de-passe
api-key: sk-live-abc123def456
Puis sops --encrypt --in-place secret.yaml. Le résultat garde la structure lisible, seules les valeurs sous stringData sont chiffrées :
apiVersion: v1
kind: Secret
metadata:
name: sops-test-secret
namespace: default
type: Opaque
stringData:
username: ENC[AES256_GCM,data:OxHIbNc=,iv:/rLZTDyCEkLy...,tag:2xWBIV...,type:str]
password: ENC[AES256_GCM,data:UpmLyyBAnAA8fy65q7oio39GZRM0NDPw,iv:fSK56eMl...,tag:Jg5/oM...,type:str]
api-key: ENC[AES256_GCM,data:NtWNjq8dExny80YUdg7HisDAg4ifIfyi,iv:D2pjzK4a...,tag:7DUrZI...,type:str]
sops:
age:
- recipient: age10nk65gu2ex4lqzdrv7rvm22n3u4zk6c5cgxqkq5cj5n3e2kzmfmqnh078m
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwK0dQ...
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-02-28T07:32:01Z"
encrypted_regex: ^(data|stringData)$
version: 3.11.0
Le metadata (name, namespace, type) reste lisible dans Git. On sait quel secret c’est sans pouvoir lire les valeurs.
L’Application ArgoCD
Le dossier secrets/apps/ contient tous les secrets chiffrés. Une Application ArgoCD pointe dessus avec le plugin sops :
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: sops-secrets
namespace: kube-infra
spec:
project: default
source:
repoURL: git@github.com:didlawowo/continuous-delivery.git
targetRevision: main
path: secrets/apps
plugin:
name: sops
destination:
server: https://kubernetes.default.svc
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
La ligne clé : plugin.name: sops. C’est ce qui redirige le rendu des manifests vers le sidecar CMP au lieu du rendu standard. ArgoCD pull le repo, le CMP déchiffre, les Secrets en clair atterrissent dans le cluster.
Vérifier que ça marche
Un pod de test qui monte le secret et affiche les valeurs déchiffrées :
apiVersion: v1
kind: Pod
metadata:
name: sops-test-pod
namespace: default
spec:
restartPolicy: Never
containers:
- name: test
image: busybox:latest
command: ["sh", "-c"]
args:
- |
echo "=== SOPS Secret Test ==="
echo "USERNAME: $(cat /secrets/username)"
echo "PASSWORD: $(cat /secrets/password)"
echo "API_KEY: $(cat /secrets/api-key)"
echo "=== OK ==="
sleep 300
volumeMounts:
- name: secret-volume
mountPath: /secrets
readOnly: true
volumes:
- name: secret-volume
secret:
secretName: sops-test-secret
$ kubectl logs sops-test-pod
=== SOPS Secret Test ===
USERNAME: admin
PASSWORD: mon-super-mot-de-passe
API_KEY: sk-live-abc123def456
=== OK ===
Le secret chiffré dans Git arrive en clair dans le pod. Le cycle complet fonctionne.
La galere du deploiement
J’ai poussé les helm values avec la nouvelle image custom avant d’avoir build et push l’image. Le repo-server est tombé immédiatement - l’image n’existait pas dans le registry.
Le problème : sans repo-server, ArgoCD ne peut plus lire les repos Git. Il ne peut donc plus se self-heal. Même le chart ArgoCD lui-même est bloqué.
J’ai dû faire un helm upgrade manuel. Sauf que Helm v4 avec le Server-Side Apply d’ArgoCD, c’est un champ de mines. --force est deprecated, --force-replace est incompatible avec SSA à cause des field ownerships du argocd-controller. La solution qui a marché :
helm upgrade argocd argo/argo-cd --server-side true --force-conflicts -n kube-infra
Leçon retenue : toujours builder et pusher l’image AVANT de modifier les values Helm.
Plan de migration
La migration se fait secret par secret depuis Pulumi ESC :
- Récupérer la valeur en clair depuis Pulumi
- Créer le manifest Secret YAML
- Chiffrer avec
sops --encrypt --in-place - Committer et pousser
- Supprimer l’ExternalSecret correspondant
Quand les 25 secrets seront migrés : désinstaller l’External Secrets Operator et supprimer le ClusterSecretStore Pulumi.
Code source
Les fichiers de configuration sont disponibles sur GitHub :
Dockerfile- Image ArgoCD custom avec SOPS, age, helm-secrets, kubectl, argocd-vault-pluginargocd-helm-values.yaml- Values Helm du repo-server avec le sidecar CMPcmp-sops-configmap.yaml- ConfigMap du plugin CMP SOPSsops-secrets-application.yaml- Application ArgoCD qui pointe vers le dossier secrets.sops.yaml- Configuration SOPS avec la clé publique agesecret-example.yaml- Exemple de secret en clair (avant chiffrement)secret-encrypted.yaml- Le même secret aprèssops --encrypttest-pod.yaml- Pod de test pour vérifier le déchiffrement
Bilan
Avant : 25 ExternalSecrets, un ClusterSecretStore, l’External Secrets Operator, un compte Pulumi ESC.
Après : 25 fichiers YAML chiffrés dans Git et une clé age de 62 caractères.
Pas de serveur externe, pas de base de données, pas d’abonnement. Le repo Git est le secret manager.