Who am I

Le couteau suisse à l'heure de l'IA

Migrer ses secrets Kubernetes de Pulumi ESC vers SOPS + age

EN REVIEW

Retour d'expérience sur la migration d'un gestionnaire de secrets SaaS vers du chiffrement Git-natif avec SOPS et age, intégré à ArgoCD via un Config Management Plugin.

28 February 2026
kubernetessecretsgitopsargocdsopsagesecurityhomelab

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

Flow de dechiffrement SOPS + age dans ArgoCD

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-plugin
  • argocd-helm-values.yaml - Values Helm du repo-server avec le sidecar CMP
  • cmp-sops-configmap.yaml - ConfigMap du plugin CMP SOPS
  • sops-secrets-application.yaml - Application ArgoCD qui pointe vers le dossier secrets
  • .sops.yaml - Configuration SOPS avec la clé publique age
  • secret-example.yaml - Exemple de secret en clair (avant chiffrement)
  • secret-encrypted.yaml - Le même secret après sops --encrypt
  • test-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.