Voraussetzung: ein eingerichteter Server – einmaliger Aufbau in Server-Basis, wegwerfen & identisch neu in Snapshot & Restore. Was die Bausteine hier kosten: Kosten & Budget.
Das Problem & die Regel
Ein Secret ist alles, was jemandem Zugang gibt: DB-Passwort, API-Token, TLS-Key, SSH-Key, OAuth-Client-Secret, das Restic-Repo-Passwort.
Ein Wert, acht Stationen – sechs sichtbare und zwei, an die man nicht denkt – und an jeder eine andere Art, ihn zu verlieren.
Zeichnung aus Diagramme: Hetzner & CRC (38).
| bisher | Problem |
|---|---|
.env in .gitignore | liegt unverschlüsselt auf Laptop und Server, nicht in Git – also nicht reproduzierbar, kein „wer hat wann was geändert“, weg bei Server-Neuaufbau |
$env:DB_PW = '...' im Terminal | steht in der PowerShell-History, verschwindet beim Neustart, nicht teilbar |
| Secret als GitHub-Repo-Secret | ok für CI – aber du kannst es nicht mehr lesen, keine Historie, an GitHub gebunden |
| Secret im Chat / in der Doku | … |
sops -d secrets/prod.env → die App liest die Klartext-Datei (chmod 600, am besten tmpfs)sops updatekeys, und den Wert selbst neu setzenSOPS (Secrets OPerationS, von Mozilla) verschlüsselt die Werte
einer .env/.yaml/.json, lässt die Schlüsselnamen
lesbar – Git-Diffs bleiben also aussagekräftig. age ist die
Verschlüsselung darunter (modern, ein kleines Binary, ein einzeiliger Public Key).
1 age-Schlüssel erzeugen
Ein Schlüsselpaar pro Ort, der entschlüsseln können soll: dein Laptop, jeder Server, die CI.
winget install FiloSottile.age
age-keygen -o "$env:APPDATA\sops\age\keys.txt"
# Ausgabe: "Public key: age1ql3z...". Die private Haelfte steht IN der Datei.
- Die keys.txt (privat) verlässt den Rechner nie und wird nie committet. Zusatz-Kopie in den Passwortmanager – geht sie verloren, entschlüsselst du nichts mehr.
- Der Public Key (
age1…) ist harmlos – er kommt gleich in.sops.yaml. - Server: dieselbe Zeile als
root, Datei nach/root/.config/sops/age/keys.txt(chmod 600). Bootstrap: einmal per SSH (Secure Shell) hinlegen oder in den Snapshot backen.
2 SOPS & .sops.yaml
winget install Mozilla.SOPS
creation_rules:
# prod-Secrets: Laptop + Server + CI duerfen entschluesseln
- path_regex: secrets/prod/.*\.(env|ya?ml|json)$
age: >-
age1ql3z...laptop,
age1abc4...server,
age1def7...ci
# alles andere: nur Laptop
- path_regex: .*
age: age1ql3z...laptop
Die Regeln greifen von oben nach unten, erste passende gewinnt. Mehr Umgebungen → mehr
path_regex-Blöcke (secrets/staging/… mit anderen Empfängern).
3 Ein Secret verschlüsseln
# neue Datei anlegen/bearbeiten: EDITOR oeffnet Klartext, beim Speichern wird verschluesselt
$env:EDITOR = 'code --wait'
sops secrets/prod/app.env
# oder eine bestehende .env in-place verschluesseln
sops -e -i secrets/prod/app.env
git add .sops.yaml secrets/prod/app.env
git commit -m "app-secrets (verschluesselt)"
DB_PASSWORD=ENC[AES256_GCM,data:9f2K...,iv:...,tag:...,type:str]
JWT_SECRET=ENC[AES256_GCM,data:pQ7...,iv:...,tag:...,type:str]
sops_age__list_0__map_recipient=age1abc4...server
sops_mac=ENC[AES256_GCM,data:...]
sops_version=3.9.4
Die ENC[…]-Datei und .sops.yaml. Nie die
keys.txt. Der Diff zeigt welcher Schlüssel sich geändert hat
(Name lesbar), nicht den Wert.
4 Formate
| Datei | SOPS verschlüsselt |
|---|---|
.env / dotenv | alle Werte, Keys lesbar – direkt als env_file nach dem Entschlüsseln nutzbar |
.yaml / .json | alle Blätter, mit encrypted_regex: '^(password|secret|token|key)$' nur bestimmte |
.txt / binary | komplett (--input-type binary) – z. B. ein TLS-Key oder die keys.txt eines anderen Servers |
sops exec-env secrets/prod/app.env -- ./mein-tool # Vars nur im Prozess
sops exec-file secrets/prod/config.yaml 'app --config {}' # Klartext in tmpfs, Pfad als {}
sops -d --extract '["database"]["password"]' secrets/prod/app.yaml # nur ein Wert
5 Auf dem Server entschlüsseln
Der Server hat seinen eigenen age-Schlüssel (Schritt 1). Der Deploy holt das Repo und schreibt die Klartext-Datei dorthin, wo Compose sie erwartet.
#!/bin/sh
set -eu
export SOPS_AGE_KEY_FILE=/root/.config/sops/age/keys.txt
cd /opt/repo && git pull --ff-only
install -m 600 /dev/null /opt/stacks/app/app.env
sops -d /opt/repo/secrets/prod/app.env > /opt/stacks/app/app.env
cd /opt/stacks/app && docker compose up -d
Am saubersten liegt app.env in einem tmpfs (RAM, nie auf
Platte): in compose.yaml
tmpfs: /run/secrets plus ein Init-Container, der sops -d dorthin
schreibt. Oder sops exec-env … -- docker compose up und die App liest die
Vars aus der Umgebung. Minimal reicht: Datei chmod 600, gehört root.
systemd-Alternative: systemd-creds encrypt +
LoadCredentialEncrypted= bindet an den Host-Schlüssel (TPM) – sehr
dicht, aber nicht portabel und nicht in Git. Gut für „nur auf dieser einen
Maschine“, schlecht für „reproduzierbar aus dem Repo“.
6 In CI
Ein einziges echtes GitHub-Secret – den age-Schlüssel der CI. Alles andere liegt verschlüsselt im Repo.
- CI-age-Schlüssel erzeugen, Public Key in
.sops.yamlaufnehmen,sops updatekeys secrets/prod/*. - Die private Haelfte als Repo-Secret
SOPS_AGE_KEY().
- uses: actions/checkout@v4
- run: curl -sSL https://github.com/getsops/sops/releases/latest/download/sops-linux-amd64 -o /usr/local/bin/sops && chmod +x /usr/local/bin/sops
- env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: |
sops -d secrets/prod/app.env > app.env
# ... deployen (siehe CI/CD-Anleitung); app.env danach nicht als Artefakt hochladen!
Noch besser: die CI entschlüsselt gar nicht, sondern der Server macht es beim Pull (Abschnitt 5). Dann kennt die CI keine Secrets – Pull-Modell in der CI/CD-Anleitung.
7 In Compose / systemd
services:
app:
image: ghcr.io/nursude/cloud-host/app:latest
env_file: [ ./app.env ] # die von sops -d geschriebene Datei
restart: unless-stopped
Alternativ Docker/Podman-Secrets statt Env-Vars (landen als Datei unter
/run/secrets/<name>, tauchen nicht in docker inspect auf):
docker compose mit secrets: und die App liest
*_FILE-Varianten (POSTGRES_PASSWORD_FILE …).
dienste-stack.ps1 / deploy.ps1 vor dem compose up
ein sops -d je Stack ausführen lassen – ein Ort für alle Secrets,
eine Datei je Stack, alles im Repo nachvollziehbar.
8 Rotieren & Zugriff entziehen
Empfänger ändern (Gerät verloren, Person raus)
# .sops.yaml: den alten age1... entfernen, ggf. neuen dazu, dann:
sops updatekeys secrets/prod/app.env
git commit -am "sops: alten Schluessel entfernt"
updatekeys verschlüsselt die aktuelle Datei neu – die
alte Version in der Git-Historie kann der alte Schlüssel weiter lesen.
Ein wirklich kompromittiertes Secret musst du deshalb am Wert selbst rotieren
(neues DB-Passwort setzen, Token neu ausstellen), nicht nur die Empfänger tauschen.
Wert rotieren
- Neuen Wert erzeugen, im Dienst setzen (DB:
ALTER USER, Token: neu ausstellen, alten widerrufen). sops secrets/prod/app.env→ Wert ersetzen → committen → deployen.- Regelmäßig für die wichtigsten (DB-Root, API-Tokens) – z. B. halbjährlich, oder sofort bei Verdacht.
9 Betrieb
| Ding | wohin |
|---|---|
age-keys.txt (Laptop, Server, CI) | nie in Git. Laptop: Passwortmanager-Kopie. Server: /root/.config/… + im Snapshot. CI: das eine GitHub-Secret. |
| verschlüsselte Secret-Dateien | ins Repo, in secrets/ |
.sops.yaml | ins Repo |
entschlüsselte app.env auf dem Server | chmod 600, am besten tmpfs, in .gitignore falls im Repo-Ordner |
- Backup der age-Identitäten ist Teil des Katastrophenplans – ohne sie ist jedes Secret im Repo verloren. Gehört in den Passwortmanager und außer Haus (verschlüsselter USB-Stick beim Vertrauensperson).
- Pre-commit-Hook:
gitleaksoder ein einfacher Grep, der unverschlüsselte Secrets im Commit blockiert – SOPS hat auch einen.git/hooks-Helfer. - Neue Person / neuer Server: deren Public Key in
.sops.yaml,sops updatekeys secrets/**, committen – ab dann können sie entschlüsseln.
+ Alternativen
| Werkzeug | Modell | wann |
|---|---|---|
| SOPS + age (diese Anleitung) | Secrets verschlüsselt im Git-Repo | 1–5 Personen, GitOps, keine laufende Infrastruktur nötig |
| Vaultwarden-Organisation | zentraler Tresor, manuell abrufen | Infra-Secrets, die Menschen (nicht Automaten) brauchen – ihr habt Vaultwarden ja schon |
| Infisical / Doppler | gehosteter Secret-Store + CLI (Command-Line Interface)/SDK (Software Development Kit), injiziert zur Laufzeit | mehr Komfort, Weboberfläche, Audit-Log, Free-Tier – aber ein weiterer Dienst / Abhängigkeit |
| HashiCorp Vault / OpenBao | eigener Secret-Server, dynamische Credentials, Policies | viele Dienste, Teams, dynamische DB-Zugänge – deutlich mehr Betrieb |
systemd-creds | host-gebunden (TPM) | „nur auf dieser Maschine“, keine Portabilität nötig |
+ Kubernetes
- helm-secrets (Plugin):
helm secrets install … -f secrets.yaml– diesecrets.yamlist SOPS-verschlüsselt im Chart-Repo. Passt zum Helm-Deploy aus der Bibliothek-Enterprise-Anleitung. - External Secrets Operator: der Cluster zieht Secrets aus einem externen
Store (Infisical, Vault, AWS SM) und legt sie als
Secretan – nichts Sensibles im Manifest-Repo. - Sealed Secrets (Bitnami): mit dem Cluster-Public-Key verschlüsseltes
SealedSecretins Repo. Nur dieser Cluster kann es öffnen. - Nie ein rohes
kind: Secret(nur base64!) ins Git.
+ Kosten
Preise sind Richtwerte. Hetzner hat 2026 zweimal erhöht (zuletzt 15. Juni: CX/CAX +30–40 %, CPX/CCX über 100 %). Aktuelle Server-Preise, alle Nebenposten, ein Rechner und Beispielrechnungen stehen zentral in Kosten & Budget.
SOPS und age sind Open Source, kein laufender Dienst – 0 €. Infisical und Doppler haben brauchbare Free-Tiers (bis ~5 Nutzer / ein Projekt). Vault/OpenBao betreibst du selbst (ein kleiner Container). Der eine GitHub-Actions-Secret für den CI-age-Key ist gratis.