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:
| Schicht | Werkzeug | Wofür |
|---|---|---|
| Repo at-rest | SOPS + PGP/age | Bootstrap-Secrets (Vault-Root-Token, Cluster-Credentials) |
| Langzeitspeicher | Vault KV v2 | alles Anwendungsnahe (DB-Passwörter, API-Tokens, OAuth-Credentials) |
| Cluster-Runtime | External Secrets Operator | materialisiert 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_regexhält den Diff lesbar. Bei Kubernetes-Secrets (.sops-k8s.yaml) verschlüssele ich nurdata/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 mehrereage1…-Zeilen unter>-, macht YAML daraus einen String mit Leerzeichen — und SOPS quittiert mitinvalid recipient encoding(ofts[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>]:
| Pfad | Beispiel |
|---|---|
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.
Ü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-Typ | Werkzeug | Kadenz |
|---|---|---|
| SOPS-Secrets at-rest | sops re-encrypt | wenn Contributor sich ändern |
| Vault-Tokens (von ESO konsumiert) | task vault:renew-tokens | quartalsweise, 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'
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.
