Secrets im Git – aber richtig: sops + age (und ein Manager drumherum)
Irgendwann hat jedes Homelab dasselbe Problem: Du willst deine Konfiguration versionieren –
docker-compose.yml, Ansible-Playbooks, ein paar Skripte. Nur stecken da überall Geheimnisse
drin: API-Tokens, Passwörter, Keys. Ins Git-Repo dürfen die im Klartext auf keinen Fall, und ein
zweiter „geheimer” Ablageort neben dem Repo wird schnell zur Karteileiche.
Die saubere Antwort heißt sops + age: verschlüsselte Werte, die trotzdem im Git leben – mit kleinen, lesbaren Diffs. Dieser Post erklärt beide Werkzeuge von Grund auf und zeigt dann, wie ich darauf einen kleinen self-hosted Credential-Manager gebaut habe (Beispiele natürlich anonymisiert).
Das Problem mit Secrets im Git
Git ist gebaut, um alles aufzuheben – auch das, was du später löschst. Ein einmal committetes Token bleibt in der History, selbst wenn du es im nächsten Commit entfernst. Die naiven Auswege taugen alle nicht so recht:
.gitignore+ Datei daneben – funktioniert, aber die Datei ist nicht versioniert, nicht gesichert, und auf drei Geräten schon dreimal anders.- Alles verschlüsseln (z. B.
git-crypt) – dann sind aber auch harmlose Metadaten blind, und jeder Diff ist ein unlesbarer Binär-Blob. - Externer Secrets-Dienst (Vault & Co.) – mächtig, aber für ein Homelab oft ein Dampfhammer mit eigenem Betriebsaufwand.
Was man eigentlich will: nur die Werte verschlüsseln, die Struktur und Metadaten lesbar lassen, und das Ganze in ein ganz normales Git-Repo legen. Genau das ist die Nische von sops.
age: Verschlüsselung ohne Zeremonie
age („actually good encryption”) ist ein modernes, winziges Verschlüsselungswerkzeug – der geistige Nachfolger von „GPG, aber ohne die 20 Jahre Ballast”. Ein Schlüsselpaar ist zwei Zeilen:
1
2
age-keygen -o key.txt
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7wcajuf7f7z3xr...
- Der öffentliche Schlüssel (
age1…, der Recipient) darf überall stehen – mit ihm wird verschlüsselt. - Der private Schlüssel (
key.txt) entschlüsselt und bleibt geheim auf dem Host.
Das Elegante: Eine Datei kann für mehrere Recipients verschlüsselt werden. Jeder Host bekommt sein eigenes Schlüsselpaar, und die verschlüsselte Datei trägt einfach mehrere Empfänger – keiner muss den privaten Key eines anderen kennen. Diese Multi-Recipient-Fähigkeit ist später der Schlüssel für ein sauberes Mehr-Host-Setup.
sops: nur die Werte verschlüsseln
sops (Secrets OPerationS, ursprünglich von Mozilla) ist der Aufsatz, der age erst richtig git-tauglich macht. Statt die ganze Datei zu verschlüsseln, geht sops durch die Struktur (YAML, JSON, ENV, INI) und chiffriert nur die Werte – die Schlüsselnamen und die Form bleiben im Klartext.
Der Kern ist eine .sops.yaml mit creation rules:
1
2
3
4
creation_rules:
- path_regex: .*\.sops\.yaml$
encrypted_regex: '^(secret|passwort)$' # NUR diese Schlüssel verschlüsseln
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7wcajuf7f7z3xr...,age1lujaf7f7z3xr8hjy54pw3hyww5ay...
path_regex– auf welche Dateien die Regel greift.age– ein oder mehrere Recipients (kommagetrennt = Multi-Recipient).encrypted_regex– welche Schlüsselnamen verschlüsselt werden. Alles andere bleibt Klartext.
Aus einer Klartext-Datei…
1
2
3
4
5
6
credentials:
- id: dns-api
typ: api-token
titel: "DNS-API"
secret: "super-geheimes-token"
export_als: DNS_API_TOKEN
…wird nach sops -e -i secrets.sops.yaml:
1
2
3
4
5
6
7
8
9
10
11
12
credentials:
- id: dns-api
typ: api-token
titel: "DNS-API"
secret: ENC[AES256_GCM,data:9ec4949…,iv:…,tag:…,type:str]
export_als: DNS_API_TOKEN
sops:
age:
- recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7wca...
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
Sieh dir an, was hier passiert: id, typ, titel, export_als – alles lesbar. Nur secret
wurde zu ENC[…]. Genau deshalb sind die Git-Diffs so angenehm: Änderst du ein Token, ändert sich
eine ENC[…]-Zeile, nicht die halbe Datei.
encrypted_regexmatcht Schlüsselnamen an beliebiger Tiefe im Baum. Ein Feldsecretwird verschlüsselt, egal ob es oben liegt oder tief verschachtelt – das wird gleich noch wichtig.
Die tägliche Bedienung ist knapp:
1
2
3
4
sops secrets.sops.yaml # im $EDITOR öffnen (transparent ent-/verschlüsseln)
sops -d secrets.sops.yaml # entschlüsselt nach stdout
sops set secrets.sops.yaml '["credentials"][0]["secret"]' '"neuer-wert"'
sops -d --extract '["credentials"][0]["secret"]' secrets.sops.yaml # nur EIN Wert
Das --extract ist Gold wert: Du holst genau einen Wert heraus, ohne den ganzen Baum zu
entschlüsseln und durch die Prozessliste zu jagen.
Warum die Kombi ins Homelab passt
flowchart LR
DEV["Editor / CLI"] -->|"sops -e (age)"| FILE["secrets.sops.yaml<br/>nur Werte verschlüsselt"]
FILE -->|"git push"| GIT[("Git-Repo<br/>Single Source of Truth")]
GIT -->|"git pull"| H1["Host A<br/>age-Key A"]
GIT -->|"git pull"| H2["Host B<br/>age-Key B"]
H1 -->|"sops -d"| ENV1["$ENV / App-Config"]
H2 -->|"sops -d"| ENV2["$ENV / App-Config"]
- Versioniert & gesichert: Jede Änderung ist ein Commit. Dein Git-Backup ist automatisch dein Secrets-Backup – der Chiffretext ist ja harmlos.
- Kleine Diffs: Nur geänderte Werte wandern, Reviews bleiben lesbar.
- Pro Host ein Key: Dank Multi-Recipient trägt dieselbe Datei die Keys aller berechtigten Hosts. Fällt ein Host weg, nimmst du seinen Recipient raus und verschlüsselst neu.
- Kein laufender Dienst nötig: Entschlüsselt wird lokal mit dem age-Key – offline, ohne Server.
Der age-Private-Key ist die Kronjuwele. Wer ihn hat, liest alles. Er gehört in einen Passwort-Manager (off-site!) und mit
chmod 600auf den Host – niemals selbst ins Git.
Vom Werkzeug zur Lösung: ein Manager drumherum
sops + age auf der Kommandozeile ist stark – aber im Alltag fehlt der Überblick: Welche Tokens
gibt es? Wofür? Wann laufen sie ab? Welches sollte mal rotiert werden? YAML im Terminal beantwortet
das nicht gut. Also habe ich eine kleine Web-GUI darübergesetzt (FastAPI + Vanilla-JS, kein
schweres Framework) – bewusst ohne eigene Krypto: sie ruft nur sops/age auf.
Der wichtigste Design-Entscheid: ein einziger Schreiber.
flowchart TD
subgraph W["Manager-VM · ALLEINIGER SCHREIBER"]
GUI["Web-GUI + API<br/>(Auth, CRUD)"]
CLONE["lokaler Git-Klon"]
GUI -->|"sops set/unset<br/>nur Geheimfeld ENC"| CLONE
GUI -->|"lock → pull → commit → push"| CLONE
end
GIT[("Git-Repo<br/>Single Source of Truth")]
subgraph C["Konsument · pull-only"]
CLONE2["lokaler Git-Klon"]
LOADER["ENV-Loader<br/>(age-Key, Shell-Start)"]
ENVV["$ENV"]
CLONE2 --> LOADER --> ENVV
end
GUI --> GIT
CLONE -->|"push (ff-only)"| GIT
GIT -->|"pull --ff-only<br/>(Cron / Button)"| CLONE2
- Genau eine Instanz schreibt – atomar (gelockt,
pull → commit → push, nur Fast-Forward). So kann das Repo strukturell nie divergieren. - Konsumenten lesen nur (
git pull). Einpre-commit-Guardrail blockt versehentliche Commits. - Werte landen als
$ENV– beim Shell-Start entschlüsselt der lokale age-Key den Block direkt aus der Datei. Ganz ohne laufenden Manager: Der Manager ist nur der Bearbeitungs-Weg, nicht der Laufzeitpfad.
Darunter liegt eine schlichte, typisierte Liste – die eine Wahrheit, aus der die flache ENV-Sicht generiert wird:
1
2
3
4
5
6
7
8
credentials:
- id: git-pat
typ: api-token # api-token | user-pass | endpoint | ssh-key | composite
titel: "Git PAT"
secret: ENC[…] # das EINZIG verschlüsselte Feld
benutzt_fuer: "push, MR-API"
laeuft_ab: null
export_als: GIT_TOKEN # speist den Shell-ENV-Block
Multi-Feld: mehrere Werte, ein Eintrag
Manche Zugänge brauchen mehrere zusammengehörige Werte. Eine DNS-API etwa will oft
Kundennummer und API-Key und API-Passwort. Als drei getrennte Einträge ist das unübersichtlich
und leicht auseinanderzureißen. Also gibt es einen Typ composite, der die Werte in einem Eintrag
mit einer felder-Liste bündelt – jedes Feld mit eigenem ENV-Namen und einem „geheim”-Schalter:
1
2
3
4
5
6
7
- id: dns-api
typ: composite
titel: "DNS-API"
felder:
- { name: Kundennummer, export_als: DNS_CUSTOMER, geheim: false, wert: "12345" }
- { name: API-Key, export_als: DNS_API_KEY, geheim: true, secret: ENC[…] }
- { name: API-Passwort, export_als: DNS_API_PW, geheim: true, secret: ENC[…] }
Und hier zahlt sich die vorhin erwähnte Eigenschaft aus: Weil encrypted_regex: '^(secret|passwort)$'
Schlüsselnamen in beliebiger Tiefe matcht, wird das verschachtelte secret in jedem Feld
automatisch verschlüsselt – ganz ohne Krypto-Sonderbehandlung. Das nicht-geheime wert bleibt
Klartext. sops set/--extract sprechen die tiefen Pfade
(["credentials"][0]["felder"][1]["secret"]) genauso an wie flache Felder.
Genau ein Trick macht Multi-Feld möglich, ohne die Verschlüsselungsregel anzufassen: die Regel greift auf den Namen des Feldes, nicht auf seine Position. „Geheim” heißt schlicht: leg den Wert unter den Schlüssel
secret.
Sicherheits-Lektionen (ehrlich eingeordnet)
- Secrets nie in
argvoder Logs. Schreiben übersops set --value-stdin(Wert kommt per stdin, nicht als Kommandozeilen-Argument), Anzeigen über gezieltes--extract. Fehlermeldungen bleiben generisch – ein Stacktrace darf nie einen Wert nach außen tragen. - Der age-Key ist bewusst unverschlüsselt auf dem Host, damit der Shell-Loader ihn beim Start ohne Passwort-Prompt nutzen kann. Das ist ein ehrlicher Trade-off: Jeder Prozess dieses Users kann ohnehin entschlüsseln. Die zusätzliche Barriere ist deshalb nicht noch eine Passphrase, sondern die Netz-Härtung (interne Bindung, VPN, GUI-Login) – gegen Zugriff ohne Shell.
- Single-Writer schlägt Locking-Hoffnung. Zwei Schreiber auf dieselbe verschlüsselte Datei =
Merge-Konflikt im Chiffretext = Kopfschmerzen. Ein Schreiber, alle anderen
pull-only– strukturell konfliktfrei. - Backup ist geschenkt: die Git-History in jedem Klon plus der age-Key off-site. Aus jedem Klon/Backup ist alles wiederherstellbar, solange der Key existiert.
sops + age vs. die Alternativen
| Ansatz | Verschlüsselt | Git-Diffs | Betrieb | Passt für |
|---|---|---|---|---|
| sops + age | nur die Werte | klein & lesbar | kein Dienst nötig | versionierte Config mit Secrets, Homelab |
git-crypt |
ganze Dateien | Binär-Blob | transparent im Git | „alles oder nichts”-Dateien |
| Ansible Vault | Werte/Dateien | mittel | an Ansible gebunden | reine Ansible-Setups |
| HashiCorp Vault | zentraler Tresor | – (nicht in Git) | eigener Dienst + HA | Firmen, dynamische Secrets |
| Bitwarden/Vaultwarden | Tresor-DB | – (nicht in Git) | eigener Dienst | Menschliche Passwörter, Browser |
Für „Konfiguration + Secrets zusammen versioniert, ohne extra Dienst” ist sops + age der Sweet-Spot. Die Web-GUI obendrauf ist reiner Komfort – die Sicherheit trägt weiterhin sops/age, und die Werkzeuge funktionieren auch dann, wenn die GUI mal aus ist.
Fang klein an: eine
.sops.yaml, ein age-Key, eine verschlüsselte Datei im Repo. Den Manager baust du erst, wenn dich das YAML-im-Terminal wirklich nervt – und selbst dann bleibt sops der Kern.