Zum Hauptinhalt springen
S-EDV news
← Alle Anleitungen
📘 Anleitung Docker 22.09.2026 · 20 min Lesezeit

2FAuth mit Docker Compose: Zwei-Faktor-Konten selbst hosten

2FAuth verwaltet TOTP- und HOTP-Konten selbst gehostet im Browser und löst damit das Problem geteilter Dienstkonten, deren zweiter Faktor sonst auf einem einzelnen Privathandy liegt. Diese Anleitung zeigt die vollständige Installation mit Docker Compose, erklärt jede wichtige Umgebungsvariable an der offiziellen Dokumentation, belegt Verschlüsselung, Backup und Wiederherstellung mit echten Testausgaben und benennt offen, wo die Sicherheitsgrenzen dieser Bündelung liegen.

Anleitungs-Titelbild zur Installation von 2FAuth mit Docker Compose, mit Feature-Karten zu Compose, APP_KEY-Sicherung und getestetem Backup sowie einem Dashboard-Mockup mit Einmalcodes KI-generiert

Zwei-Faktor-Authentifizierung ist in kleinen Unternehmen meistens an Privathandys gebunden. Das funktioniert, solange jeder Zugang genau einer Person gehört. Sobald ein Dienstkonto, ein Kundenzugang oder ein gemeinsamer Provider-Account im Spiel ist, wird es unangenehm: Der zweite Faktor liegt auf dem Telefon eines einzelnen Mitarbeiters, und wenn dieser im Urlaub ist, das Gerät verliert oder das Unternehmen verlässt, steht das Team vor einer verschlossenen Tür.

2FAuth ist eine selbst gehostete Weboberfläche, die TOTP- und HOTP-Konten speichert, Codes im Browser erzeugt und seit Version 7 einzelne Konten kontrolliert an andere Benutzer freigeben kann, ohne das zugrunde liegende Geheimnis preiszugeben. Diese Anleitung zeigt eine vollständige Installation mit Docker Compose, erklärt jede relevante Umgebungsvariable, prüft Backup und Wiederherstellung praktisch nach und benennt am Ende offen, wo die Sicherheitsgrenzen dieses Ansatzes liegen. Sie richtet sich an Administratoren, die den Stack auf einem eigenen Server betreiben und dabei nicht hinter einen offenen Port ins Internet rutschen wollen.

Nutzen und Grenzen

2FAuth löst ein organisatorisches Problem, kein kryptografisches. Der Nutzen liegt darin, dass ein zweiter Faktor für geteilte Dienstkonten nicht mehr an ein einzelnes Gerät gebunden ist. Ein kleines Team kann sich an der Instanz anmelden, den Code im Browser abholen und fertig. Bei Onboarding und Offboarding entfällt die Bastelei mit abfotografierten QR-Codes, die anschließend in irgendeinem Chatverlauf hängen bleiben.

Genauso ehrlich muss die Kehrseite benannt werden. Wer alle zweiten Faktoren in einer Anwendung bündelt, baut ein attraktives Ziel. Ein Angreifer, der Zugriff auf die Instanz bekommt, erhält in einem Schritt die zweiten Faktoren vieler Konten. Diese Konzentration ist nur dann vertretbar, wenn drei Bedingungen erfüllt sind:

  • Die Instanz ist nicht ungeschützt aus dem Internet erreichbar, sondern hinter VPN, Reverse Proxy mit Authentifizierung oder zumindest strikter IP-Beschränkung.
  • Passwörter liegen nicht im selben System. Wer Passwortmanager und 2FA-Verwaltung zusammenlegt, hat faktisch wieder einen Faktor, nur mit mehr Schritten. Vaultwarden oder Passbolt gehören auf eine andere Instanz, idealerweise mit anderem Zugang.
  • Die Benutzerkonten in 2FAuth selbst sind stark abgesichert, bevorzugt mit einem Passkey beziehungsweise WebAuthn-Schlüssel statt eines Passworts.

Für persönliche Konten einzelner Mitarbeiter bleibt die Authenticator-App auf dem Telefon die sauberere Lösung. 2FAuth ist das Werkzeug für die Fälle dazwischen: Dienstkonten, Kundenzugänge, Verwaltungsoberflächen von Providern, gemeinsam genutzte Portale. Genau dafür hat das Projekt seit Version 7 die Freigabefunktion, bei der ein Benutzer Codes erzeugen darf, ohne das Geheimnis selbst je zu sehen.

Noch eine Grenze, die häufig übersehen wird: 2FAuth ist kein Ersatz für eine Notfallplanung. Wiederherstellungscodes der jeweiligen Dienste gehören getrennt aufbewahrt, am besten offline. Wenn die Instanz ausfällt und niemand einen alternativen Weg in die Konten hat, ist das Team ausgesperrt.

Voraussetzungen und Ressourcen

Die Anforderungen sind bescheiden. 2FAuth ist eine PHP-Anwendung, die im offiziellen Container zusammen mit Nginx und PHP-FPM unter einem Supervisor läuft und ihre Daten standardmäßig in einer SQLite-Datei ablegt.

PunktAnforderung
BetriebssystemBeliebiges Linux mit Docker Engine, im Test Ubuntu-Host mit Docker 29.1.3
Docker ComposeCompose V2, im Test Version 2.40.3
ArbeitsspeicherUnter 256 MB im Leerlauf, das Image belegt rund 211 MB auf der Platte
SpeicherplatzWenige hundert Megabyte, die SQLite-Datei startet bei etwa 292 KB
NetzEin freier Port auf dem Host, im Beispiel an 127.0.0.1 gebunden
Vor der InstanzReverse Proxy mit TLS, siehe Abschnitt zur Netzwerkfreigabe

Der Container läuft nach eigener Aussage der Dokumentation ohne Root-Rechte als Benutzer mit UID und GID 1000. Das ließ sich im Test bestätigen: Alle Prozesse im Container, vom Einstiegsskript über Supervisord bis zu Nginx und den PHP-FPM-Pools, liefen unter UID 1000. Für das Verzeichnis auf dem Host bedeutet das, dass es demselben Benutzer gehören muss, sonst scheitert das Anlegen der Datenbank.

Unterstützte Plattformen und getestete Versionen

Laut offizieller Docker-Dokumentation ist das Image für die Architekturen amd64, 386, arm64, arm/v6 und arm/v7 gebaut. Das deckt neben gewöhnlichen Servern auch Raspberry-Pi-Hardware ab. Getestet wurde für diese Anleitung ausschließlich amd64 unter Linux, die ARM-Varianten sind hier nur dokumentiert, nicht selbst geprüft.

KomponenteIm Test beobachtete Version
2FAuth8.0.2, Commit 2023d68, Image gebaut am 2. September 2026
PHP8.4.25 (fpm-fcgi)
Nginx im Container1.30.4
Supervisordv0.6.8
Image-Architekturlinux/amd64, Label org.opencontainers.image.version = 8.0.2

Zum Stand der Abfrage über die GitHub-API am 22. September 2026 hat das Repository Bubka/2FAuth 4156 Sterne, ist nicht archiviert, steht unter der AGPL-3.0 und hat als jüngsten Push den 2. September 2026. Das aktuellste Release ist v8.0.2, veröffentlicht am 2. September 2026. Angenehm: Der Tag latest lieferte im Test tatsächlich exakt diese Version und lag ausnahmsweise nicht hinter dem Release zurück, was bei vielen Projekten sonst der Fall ist.

Zur Tag-Auswahl nennt die Dokumentation vier Varianten: latest für den Stand des master-Branches, unveränderliche Versions-Tags wie 8.0.2, bewegliche Teilversions-Tags wie 8 oder 8.0 sowie dev für den Entwicklungszweig. Für den produktiven Betrieb ist ein Versions-Tag die richtige Wahl, weil ein unbeabsichtigtes Update dann nicht schon beim nächsten Neustart passiert.

Verzeichnis anlegen und APP_KEY erzeugen

Zuerst das Arbeitsverzeichnis mit dem Datenordner. Die Besitzrechte müssen zu UID und GID 1000 im Container passen.

# Arbeitsverzeichnis und Datenordner anlegen
mkdir -p /opt/2fauth/data
cd /opt/2fauth

# Besitzer und Rechte an die Container-UID anpassen
chown -R 1000:1000 data
chmod 700 data

Der wichtigste Schritt vor dem ersten Start ist der Verschlüsselungsschlüssel. 2FAuth ist eine Laravel-Anwendung, und APP_KEY ist der Schlüssel, mit dem laut Dokumentation sämtliche sicherheitsrelevanten Funktionen arbeiten: Sitzungen, Datenbankverschlüsselung, WebAuthn und persönliche Zugriffstoken. Die Dokumentation formuliert unmissverständlich, dass alle verschlüsselten Daten in der Datenbank als verloren gelten müssen, wenn dieser Schlüssel abhandenkommt. Es gibt dafür keinen Umweg.

Erzeugen lässt er sich direkt mit dem 2FAuth-Image, ohne den Stack zu starten:

# Neuen Schluessel lokal mit dem 2FAuth-Image erzeugen
docker run -it --rm --entrypoint /usr/bin/php 2fauth/2fauth artisan key:generate --show

Die Ausgabe ist eine Zeile der Form base64:... mit einem 32 Byte langen Zufallswert. Genau dieser Wert gehört in die Konfiguration und zusätzlich an einen sicheren, vom Server getrennten Ort. Ein Schlüssel im gleichen Backup wie die Datenbank schützt nur vor versehentlichem Löschen, nicht vor einem Diebstahl des Backups.

Warum der Schlüssel nach dem ersten Start nicht mehr einfach ausgetauscht werden darf, lässt sich sehr konkret zeigen. Im Test wurde ein TOTP-Konto angelegt, die Verschlüsselung aktiviert, dann der APP_KEY durch einen frisch erzeugten ersetzt und der Container neu gestartet. Die Anmeldung funktionierte noch, der Codeabruf nicht mehr:

# Reale Antwort der API nach unbedachtem Schluesselwechsel
{"message":"The secret cannot be deciphered. This is mainly caused by a wrong APP_KEY
set in the .env configuration file of 2Fauth or a corrupted data stored in database."}
# HTTP 400

Die Daten waren damit nicht zerstört, aber unlesbar. Der von der Dokumentation vorgesehene Ausweg ist die Variable APP_PREVIOUS_KEYS, eine kommagetrennte Liste früherer Schlüssel. Auch das wurde geprüft: Nach Eintragen des alten Schlüssels in APP_PREVIOUS_KEYS und einem erneuten Neustart lieferte derselbe Abruf wieder einen gültigen Code mit HTTP 200. Eine Schlüsselrotation ist also möglich, aber nur bewusst und mit dem alten Schlüssel in der Hand. Wer ihn verloren hat, hat die Geheimnisse verloren.

compose.yaml und Umgebungsvariablen

Das offizielle Compose-Beispiel des Projekts ist bewusst knapp gehalten und besteht aus einem einzigen Dienst, der seine Variablen aus einer Datei settings.env bezieht. Die folgende Fassung übernimmt diese Struktur, bindet den Port aber ausdrücklich nur an die Loopback-Adresse und ergänzt einen Healthcheck sowie eine Neustartrichtlinie.

services:
  2fauth:
    image: 2fauth/2fauth:8.0.2
    container_name: 2fauth
    restart: unless-stopped
    env_file: settings.env
    volumes:
      - ./data:/2fauth
    ports:
      # Nur lokal erreichbar, der Reverse Proxy spricht die Instanz an
      - "127.0.0.1:8000:8000/tcp"
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8000/"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 60s

Der Healthcheck nutzt das im Image vorhandene wget. Das ist kein Zufallsgriff, sondern im Test geprüft: which wget im Container lieferte /usr/bin/wget, ein interner Abruf gegen Port 8000 war erfolgreich, und nach dem Start meldete docker inspect den Status healthy mit zwei Prüfungen vom Exit-Code 0. Die start_period von 60 Sekunden ist großzügig gewählt, weil der erste Start Migrationen ausführt und die Anwendung in dieser Zeit noch mit HTTP 502 antwortet.

Die zugehörige settings.env enthält keine echten Geheimnisse, der Schlüssel unten ist ein Platzhalter und muss ersetzt werden:

# Allgemein
APP_NAME=2FAuth
APP_ENV=production
APP_DEBUG=false
APP_TIMEZONE=Europe/Berlin
SITE_OWNER=admin@example.com

# Oeffentliche Adresse der Instanz, muss exakt zur aufgerufenen URL passen
APP_URL=https://2fa.example.com

# Platzhalter, eigenen Wert mit artisan key:generate --show erzeugen
APP_KEY=base64:BITTE_DURCH_EIGENEN_SCHLUESSEL_ERSETZEN=

# Datenbank, hier SQLite im gebundenen Volume
DB_CONNECTION=sqlite
DB_DATABASE=/2fauth/database.sqlite

# Cache und Sessions im Dateisystem
CACHE_DRIVER=file
SESSION_DRIVER=file

# Protokollierung
LOG_CHANNEL=daily
LOG_LEVEL=notice

# Mail, fuer Passwort-Zuruecksetzen noetig. log schreibt nur ins Protokoll
MAIL_MAILER=log

# Anmeldeversuche pro Minute und IP
LOGIN_THROTTLE=5

# Aufbewahrung der Protokolle in Tagen
AUTHENTICATION_LOG_RETENTION=365
OTP_LOG_RETENTION=365

# Reverse Proxy als vertrauenswuerdig markieren, IP-Liste oder *
TRUSTED_PROXIES=172.18.0.0/16

# Schutzmassnahmen
CONTENT_SECURITY_POLICY=true
BLOCK_OPTAUTH_IMAGELINK_FETCHING=true

Die wichtigsten Variablen im Einzelnen, jeweils an der offiziellen Dokumentation zu den Umgebungsvariablen abgeglichen:

VariableBedeutungHinweis
APP_KEYVerschlüsselungsschlüssel für Sitzungen, Datenbankverschlüsselung, WebAuthn und ZugriffstokenPflicht. Genau 32 Zeichen beziehungsweise ein base64-Wert. Ohne ihn startet die Anwendung nicht.
APP_PREVIOUS_KEYSKommagetrennte Liste früherer SchlüsselNur bei bewusster Rotation nötig, rettet bestehende Daten
APP_KEY_FILEVariante für Docker-Secrets, liest den Schlüssel aus einer eingehängten DateiHat laut Dokumentation Vorrang vor APP_KEY. Beide gleichzeitig zu setzen führt zu einem Fehler beim Start.
APP_URLÖffentliche Adresse der InstanzMuss mit der tatsächlich aufgerufenen URL samt Schema und gegebenenfalls Port übereinstimmen, sonst funktionieren WebAuthn, Linkerzeugung und SSO-Weiterleitung nicht
DB_CONNECTIONDatenbanktreiberZulässig sind sqlite, mysql, pgsql und sqlsrv. Der Standardwert ist mysql, das offizielle Docker-Setup arbeitet aber mit SQLite.
DB_DATABASEDatenbankname oder Pfad zur SQLite-DateiIm Container sinnvollerweise unterhalb von /2fauth, damit die Datei im gebundenen Volume liegt
DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORDZugangsdaten für einen externen DatenbankserverOhne Wirkung bei SQLite. Für MySQL ist 3306 voreingestellt, für PostgreSQL 5432.
TRUSTED_PROXIESKommagetrennte IP-Liste vertrauenswürdiger Proxys, alternativ *Nötig, wenn ein Reverse Proxy TLS terminiert, sonst erzeugt die Anwendung Links mit falschem Schema. Ab Version 8 zwingend, wenn AUTHENTICATION_GUARD auf reverse-proxy-guard steht.
AUTHENTICATION_GUARDAnmeldeverfahren, web-guard oder reverse-proxy-guardBei reverse-proxy-guard prüft 2FAuth ausschließlich die Kopfzeilen des Proxys und überspringt alle eigenen Prüfungen. Der Proxy trägt dann die gesamte Verantwortung.
LOGIN_THROTTLEFehlversuche pro Minute vor der SperreGilt laut Dokumentation sowohl für Passwort- als auch für WebAuthn-Anmeldungen
CONTENT_SECURITY_POLICYAktiviert die Content-Security-PolicyStandardmäßig aktiv, sollte aktiv bleiben
BLOCK_OPTAUTH_IMAGELINK_FETCHINGVerhindert das Nachladen von Bildern, die in QR-Codes verlinkt sindSchutz gegen Blind-SSRF, standardmäßig aktiv
PHP_MEMORY_LIMIT_TEMP_OVERRIDETemporäres Speicherlimit während der QR-Code-ErkennungNeu in Version 8, Standardwert 512

Wer den Schlüssel nicht in einer Datei mit Umgebungsvariablen stehen haben möchte, kann ihn laut Dokumentation seit Version 6 als Docker-Secret einhängen. Das Image unterstützt die Konvention mit dem Suffix _FILE für APP_KEY_FILE, DB_DATABASE_FILE, DB_USERNAME_FILE, DB_PASSWORD_FILE, DB_HOST_FILE, MAIL_USERNAME_FILE, MAIL_PASSWORD_FILE und REDIS_PASSWORD_FILE. Diese Variante wurde hier nicht selbst getestet.

Start und Erstkonfiguration

Der Start ist unspektakulär:

# Stack im Hintergrund starten
docker compose up -d

# Status und Healthcheck pruefen
docker compose ps

# Startvorgang verfolgen
docker compose logs -f

Beim ersten Start legt das Einstiegsskript die SQLite-Datei an, verknüpft das Speicherverzeichnis und führt sämtliche Migrationen aus. Die Protokollausgabe zeigt das sehr deutlich:

Running version 8.0.2 commit 2023d68 built on 2026-09-02T20:35:12Z
supervisord version: v0.6.8
PHP 8.4.25 (fpm-fcgi)
nginx version: nginx/1.30.4
DB_DATABASE sets with custom path: /2fauth/database.sqlite
/2fauth/database.sqlite does not exist, we create it
/srv/storage is now a symlink to /2fauth/storage

   INFO  Preparing database.
   INFO  Running migrations.

  2014_10_12_000000_create_users_table .......................... 45.93ms DONE
  2019_05_16_162730_create_twofaccounts_table ................... 15.32ms DONE
  2026_03_15_000000_create_twofaccount_shares_table ............. 48.08ms DONE
  2026_08_29_175933_update_oauth_clients_table_for_passport_13 . 496.79ms DONE

Wichtig für die Erwartungshaltung: Die Oberfläche antwortet nicht sofort. Im Test lieferte der Abruf in den ersten acht Sekunden gar keine Verbindung, danach kurz HTTP 502 mit der Meldung connect() failed (111: Connection refused) while connecting to upstream im Nginx-Protokoll, und erst nach rund zehn Sekunden HTTP 200. Das ist kein Fehler, sondern PHP-FPM, das noch nicht bereit ist. Deshalb die großzügige start_period im Healthcheck.

Anschließend liefert die Instanz eine vollständige Antwort mit gesetzter Content-Security-Policy und zwei Cookies. Das ließ sich im Test direkt nachvollziehen:

# Verfuegbarkeit pruefen
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/
# 200

# Kopfzeilen ansehen
curl -sI http://127.0.0.1:8000/ | head -8
# HTTP/1.1 200 OK
# Server: nginx/1.30.4
# X-Powered-By: PHP/8.4.25
# Content-Security-Policy: script-src 'nonce-...' 'wasm-unsafe-eval' 'strict-dynamic'; ...
# Set-Cookie: 2fauth_session=...; httponly; samesite=lax

Die Erstkonfiguration passiert im Browser. Beim ersten Aufruf legt man ein Konto an, und dieses erste Konto wird laut Dokumentation automatisch zum Administrator. Das ist ein Punkt, an dem man nicht trödeln sollte: Eine frisch gestartete, aus dem Netz erreichbare Instanz verteilt die Administratorrolle an denjenigen, der zuerst das Registrierungsformular ausfüllt. Deshalb gehört der erste Start hinter eine geschlossene Tür.

Die Registrierung funktioniert auch über die Weboberflächen-API, was sich für einen automatisierten Funktionstest nutzen lässt. Im Test lieferte ein POST auf /user mit gültigem CSRF-Token die Antwort {"message":"account created","id":1,...}.

Nach der Anmeldung sind im Administrationsbereich unter Admin > App Setup drei Einstellungen wichtig:

  • Sensible Daten schützen aktiviert die Datenbankverschlüsselung für 2FA-Geheimnisse und otpauth-URIs. Diese Option sollte in jedem Fall aktiv sein.
  • 2FA-Freigabe steuert, ob Benutzer Konten überhaupt teilen dürfen. Wird die Funktion später deaktiviert, bleiben bestehende Freigaben laut Dokumentation erhalten, werden aber inaktiv und gelten beim erneuten Aktivieren wieder.
  • ALL_USERS-Bereich erlaubt Freigaben an sämtliche Benutzer der Instanz, auch an künftige. Die Dokumentation stuft das selbst als potenziell gefährlich ein. Für die meisten kleinen Teams ist die Beschränkung auf SPECIFIC_USERS die richtige Wahl.

Funktionstest und Verschlüsselung prüfen

Ob die Instanz wirklich arbeitet, zeigt sich am besten an einem echten TOTP-Code. Für den Test wurde ein Konto mit dem bekannten Beispielgeheimnis JBSWY3DPEHPK3PXP angelegt und der erzeugte Code gegen eine unabhängige lokale Berechnung geprüft. Beide lieferten denselben Wert, in diesem Fall 457859. Die API-Antwort enthält neben dem aktuellen Code auch den Zeitstempel und bereits das nächste Passwort:

# Reale Antwort der OTP-Abfrage im Test
{"password":"457859","otp_type":"totp","generated_at":1790036107,"period":30,"next_password":"499341"}

Deutlich aufschlussreicher ist der Blick in die Datenbank, weil er zeigt, was die Verschlüsselungsoption wirklich tut. Vor ihrer Aktivierung stand das Geheimnis im Klartext in der SQLite-Datei:

# Vor dem Aktivieren der Verschluesselung
1|S-EDV Testdienst|JBSWY3DPEHPK3PXP

# Nach dem Aktivieren, Dienstname und Geheimnis verschluesselt
1|eyJpdi...Q0E9|eyJpdi...MTRz

Das ist ein Ergebnis, das man kennen sollte: Ohne die aktivierte Option liegen die zweiten Faktoren unverschlüsselt in einer Datei, die in jedem Backup und in jedem versehentlich kopierten Verzeichnis landet. Und es erklärt zugleich, warum der APP_KEY danach unantastbar ist, denn genau dieser Schlüssel entschlüsselt die Werte wieder.

Persistente Daten und Rechte

Alles, was überleben muss, liegt im gebundenen Verzeichnis ./data, das im Container unter /2fauth eingehängt ist. Nach dem ersten Start sah es im Test so aus:

data/
├── database.sqlite          # die komplette Datenbank, rund 292 KB nach der Installation
├── installed                # Markierungsdatei des Einstiegsskripts
└── storage/
    ├── oauth-private.key    # Passport-Schluessel, Rechte 0600
    ├── oauth-public.key     # Passport-Schluessel, Rechte 0660
    ├── framework/           # Sessions, Cache, kompilierte Views
    ├── logs/                # Anwendungsprotokolle
    └── app/public/icons/    # hochgeladene Dienst-Symbole

Die beiden OAuth-Schlüsseldateien verdienen Aufmerksamkeit. Die Release-Notes zu Version 8 weisen ausdrücklich darauf hin, dass die Authentifizierungskomponente nun die Dateirechte prüft. Wenn beim Aktualisieren der Fehler chmod(): Operation not permitted erscheint, gehören die Dateien meist nicht dem Benutzer 1000. Laut Release-Notes muss oauth-private.key die Rechte 0660 und oauth-public.key die Rechte 0600 haben. Im Test legte das Image die Dateien mit 0600 für den privaten und 0660 für den öffentlichen Schlüssel an, also genau umgekehrt zur Angabe in den Release-Notes, und funktionierte damit einwandfrei. Wer auf den Fehler stößt, sollte daher zuerst den mitgelieferten Reparaturbefehl verwenden, statt die Rechte von Hand zu setzen:

# Schluesselrechte durch die Anwendung selbst korrigieren lassen
docker compose exec 2fauth /usr/bin/php /srv/artisan 2fauth:fix-passport-key-permissions

# Besitzer auf dem Host pruefen und notfalls korrigieren
ls -l data/storage/oauth-*.key
chown 1000:1000 data/storage/oauth-private.key data/storage/oauth-public.key

Das Anwendungsprotokoll liegt laut Dokumentation in /2fauth/storage/logs und damit ebenfalls im gebundenen Verzeichnis. Nginx- und PHP-FPM-Protokolle schreibt der Container auf die Standardausgabe, sie erscheinen also in docker compose logs.

Sichere Netzwerkfreigabe, Reverse Proxy und TLS

Die Portangabe im Compose-Beispiel oben bindet bewusst an 127.0.0.1. Damit ist die Anwendung vom Netz aus nicht direkt erreichbar, und der Reverse Proxy ist der einzige Weg hinein. Das ist bei einer Anwendung, die zweite Faktoren bündelt, keine Feinheit, sondern die Grundeinstellung.

Eine passende Nginx-Konfiguration vor dem Container sieht so aus:

server {
    listen 443 ssl http2;
    server_name 2fa.example.com;

    ssl_certificate     /etc/letsencrypt/live/2fa.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/2fa.example.com/privkey.pem;

    # Nur moderne Protokolle zulassen
    ssl_protocols TLSv1.2 TLSv1.3;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Zwei Dinge müssen dazu passen. Erstens muss APP_URL exakt die öffentliche Adresse enthalten, inklusive https und gegebenenfalls einem abweichenden Port. Die Dokumentation weist darauf hin, dass ein Aufruf über eine alternative Adresse wie eine nackte IP zwar funktioniert, aber WebAuthn, SSO-Weiterleitungen und die Linkerzeugung brechen lässt. Zweitens muss TRUSTED_PROXIES die Adresse des Proxys enthalten, sonst erzeugt die Anwendung Links mit dem falschen Schema, weil sie nur den unverschlüsselten Verkehr vom Proxy sieht. Die Fehlerseite der Dokumentation nennt genau dieses Symptom: Firefox warnt vor einer unsicheren Verbindung, obwohl TLS terminiert wird.

Für den Zugriff selbst gibt es drei vertretbare Modelle, in absteigender Sicherheit:

  • Nur über VPN. Die Instanz ist aus dem Internet überhaupt nicht erreichbar. Für ein kleines Team mit fester Arbeitsumgebung der sauberste Weg.
  • Vorgeschaltete Authentifizierung. Ein Forward-Auth-Dienst vor der Anwendung. Für diesen Fall bietet 2FAuth den reverse-proxy-guard, bei dem die Anwendung ausschließlich den Kopfzeilen des Proxys vertraut. Seit Version 8 muss der Proxy dafür zwingend in TRUSTED_PROXIES stehen. Wer diesen Weg geht, sollte sich der Konsequenz bewusst sein: 2FAuth prüft dann nichts mehr selbst.
  • Öffentlich erreichbar mit Passkey-Pflicht. Nur vertretbar, wenn jedes Konto mit WebAuthn abgesichert ist, die Registrierung geschlossen bleibt und Anmeldeversuche begrenzt sind. Ein reines Passwort als einziger Schutz vor allen zweiten Faktoren des Unternehmens ist es nicht.

Unabhängig vom Modell gilt: Passwortmanager und 2FAuth gehören nicht auf dieselbe Instanz, nicht hinter dieselbe Anmeldung und idealerweise nicht auf denselben Host. Sonst genügt eine kompromittierte Sitzung, um beide Faktoren gleichzeitig zu erhalten.

Backup und Wiederherstellung

Beim Standard-Setup mit SQLite besteht ein vollständiges Backup aus drei Teilen: dem Datenverzeichnis, der Datei mit den Umgebungsvariablen und dem APP_KEY. Letzterer steckt zwar in settings.env, gehört aber zusätzlich getrennt gesichert, denn ohne ihn ist das Backup der verschlüsselten Einträge wertlos.

Der Container sollte für das Backup kurz gestoppt werden, damit die SQLite-Datei nicht mitten in einer Schreiboperation kopiert wird:

# Dienst anhalten, damit die SQLite-Datei konsistent ist
docker compose stop 2fauth

# Datenverzeichnis, Variablen und Compose-Datei sichern
mkdir -p backup
tar czf backup/2fauth-$(date +%F).tar.gz data settings.env compose.yaml

# Dienst wieder starten
docker compose start 2fauth

# Inhalt des Archivs kontrollieren
tar tzf backup/2fauth-$(date +%F).tar.gz | head

Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung. Deshalb wurde die Wiederherstellung im Test vollständig durchgespielt: Container beenden, Datenverzeichnis löschen, Archiv entpacken, Stack neu starten. Der Abruf der Oberfläche lieferte beim ersten Versuch wieder HTTP 200, die Anmeldung mit dem gesicherten Konto antwortete mit {"message":"authenticated"}, und der Codeabruf für den verschlüsselten Eintrag lieferte einen gültigen TOTP-Code mit HTTP 200, der wiederum mit der unabhängigen lokalen Berechnung übereinstimmte.

# Wiederherstellung durchspielen
docker compose down
rm -rf data
tar xzf backup/2fauth-2026-09-22.tar.gz
docker compose up -d

# Danach die Oberflaeche pruefen
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/
# 200

Wer eine externe MySQL- oder PostgreSQL-Datenbank verwendet, sichert statt der SQLite-Datei einen Datenbankabzug, braucht aber weiterhin das Verzeichnis storage mit den OAuth-Schlüsseln und den hochgeladenen Symbolen. Diese Variante wurde hier nicht getestet.

Ein zweiter Rettungsanker: 2FAuth kann Konten exportieren. Ein solcher Export enthält die Geheimnisse im Klartext und ist damit selbst ein hochsensibles Dokument. Er eignet sich als verschlüsselt abgelegter Notfallstand, nicht als tägliches Backup.

Updates und Rollback

Die Dokumentation beschreibt das Update für Docker knapp: neues Image ziehen, Container neu starten. Die Migrationen laufen beim Start automatisch mit.

# Vorher sichern, insbesondere die SQLite-Datei
docker compose stop 2fauth
tar czf backup/vor-update-$(date +%F).tar.gz data settings.env

# Neues Image holen und Container ersetzen
docker compose pull
docker compose up -d

# Laufende Version aus dem Protokoll ablesen
docker compose logs --no-log-prefix | grep "Running version"

Der letzte Befehl ist mehr als Kosmetik. Im Test gab der Container beim Start die Zeile Running version 8.0.2 commit 2023d68 built on 2026-09-02T20:35:12Z aus, was sich direkt gegen das Release-Tag aus der GitHub-API prüfen lässt.

Beim Rollback gilt eine harte Grenze: Migrationen verändern das Datenbankschema, und ein Zurückwechseln auf ein älteres Image nimmt diese Änderungen nicht zurück. Ein Rollback funktioniert deshalb verlässlich nur zusammen mit dem Rückspielen des Datenbank-Backups von vor dem Update. Genau dafür ist der erste Schritt oben da.

Beim Sprung auf Version 8 sind laut Release-Notes zwei Punkte zu beachten, die hier nicht selbst getestet wurden, weil die Installation direkt mit 8.0.2 erfolgte. Erstens werden alle persönlichen Zugriffstoken aus Version 7 und früher ungültig und müssen neu erstellt werden. Zweitens muss ein vorgeschalteter Authentifizierungsproxy nun zwingend in TRUSTED_PROXIES eingetragen sein, sonst funktioniert die Anmeldung über den Proxy nicht mehr.

Typische Fehler, Diagnose und Lösung

Die folgenden Fehlerbilder stammen bis auf die gekennzeichnete Ausnahme aus dem eigenen Test und wurden absichtlich provoziert.

SymptomUrsacheLösung
!! Environment variable APP_KEY is empty !! beim StartKein Schlüssel gesetztSchlüssel mit artisan key:generate --show erzeugen und in settings.env eintragen
Bind for 127.0.0.1:8000 failed: port is already allocatedDer Host-Port ist belegtFreien Port mit ss -tlnp suchen und die Portzuordnung in der compose.yaml ändern
The container name "/2fauth" is already in useEin Container mit diesem Namen existiert nochdocker rm -f 2fauth oder container_name anpassen
HTTP 502, im Protokoll connect() failed (111: Connection refused) while connecting to upstreamPHP-FPM ist beim frühen Abruf noch nicht bereitRund zehn Sekunden warten, start_period im Healthcheck ausreichend groß setzen
The secret cannot be deciphered, HTTP 400 beim CodeabrufDer APP_KEY wurde nach der Verschlüsselung geändertAlten Schlüssel in APP_PREVIOUS_KEYS eintragen und den Container neu starten. Ohne den alten Schlüssel sind die Daten verloren.
Datenbank wird nicht angelegt, Rechtefehler im ProtokollDas Hostverzeichnis gehört nicht UID 1000chown -R 1000:1000 data und chmod 700 data
chmod(): Operation not permitted nach dem Update auf Version 8Falsche Besitzrechte an den Passport-Schlüsseln, laut Release-Notes2fauth:fix-passport-key-permissions ausführen, Besitzer auf 1000:1000 setzen. Nicht selbst getestet.
Browser warnt hinter dem Proxy vor unsicherer VerbindungTRUSTED_PROXIES ist nicht gesetztProxy-IP eintragen, laut Dokumentation bei mehreren Proxys kommagetrennt. Nicht selbst getestet.

Zur Diagnose helfen drei Befehle, die im Test durchgehend brauchbare Ausgaben lieferten:

# Protokoll der Anwendung und des Webservers
docker compose logs --tail=80

# Datenbankverbindung durch die Anwendung selbst pruefen lassen
docker compose exec 2fauth /usr/bin/php /srv/artisan 2fauth:check-db-connection

# Healthcheck-Status und die letzten Pruefergebnisse
docker inspect 2fauth --format '{{.State.Health.Status}}'

Wenn sich Einstellungen nicht auswirken, liegt das meist am Konfigurationscache. Die Dokumentation nennt dafür php artisan config:clear beziehungsweise das Löschen von bootstrap/cache/config.php und anschließend php artisan config:cache. Im Container ist ein Neustart über docker compose up -d --force-recreate der einfachere Weg, weil das Einstiegsskript den Cache ohnehin neu aufbaut.

Saubere Deinstallation

Zum Entfernen genügen wenige Befehle. Die Warnung vorweg: Mit dem Datenverzeichnis verschwinden sämtliche 2FA-Geheimnisse unwiderruflich. Wenn auch nur ein Konto ausschließlich hier hinterlegt ist, sperrt dieser Schritt das Team aus dem entsprechenden Dienst aus. Vor dem Löschen also zuerst prüfen, ob für jeden Eintrag ein alternativer Zugang oder ein Wiederherstellungscode vorliegt.

# Container und Netzwerk entfernen
docker compose down --remove-orphans

# Letzte Sicherung anlegen, bevor die Daten verschwinden
tar czf ~/2fauth-final-$(date +%F).tar.gz data settings.env

# Daten endgueltig loeschen
rm -rf data

# Image entfernen
docker rmi 2fauth/2fauth:8.0.2

Einordnung für kleine Teams

2FAuth ist ein ausgereiftes Werkzeug für einen klar umrissenen Zweck. Die Installation ist in wenigen Minuten erledigt, der Container läuft ohne Root-Rechte, die Datenhaltung ist übersichtlich, und die Freigabefunktion löst genau das Problem, an dem Authenticator-Apps im Teamkontext scheitern. Wer geteilte Dienstkonten verwaltet, bekommt hier eine nachvollziehbare und auditierbare Lösung mit Protokollen über Anmeldungen und Codeerzeugungen.

Die entscheidende Frage ist aber nicht die Installation, sondern die Absicherung drumherum. Eine 2FAuth-Instanz ohne VPN oder vorgeschaltete Authentifizierung, mit passwortgeschützten Konten und ohne aktivierte Datenbankverschlüsselung, senkt das Sicherheitsniveau gegenüber verteilten Authenticator-Apps spürbar. Mit Passkey-Anmeldung, geschlossener Registrierung, aktivierter Verschlüsselung, getrenntem Passwortmanager und getesteter Wiederherstellung ist sie dagegen ein sinnvoller Baustein. Der Unterschied zwischen beiden Zuständen sind vielleicht dreißig Minuten Arbeit, und die lohnen sich an dieser Stelle mehr als fast überall sonst.

Passende Anleitungen auf S-EDV

Quellen

2FAuthDockerDocker ComposeZwei-Faktor-AuthentifizierungTOTPSelfhostingSicherheit