Docker Secrets richtig machen: Von Portainer-Handarbeit zu vollautomatischem Deployment

Wer seine Docker-Stacks über Portainer verwaltet, kennt das Spiel: Die Compose-Files liegen sauber im Git, aber die Passwörter? Die tippt man nach jedem Deploy von Hand in die UI. Das funktioniert, bis man zum dritten Mal das SMTP-Passwort zusammensuchen muss, weil man einen Stack neu deployen musste.

Wie ich von diesem halbautomatischen Zustand zu einem Setup gekommen bin, bei dem Portainer einen Stack aus Git zieht und alles sofort läuft, ohne manuelles Nachreichen von Credentials, darum geht es hier.

Wie es angefangen hat: Passwörter per Hand nachreichen

Mein portainer_iac Repo enthält ein YAML-File pro Service. In Portainer deploye ich jeden Stack über “Create from Repository”, das heißt: Portainer zieht sich das YAML direkt aus Git und startet den Stack. Klingt nach Infrastructure as Code. War es aber nur zur Hälfte.

Das Problem: Die YAML-Files lagen sauber im Repo, aber die Passwörter natürlich nicht. Also habe ich nach jedem Deploy die Umgebungsvariablen manuell in der Portainer-Oberfläche nachgetragen. Stack deployen, dann in die Environment-Sektion klicken, ADMIN_TOKEN reinkopieren, SMTP_PASSWORD reinkopieren, speichern, Stack neu starten. Für jeden Service. Jedes Mal.

Das war so semi-automatisch. Die Infrastruktur kam aus Git, die Secrets kamen aus meinem Kopf (oder aus einer Textdatei auf dem Desktop, wenn ich ehrlich bin). Wenn ich einen Stack neu deployen musste, durfte ich erst mal die Passwörter zusammensuchen. Von “per Knopfdruck wiederaufbauen” war das weit entfernt.

Der Durchbruch: Docker Secrets statt Environment Variables

Dann bin ich auf Docker Secrets gestoßen. Docker Compose kann Dateien als Read-Only unter /run/secrets/ in Container mounten. Statt Passwörter als Umgebungsvariablen über die Portainer-UI einzutippen, referenziert das YAML-File einfach eine Datei auf dem Host. Portainer zieht das YAML aus Git, die Secrets liegen schon auf dem Server, der Stack startet ohne manuelles Zutun. Vollautomatisch.

Das war der Moment, in dem aus “Infrastructure as Code mit manuellem Passwort-Nachreichen” echtes IaC wurde. Aber damit stellte sich die nächste Frage: Wie kommen die Secret-Dateien auf den Server, ohne dass ich sie einzeln per SSH hochschiebe?

Das Problem: Secrets und Git vertragen sich nicht

Jeder Stack braucht irgendwelche Zugangsdaten: Vaultwarden will ein Admin-Token und SMTP-Credentials, Grafana braucht Login-Daten, der Cloudflare Tunnel sein Token, CrowdSec einen Bouncer-Key. Insgesamt reden wir über 20+ einzelne Secrets, verteilt auf sechs Stacks.

Was ich wollte:

  • Eine einzige Datei mit allen Credentials
  • Verschlüsselt im Git, damit ich sie nicht verliere
  • Entschlüsselt auf dem Server als einzelne Dateien, die Docker Secrets lesen kann
  • Kein HashiCorp Vault oder AWS Secrets Manager nötig, wenn man nur eine Handvoll Container betreibt

Die Lösung: SOPS + age in einem Docker-Container

SOPS (Secrets OPerationS) ist ein Tool von Mozilla, das Dateien verschlüsselt. age ist ein modernes Verschlüsselungstool, das als Backend für SOPS dient. Zusammen ersetzen sie das ältere PGP-Setup, das niemand vermisst.

Mein Ansatz: Ein kleiner Alpine-Container, der SOPS und age mitbringt und genau drei Dinge kann: verschlüsseln, entschlüsseln und Secrets auspacken.

Die Architektur

Eine Datei für alles

Alle Credentials leben in einer einzigen credentials.yml. Die Struktur ist simpel: Pro Service ein Top-Level-Key, darunter die einzelnen Secrets.

# credentials.yml (Auszug)
vaultwarden:
  admin_token: "$argon2id$v=19$m=65540..."
  smtp:
    host: mail-eu.smtp2go.com
    from: info@example.com
    username: myuser
    password: supergeheim123

tunnel:
  token: eyJhIjoiNGNjZDJm...

grafana:
  admin:
    user: admin
    email: admin@example.com
    password: nochgeheimer456

crowdsec:
  bouncer_key: 6ae1c245e3420ffd...

Diese Datei wird verschlüsselt und als credentials.enc.yml ins Git committed. Die Klartext-Version bleibt per .gitignore draußen.

Der Container

Das Tooling läuft in einem minimalen Alpine-Container. Kein Python-Ökosystem auf dem Host nötig, keine SOPS-Installation, keine age-Binaries. Alles gekapselt.

FROM alpine:3.23
RUN apk add --no-cache python3 sops age shadow py3-yaml

ARG USER_ID=1000
ARG GROUP_ID=1000
# ... User-Mapping für Host-Permissions

Der Container bekommt beim Build die UID/GID des Host-Users, damit die erzeugten Dateien die richtigen Berechtigungen haben. Kein chmod 777 als Workaround.

Der Wrapper

Ein Bash-Script sops-run.sh baut den Container und mountet zwei Dinge rein: das aktuelle Verzeichnis und den age-Schlüssel vom Host.

docker run --rm -it \
    -v "$(pwd)":/app \
    -v "$LOCAL_KEY_DIR":"/home/sopsuser/.config/sops/age" \
    $IMAGE_NAME "$@"

Der age-Key liegt unter ~/.config/sops/age/keys.txt und verlässt nie den Rechner. Er wird nicht committed, nicht kopiert, nicht in die Cloud geladen.

Der Workflow

Einmalig: Schlüssel erzeugen

./sops-run.sh generate-key

Das erzeugt ein age-Keypair. Den Public Key braucht SOPS zum Verschlüsseln, den Private Key zum Entschlüsseln. Diesen Key sollte man sichern. Wenn er weg ist, kommt man an die verschlüsselten Credentials nicht mehr ran.

Verschlüsseln

./sops-run.sh encrypt credentials.yml

SOPS verschlüsselt die gesamte Datei als Binary. Das heißt: Die credentials.enc.yml ist komplett unleserlich. Keine YAML-Keys sichtbar, keine Struktur erkennbar. Wer das File öffnet, sieht nur Kauderwelsch.

Warum Binary statt der üblichen SOPS-Methode, bei der nur die Values verschlüsselt werden? Weil ich nicht will, dass jemand am Git-Repo sieht, welche Services ich betreibe oder wie meine Secret-Struktur aussieht. Die Key-Namen sind auch Information.

Entschlüsseln und Auspacken

./sops-run.sh unpack credentials.enc.yml

Das ist der spannende Teil. unpack entschlüsselt die Datei und erzeugt aus der YAML-Struktur einzelne Dateien:

/opt/portainer_iac/secrets/secrets/
├── vaultwarden/
│   ├── admin_token
│   ├── smtp_host
│   ├── smtp_from
│   ├── smtp_username
│   └── smtp_password
├── tunnel/
│   └── token
├── grafana/
│   ├── admin_user
│   ├── admin_email
│   └── admin_password
├── crowdsec/
│   └── bouncer_key
├── hooks/
│   ├── cloudflare_auth_token
│   ├── cloudflare_account_id
│   └── cloudflare_policy_id
└── monitoring/
    ├── pve_user
    ├── pve_token_name
    └── pve_token_value

Jede Datei enthält genau einen Wert. Keine Zeilenumbrüche, kein YAML, nur den rohen Secret-String. Genau so, wie Docker Secrets es erwartet.

Die Konvention für die Dateinamen: Der Top-Level-Key wird zum Verzeichnis, verschachtelte Keys werden mit Unterstrichen verbunden. Aus vaultwarden.smtp.host wird vaultwarden/smtp_host.

Wie die Stacks die Secrets konsumieren

Docker Compose kennt eine secrets-Sektion. Damit lassen sich Dateien als Read-Only unter /run/secrets/ in den Container mounten. Das ist sicherer als Umgebungsvariablen, weil Secrets nicht in docker inspect, Prozesslisten oder Core-Dumps auftauchen.

Variante 1: Token-File direkt

Der einfachste Fall. Der Cloudflare Tunnel akzeptiert ein --token-file Argument:

# tunnel.yml
services:
  tunnel:
    image: cloudflare/cloudflared:latest
    command: tunnel --no-autoupdate run --token-file /run/secrets/cf_tunnel_token
    secrets:
      - cf_tunnel_token

secrets:
  cf_tunnel_token:
    file: /opt/portainer_iac/secrets/secrets/tunnel/token

Sauber. Der Container liest das Token aus der Datei, fertig.

Variante 2: Entrypoint mit cat

Viele Container erwarten Secrets als Umgebungsvariablen. Vaultwarden zum Beispiel will ADMIN_TOKEN als ENV. Die Lösung: Ein Custom-Entrypoint, der die Secrets aus /run/secrets/ liest und als ENV exportiert, bevor der eigentliche Prozess startet.

# vaultwarden.yml (Auszug)
services:
  vaultwarden:
    command:
      - /bin/sh
      - -ec
      - |
        set -eu
        export ADMIN_TOKEN="$(cat /run/secrets/admin_token)"
        export SMTP_HOST="$(cat /run/secrets/smtp_host)"
        export SMTP_PASSWORD="$(cat /run/secrets/smtp_password)"
        exec /start.sh
    secrets:
      - source: admin_token
      - source: smtp_host
      - source: smtp_password

Das exec am Ende ist wichtig. Damit wird der Shell-Prozess durch den eigentlichen Anwendungsprozess ersetzt. Ohne exec würde die Shell als PID 1 laufen und Signale wie SIGTERM nicht korrekt weiterleiten.

Variante 3: Permissions für nicht-root Container

Manche Container laufen nicht als Root. Der Gitea Runner zum Beispiel braucht das Secret als User 1000. Docker Compose kann Permissions direkt setzen:

# gitea.yml (Auszug)
secrets:
  - source: gitea_runner_token
    uid: "1000"
    gid: "1000"
    mode: 0400

0400 heißt: Nur der Besitzer darf lesen. Kein Schreiben, kein Ausführen, kein Zugriff für andere. Genau richtig für ein Token.

Was das .gitignore regelt

Die .gitignore im secrets/-Verzeichnis ist bewusst restriktiv. Standardmäßig wird alles ignoriert. Nur explizit freigegebene Dateien landen im Git:

# Alles ignorieren
*

# Tooling tracken
!.gitignore
!README.md
!Dockerfile
!sops-run.sh
!sops_manager.py

# Verschlüsselte Dateien erlauben
!*.enc.yml
!*.enc.yaml

Die Klartext-Credentials, die entpackten Secret-Dateien, alles bleibt lokal. Im Git landet nur die verschlüsselte credentials.enc.yml und das Tooling drum herum.

Warum nicht einfach .env-Dateien?

Berechtigte Frage. .env-Dateien funktionieren, aber sie haben ein paar Nachteile:

Docker Secrets werden als Dateien unter /run/secrets/ gemountet, nicht als Umgebungsvariablen. Das heißt: Sie tauchen nicht in docker inspect auf, nicht in /proc/<pid>/environ, nicht in Crash-Dumps. Wenn ein Container kompromittiert wird und der Angreifer die Umgebungsvariablen ausliest, sind die Secrets bei .env sofort sichtbar. Bei Docker Secrets muss er aktiv die Datei lesen.

Außerdem: .env-Dateien pro Service bedeuten viele einzelne Dateien, die man synchron halten muss. Eine zentrale credentials.yml, die automatisch in die richtige Struktur entpackt wird, ist weniger fehleranfällig.

Der Gesamtablauf

Wenn ich ein neues Secret brauche oder ein bestehendes ändere:

  1. credentials.yml auf meinem Rechner bearbeiten
  2. ./sops-run.sh encrypt credentials.yml ausführen
  3. credentials.enc.yml committen und pushen
  4. Auf dem Server: git pull && ./sops-run.sh unpack credentials.enc.yml
  5. Betroffenen Stack in Portainer neu deployen

Fünf Schritte, keine manuellen Datei-Edits auf dem Server, kein Copy-Paste von Passwörtern über SSH. Und wenn der Server morgen abbrennt, habe ich alles im Git. Neuen Server aufsetzen, age-Key aus dem Backup holen, unpack laufen lassen, Stacks deployen. Fertig.

Was ich anders machen würde

Der age-Key ist der Single Point of Failure. Wenn der weg ist, sind alle verschlüsselten Credentials verloren. Aktuell liegt eine Kopie auf einem verschlüsselten USB-Stick. Nicht elegant, aber es funktioniert. Eine bessere Lösung wäre age mit mehreren Empfängern, sodass ein zweiter Key als Backup dient.

Außerdem fehlt noch eine Rotation. Die Secrets ändern sich selten, aber ein automatischer Reminder wäre sinnvoll. Vielleicht ein Cron-Job, der prüft, ob das last_updated-Feld in der credentials.yml älter als 90 Tage ist.

Aber für ein Self-Hosted Setup mit einer überschaubaren Anzahl Container? Das Setup tut genau das, was es soll. Secrets sind verschlüsselt im Git, entschlüsselt auf dem Server, und kein Passwort steht im Klartext in einem YAML-File, das auf GitHub liegt.