Passwörter im Git-Repo? Secrets verschlüsseln mit SOPS und age
In meinem letzten Beitrag habe ich erzählt, dass mein ganzes Homelab inzwischen aus einem Git-Repo aufgebaut wird. Ein Punkt kam dort nur in einem Satz vor, obwohl er mich am Anfang am meisten beschäftigt hat: Wohin mit den ganzen Passwörtern?
Datenbank-Passwörter, API-Tokens, der Schlüssel für den Cloudflare-Tunnel, das Passwort für die Backups … In einem Homelab kommen schnell ein paar Dutzend Secrets zusammen. Und die braucht Ansible beim Deploy, also müssen sie irgendwie ins Repo.
Warum nicht einfach eine .env-Datei?
Der naheliegende Weg ist eine .env-Datei, die man in die .gitignore schreibt. Das funktioniert, hat aber zwei Probleme:
- Die Datei liegt nur auf meinem Laptop. Wenn der kaputtgeht, sind alle Passwörter weg, und mein schönes „alles aus dem Repo“ ist nicht mehr viel wert.
- Man vergisst schnell, welche Variablen es gibt und welcher Dienst welche braucht.
Klartext ins Repo ist aber auch keine Option. Selbst in einem privaten Repo sollte man das nicht machen: Der Verlauf bleibt für immer erhalten, und ein falsch gesetzter Haken bei der Sichtbarkeit reicht.
Die Lösung, die ich am Ende genommen habe: Die Secrets liegen im Repo, aber verschlüsselt. Dafür nutze ich zwei Werkzeuge.
age und SOPS – wer macht was?
- age ist ein kleines, modernes Verschlüsselungsprogramm. Man erzeugt ein Schlüsselpaar: Mit dem öffentlichen Schlüssel (fängt mit
age1…an) wird verschlüsselt, mit dem privaten wieder entschlüsselt. Im Prinzip wie bei SSH-Schlüsseln, nur viel einfacher als GPG. - SOPS (von Mozilla, inzwischen ein CNCF-Projekt) kümmert sich um die Dateien. Das Besondere: Bei YAML- oder JSON-Dateien verschlüsselt SOPS nur die Werte, nicht die Schlüssel. Man sieht also im Repo noch, welche Secrets es gibt, nur nicht, was drinsteht. Das macht Diffs in Pull Requests deutlich lesbarer.
SOPS benutzt age im Hintergrund. Man könnte stattdessen auch GPG oder einen Cloud-Dienst wie AWS KMS nehmen, für ein Homelab ist age aber genau richtig.
Einrichtung
Auf dem Mac geht die Installation über Homebrew:
brew install sops ageDann ein Schlüsselpaar erzeugen. Der Ort ist der, an dem SOPS unter Linux standardmäßig sucht:
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txtDie Ausgabe zeigt den öffentlichen Schlüssel an (Public key: age1…). Der private Schlüssel steht in keys.txt und darf niemals ins Repo.
Kleine Stolperfalle auf macOS: Dort sucht SOPS den Schlüssel unter ~/Library/Application Support/ und nicht unter ~/.config. Damit beide Systeme gleich funktionieren, habe ich den Pfad in der ~/.zshrc festgelegt:
export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt"Als Letztes bekommt das Repo eine Datei .sops.yaml. Darin steht, welche Dateien mit welchem Schlüssel verschlüsselt werden:
# Alles unter secrets/, das auf .sops endet, wird mit age verschlüsselt
creation_rules:
- path_regex: secrets/.*\.sops$
age: age1deinoeffentlicherschluesselxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxAb jetzt muss man bei SOPS keinen Schlüssel mehr angeben, es sucht sich die passende Regel anhand des Dateipfads selbst.
Verschlüsseln, bearbeiten, entschlüsseln
Die meisten meiner Docker-Stacks bekommen ihre Secrets als .env-Datei, also Zeilen der Form NAME=wert. Am angenehmsten ist es, so eine Datei direkt mit SOPS zu öffnen. SOPS entschlüsselt sie in eine temporäre Datei, öffnet den Editor und verschlüsselt beim Schließen wieder:
sops --input-type dotenv --output-type dotenv secrets/pi5/searxng.env.sopsDie beiden --…-type-Angaben sind nötig, weil ich an alle Secrets .sops anhänge, damit man sofort sieht, dass sie verschlüsselt sind. An dieser Endung kann SOPS das Format nicht mehr erkennen. Bei einer Datei, die auf .env, .yaml oder .json endet, geht es auch ohne.
Im Repo sieht die Datei dann ungefähr so aus (gekürzt):
SEARXNG_SECRET=ENC[AES256_GCM,data:k3Jv9…,iv:…,tag:…,type:str]
sops_age__list_0__map_recipient=age1deinoeffentlicherschluessel…
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\n…
sops_lastmodified=2026-10-13T18:42:10Z
sops_mac=ENC[AES256_GCM,data:…,type:str]
sops_version=3.xDer Name SEARXNG_SECRET ist lesbar, der Wert nicht. Dahinter hängt SOPS seine Metadaten an, unter anderem eine Prüfsumme (sops_mac). Wenn jemand die Datei von Hand verändert, merkt SOPS das beim Entschlüsseln. Bei YAML-Dateien funktioniert es genauso, dort stehen die Metadaten in einem Block sops: am Ende.
Zum Nachschauen, ohne etwas zu ändern:
sops --decrypt --input-type dotenv --output-type dotenv secrets/pi5/searxng.env.sopsFür Dinge wie private Schlüssel oder Zertifikate gibt es den Typ binary. Dann wird einfach die ganze Datei verschlüsselt:
sops --encrypt --input-type binary --output-type binary \
--filename-override secrets/pi5/edge/oidc_private_key.sops \
private_key.pem > secrets/pi5/edge/oidc_private_key.sopsWeil ich mir diese langen Befehle nicht merken will, habe ich zwei kleine Skripte geschrieben. Das eine legt .env-Secrets an und kann Zufallswerte direkt erzeugen, sodass das Passwort nie im Klartext auf dem Bildschirm oder in der Shell-History auftaucht:
scripts/env-secret.sh secrets/docker-vm/karakeep.env.sops NEXTAUTH_SECRET:random MEILI_MASTER_KEY:randomIntern erzeugt das Skript die Werte mit openssl rand -hex 32, schreibt sie in eine temporäre Datei, verschlüsselt sie mit SOPS und löscht die temporäre Datei danach mit shred.
Und wie kommen die Secrets auf die Server?
Hier kommt Ansible ins Spiel. Für Ansible gibt es die Collection community.sops, die SOPS-Dateien direkt beim Deploy entschlüsseln kann:
ansible-galaxy collection install community.sopsIn meiner Rolle für die Docker-Stacks sieht das (vereinfacht) so aus:
- name: Env-Secrets entschlüsseln
ansible.builtin.copy:
content: "{{ lookup('community.sops.sops', 'secrets/pi5/searxng.env.sops', input_type='dotenv', output_type='dotenv') }}"
dest: /opt/homelab/stacks/searxng/.env
owner: root
group: docker
mode: '0640'
no_log: trueEntschlüsselt wird dabei auf meinem Laptop, denn nur dort liegt der private age-Schlüssel. Die Server sehen nur das Ergebnis, die fertige .env-Datei mit eingeschränkten Rechten. Wichtig ist no_log: true: Ohne das würde Ansible den Inhalt bei Fehlern oder mit -v im Terminal ausgeben.
Sicherheitsnetze
Das beste System bringt nichts, wenn man aus Versehen doch mal eine Klartext-Datei committet. Deshalb habe ich drei Sicherheitsnetze eingebaut.
1. .gitignore für alles, was nach entschlüsselten Secrets aussieht:
*.env
.env
*.dec
keys.txt
*.agekey
!*.env.sops2. Ein pre-commit-Hook, der jede Datei unter secrets/ prüft. Eine von SOPS verschlüsselte Datei enthält immer einen sops-Block (YAML/JSON) bzw. eine Zeile sops_version= (dotenv). Fehlt der, bricht der Commit ab:
#!/usr/bin/env bash
# Bricht ab, wenn eine Datei unter secrets/ nicht SOPS-verschlüsselt ist
status=0
for file in "$@"; do
if ! grep -qE '^(sops:|sops_version=)|"sops"[[:space:]]*:' "$file"; then
echo "NICHT VERSCHLÜSSELT: $file" >&2
status=1
fi
done
exit "$status"3. gitleaks läuft ebenfalls als pre-commit-Hook und sucht in allen anderen Dateien nach Dingen, die wie Tokens oder private Schlüssel aussehen.
Eingerichtet wird das Ganze einmal mit:
pip install pre-commit
pre-commit installDer wichtigste Teil: den Schlüssel sichern
Das klingt banal, ist aber der Punkt, an dem alles hängt: Ohne den privaten age-Schlüssel sind alle Secrets im Repo wertlos. Wenn mein Laptop abraucht und ich keine Kopie von keys.txt habe, kann ich nichts mehr entschlüsseln.
Ich habe den Schlüssel deshalb an zwei weiteren Orten:
- in Vaultwarden (meinem Passwortmanager)
- offline auf Papier bzw. einem USB-Stick
Ein Haken bei Vaultwarden: Der läuft in meinem Homelab. Er wird zwar jede Nacht gesichert (wie, steht in meinem Beitrag über Backups mit restic), aber auch dieses Backup ist mit einem Passwort geschützt, das im Repo liegt. Fällt das komplett aus, komme ich also auch nicht an diese Kopie. Genau dafür gibt es die Offline-Kopie.
SOPS kann übrigens auch mit mehreren Schlüsseln gleichzeitig verschlüsseln. Man trägt sie in der .sops.yaml kommagetrennt ein und aktualisiert dann alle bestehenden Dateien:
find secrets -name '*.sops' -exec sops updatekeys --yes {} \;Bei Dateien, deren Format SOPS nicht an der Endung erkennt (bei mir alle mit .sops), muss man dabei wie oben --input-type mitgeben. So könnte z. B. ein zweiter Rechner einen eigenen Schlüssel bekommen, ohne dass man den privaten Schlüssel kopieren muss.
Was SOPS nicht kann
Zum Schluss noch etwas, das mir erst beim Lesen der Doku klar geworden ist: Neu verschlüsseln hilft nicht, wenn ein Secret einmal bekannt geworden ist. Wenn der age-Schlüssel in falsche Hände gerät, kann man damit auch alle alten Versionen aus dem Git-Verlauf entschlüsseln. Dann muss man die Secrets selbst austauschen, also neue Passwörter und neue Tokens erzeugen, und nicht nur den Schlüssel wechseln.
Das Gleiche gilt, wenn ein Token mal im Klartext in einem Chat, Log oder Screenshot gelandet ist: neu erzeugen und das alte ungültig machen.
Fazit
Am Anfang wirkte SOPS auf mich etwas umständlich, vor allem wegen der Formate. Seit die zwei Hilfsskripte da sind, merke ich im Alltag kaum noch etwas davon: Secret anlegen, committen, make deploy, fertig. Und das gute Gefühl, dass wirklich alles im Repo steckt und ich mein Homelab mit Laptop, Repo und age-Schlüssel komplett neu aufbauen könnte, ist den Aufwand auf jeden Fall wert.
Wer selbst ein Homelab mit Ansible oder Docker Compose betreibt und die Passwörter bisher in einer .env auf dem Laptop hat: Probiert es aus, die Einrichtung dauert keine halbe Stunde.
Zum Weiterlesen
Wer tiefer einsteigen will, findet hier die Quellen, die mir beim Einrichten am meisten geholfen haben:
- SOPS-Dokumentation – alle Formate, Schlüsselquellen und Befehle, z. B.
updatekeysundrotate - SOPS mit age – wo SOPS den Schlüssel sucht und wie man mehrere Empfänger einträgt
- age auf GitHub – das Verschlüsselungsprogramm selbst, mit einer kurzen Erklärung des Formats
- community.sops für Ansible – Lookup, Vars-Plugin und Module, mit denen Ansible SOPS-Dateien liest
- gitleaks – sucht in Commits und im Verlauf nach versehentlich eingecheckten Secrets
- pre-commit – das Framework, über das bei mir alle Git-Hooks laufen