Drei Schichten für ein Secret — von pass über SOPS und Vault in den Pod

In einem öffentlichen GitOps-Repo darf nie ein Klartext-Secret liegen — und trotzdem muss jeder Workload an seine Passwörter kommen. Mein Lab löst das in drei klar getrennten Schichten: SOPS+PGP/age für die paar Bootstrap-Secrets, die vor Vault existieren müssen; Vault KV als Langzeitspeicher für alles Anwendungsnahe; und der External Secrets Operator, der daraus zur Laufzeit Kubernetes-Secrets materialisiert. Eine Tour durch den Lebensweg eines Geheimnisses.
Table of contents

Mein Lab ist ein öffentliches GitOps-Repo — über meinen Radicle -Seed-Node seed.this-is-fine.io kann es jeder klonen. Daraus folgt eine harte Regel: kein Klartext-Secret, niemals, nirgends in Git. Und trotzdem braucht jeder Workload im Cluster seine Passwörter, API-Tokens und Signing-Keys. Diesen Widerspruch löse ich in drei Schichten, von denen jede genau eine Aufgabe hat.

Im Beitrag über die pre-commit-Hooks ging es darum, wie ich verhindere, dass ein Secret versehentlich in Git landet. Hier geht es um den geplanten Weg: wie ein Secret absichtlich von meiner Maschine bis in einen Pod fließt, ohne je unverschlüsselt das Repo zu berühren.

Drei Schichten, drei Aufgaben

Contributor ──sops edit──► Git repo ──────► Flux
(pass / 1Password)         (*.sops.yaml)    (SOPS-Decryption-Key)
                                              │ schreibt Seed-Secrets
   Workload    ◄──  Kubernetes  ◄── External Secrets ◄── Vault KV
 (env / volume)        Secret      (ClusterSecretStore)

Jede Schicht hat eine klare Rolle — und die Kunst liegt darin, jedes Secret in der richtigen zu halten:

SchichtWerkzeugWofür
Repo at-restSOPS + PGP/ageBootstrap-Secrets (Vault-Root-Token, Cluster-Credentials)
LangzeitspeicherVault KV v2alles Anwendungsnahe (DB-Passwörter, API-Tokens, OAuth-Credentials)
Cluster-RuntimeExternal Secrets Operatormaterialisiert K8s-Secrets aus Vault im Refresh-Intervall

Der entscheidende Satz: Der Großteil meiner Secrets lebt in Vault. SOPS trägt nur die wenigen, die existieren müssen, bevor Vault existiert — ein Henne-Ei-Problem, das ich gleich auflöse.

Schicht 1: SOPS, und warum mehrere Empfänger

SOPS verschlüsselt Werte in YAML/JSON gegen eine Liste von Empfängern. Die Regeln stehen in .sops.yaml und mappen Datei-Globs auf PGP-Fingerprints und age-Recipients:

keys:
  age: &age_keys
    - age15xjaulc96n9yasy3v7alv3azuu9nkksm4f2cyj4c3w0petce05psler9u4  # hydra (Flux)
    - age1y9pzrrsmcc6eshl99xsxkcx53ekm4cszl44gc05zxxwzvu96c94ss9tn32  # ZeroClaw-Agent
  pgp: &pgp_keys
    - 651857F9EA219C8320D52332B6058AC136181295                        # Contributor

creation_rules:
  - path_regex: ^.*\.sops-k8s\.ya?ml$
    encrypted_regex: ^(data|stringData)$   # nur die Secret-Felder, Struktur bleibt diffbar
    pgp: *pgp_keys
    age: *age_keys
  - path_regex: ^.*\.sops(?:(\.|-).*)?$
    pgp: *pgp_keys
    age: *age_keys

Zwei Dinge, die ich teuer gelernt habe:

  • encrypted_regex hält den Diff lesbar. Bei Kubernetes-Secrets (.sops-k8s.yaml) verschlüssele ich nur data/stringData — die Struktur (Namespace, Name, Keys) bleibt im Klartext. So sehe ich in einem PR-Diff, dass sich ein Secret geändert hat, ohne den Wert zu sehen.
  • Mehrere age-Keys gehören in eine YAML-Liste, nicht in einen Folded-Scalar. Schreibt man mehrere age1…-Zeilen unter >-, macht YAML daraus einen String mit Leerzeichen — und SOPS quittiert mit invalid recipient encoding (oft s[62]=32, ein Space). Eine Liste ist auch besser kommentierbar.

Die zwei Empfänger sind kein Zufall, sondern das Henne-Ei-Problem gelöst: Der erste age-Key gehört Flux im Cluster hydra — damit entschlüsselt die GitOps-Engine die Seed-Secrets beim Reconcile. Der zweite gehört dem ZeroClaw-Agenten, der in einem Checkout interaktiv sops braucht. Der PGP-Key bin ich — der Mensch, der die Dateien editiert.

Contributor-Keys deklarativ

Die PGP-Fingerprints kommen nicht von Hand in die .sops.yaml, sondern aus .public-keys/USERS.json — ein Contributor-Eintrag verweist auf seinen öffentlichen Key (z. B. via SourceHut), ein Task holt ihn und re-verschlüsselt:

# Contributor zu USERS.json hinzufügen, dann:
task core:update-gpg-keys   # re-encrypt jede SOPS-Datei mit der neuen Empfängerliste
task core:update-age-key    # rotiert den Cluster-seitigen Flux-AGE-Key

Nach dem Commit re-decrypted Flux beim nächsten Reconcile mit dem Cluster-Key. Hinzufügen eines Menschen und Rotieren des Maschinen-Keys sind zwei getrennte, idempotente Operationen — genau so, wie ich es haben will.

Schicht 2: Vault als Langzeitspeicher

Nach dem Bootstrap wandert alles, was eine Anwendung zur Laufzeit braucht, in Vault KV v2. SOPS für App-Secrets wäre möglich, aber Vault gibt mir Rotation, Audit und ein einheitliches Namensschema <scope>/<system>[/<subsystem>]:

PfadBeispiel
apps/<name>apps/matrix, apps/mastodon
db/<engine>/<host>/<db>db/dragonfly/main/main
mail/<domain>mail/this-is-fine.social
volsync/restic/<repo>volsync/restic/borgbase

Secrets schiebe ich direkt aus meinem lokalen pass-Store hinein — der Wert berührt nie eine Datei:

task vault:add-kv-secret STORE=apps/matrix \
  SECRETS="signing_key='PASS+apps/matrix/signing.key'"

Das PASS+-Präfix ist die Pointe: Der Task liest den Wert zur Laufzeit aus pass, schreibt ihn nach Vault, und nirgends entsteht ein Klartext-Artefakt auf der Platte. Mein pass-Store ist die Quelle der Wahrheit für mich, Vault die Quelle für den Cluster.

Schicht 3: External Secrets Operator

Und jetzt die schönste Schicht, weil sie das Henne-Ei-Problem endgültig versteckt: Ein Workload liest nie direkt aus Vault. Stattdessen deklariere ich neben dem Workload eine ExternalSecret -Ressource, und der Operator materialisiert daraus ein Kubernetes-Secret:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: matrix-signing-key
  namespace: matrix
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: vault
  target:
    name: matrix-signing-key
    creationPolicy: Owner
  data:
    - secretKey: signing.key
      remoteRef:
        key: apps/matrix
        property: signing_key

Der Operator erzeugt ein Secret namens matrix-signing-key mit einem signing.key-Feld, das ich ganz normal per env: oder volumes: mounte. Mein Manifest in Git referenziert nur den Pfad in Vault — apps/matrix, Property signing_key. Der Wert taucht erst im Cluster auf, materialisiert vom Operator, der über einen ClusterSecretStore an Vault hängt.

flowchart LR PASS["pass (lokal)"] -->|task vault:add-kv-secret| VAULT["Vault KV v2<br/>apps/matrix"] GIT["Git: ExternalSecret<br/>(nur der Pfad)"] --> FLUX["Flux reconcile"] FLUX --> ESO["External Secrets Operator"] VAULT --> ESO ESO -->|materialisiert| SEC["K8s Secret<br/>matrix-signing-key"] SEC -->|env / volume| POD["Synapse Pod"]

Über Cluster hinweg

Vault läuft in einem Cluster (dem Vault-primary). Jeder andere Cluster erreicht es über das Headscale-Tailnet unter https://vault.${TAILNET_DOMAIN} — der ClusterSecretStore zeigt schlicht auf diese Tailnet-URL. Damit ist der Secret-Backend genau einmal vorhanden und föderiert über dasselbe WireGuard-Fabric wie der Rest meiner Cross-Cluster-Dienste.

Rotation und Auditing

Weil die Werte zentral in Vault liegen, ist Rotation ein Punkt, nicht hundert:

Secret-TypWerkzeugKadenz
SOPS-Secrets at-restsops re-encryptwenn Contributor sich ändern
Vault-Tokens (von ESO konsumiert)task vault:renew-tokensquartalsweise, oder bei permission denied
App-Secrets (DB, API-Keys)task vault:add-kv-secret …je nach App-Policy

Will ich nicht aufs Refresh-Intervall warten, stoße ich den Resync von Hand an:

kubectl annotate externalsecret <name> -n <ns> \
  force-sync="$(date +%s)" --overwrite

Und für den Überblick, welche Secrets der Operator wo erzeugt hat:

kubectl get externalsecret -A
kubectl get secrets -A -l 'reconcile.external-secrets.io/managed=true'
Wohin die Reise geht. Vault ist mächtig, aber im Lab auch schwergewichtig — eine StatefulSet-Instanz, die sealed/unsealed werden will und deren Token-Ablauf der häufigste Ausfallgrund ist. Für die PKI habe ich Vault bereits durch eine Offline-CA-Hierarchie ersetzt; als Secret-Backend wandert es schrittweise zurück nach SOPS. Die drei Schichten bleiben — nur die mittlere wird leichter.

Warum die Trennung trägt

Die saubere Drei-Schichten-Teilung ist mehr als Ordnung. Sie ist eine Antwort auf drei verschiedene Vertrauensfragen: Wer darf das Repo entschlüsseln (SOPS-Empfänger), wer ist die Quelle der Wahrheit zur Laufzeit (Vault), und wer übersetzt das eine ins andere (der Operator). Jede Schicht kennt nur ihre Nachbarn. Mein pass-Store weiß nichts von Kubernetes, der Pod weiß nichts von Vault, und Git weiß von keinem Wert — nur von Pfaden und Empfängern. Genau diese Unwissenheit ist der Schutz.