· Webentwicklung · 5 minuten Lesezeit
Zitadel Login V2 in Docker Compose einrichten: drei versteckte Fehler und ihre Lösung
Zitadel v4 bringt eine eigene Next.js Login-UI mit. Die Einrichtung in Docker Compose scheitert an drei nicht offensichtlichen Problemen: Postgres 18 Volume-Layout, fehlende PAT-Dateiberechtigung und falsche BaseURI-Konfiguration.

Inhalt
- Warum Zitadel und nicht Auth0
- Fehler 1: Postgres 18 ändert die Volume-Struktur
- Fehler 2: PAT-Datei kann nicht geschrieben werden
- Fehler 3: Die falsche BaseURI für Login V2
- Die vollständige Docker Compose Konfiguration
- Startsequenz
- Interaktiver PKCE-Flow und Container-Topologie
- Zitadel Console einrichten
- Alle Artikel der Serie
Warum Zitadel und nicht Auth0
Die Entscheidung war einfach: DSGVO-Konformität ohne Kompromisse. Auth0 und Okta sind US-amerikanische Unternehmen. Jeder JWT-Request landet auf Servern außerhalb der EU. Das ist nach Schrems II ein Problem, das ich nicht haben will.
Zitadel läuft als Docker Container auf demselben deutschen Server wie der Rest der Anwendung. Keine Daten verlassen Deutschland. Kein Drittanbieter-Vertrag außer einem Auftragsverarbeitungsvertrag mit dem Hoster.
Ab Zitadel v4 gibt es außerdem eine offizielle Next.js Login-UI namens Login V2. Die ersetzt die alte eingebettete Login-Seite durch eine eigenständige Next.js-Anwendung, die separat deployed wird. Das ermöglicht vollständiges UI-Customizing ohne Zitadel selbst anzufassen.
Die Einrichtung klingt einfach. In der Praxis sind drei nicht offensichtliche Fehler aufgetaucht, die ich hier dokumentiere.
Fehler 1: Postgres 18 ändert die Volume-Struktur
Postgres 18 speichert Daten nicht mehr in /var/lib/postgresql/data, sondern in einem versionierten Unterverzeichnis: /var/lib/postgresql/18/main.
Wenn das Volume auf /var/lib/postgresql/data gemountet wird, sieht Postgres keine kompatiblen Daten und verweigert den Start:
zitadel_db | Error: there appears to be PostgreSQL data in:
zitadel_db | /var/lib/postgresql/data (unused mount/volume)
zitadel_db | This is usually the result of upgrading the Docker image
zitadel_db | without upgrading the underlying database using pg_upgradeDie Lösung: Das Volume eine Ebene höher mounten. Postgres erstellt dann selbst das versionierte Unterverzeichnis.
# Wrong (Postgres 17 and earlier):
volumes:
- zitadel_db_data:/var/lib/postgresql/data
# Correct (Postgres 18+):
volumes:
- zitadel_db_data:/var/lib/postgresqlDas ist eine Breaking Change in Postgres 18. Wer ein bestehendes Volume migriert, muss pg_upgrade durchführen. Für lokale Entwicklung: Volume löschen und neu initialisieren.
Fehler 2: PAT-Datei kann nicht geschrieben werden
Zitadel läuft als User 1001 (nicht root). Beim ersten Start mit start-from-init erstellt Zitadel automatisch einen login-service Machine User und schreibt dessen PAT (Personal Access Token) in ein Volume, aus dem zitadel_login ihn liest.
Das Problem: Docker erstellt Named Volumes standardmäßig mit root-Besitz. Zitadel als User 1001 hat keine Schreibrechte.
zitadel | migration failed: open /pat/login-client.pat: permission deniedDie 03_default_instance-Migration schlägt fehl. Zitadel startet, hat aber keine gültige Instanz. Das Ergebnis: Login-Redirects landen ins Leere oder in einer Endlosschleife.
Die Lösung ist ein Init-Container, der das Verzeichnis vor Zitadel-Start beschreibbar macht:
services:
pat-init:
image: alpine:3
command: sh -c "chmod 777 /pat"
volumes:
- zitadel_login_pat:/pat
zitadel:
depends_on:
pat-init:
condition: service_completed_successfully
zitadel_db:
condition: service_healthypat-init läuft einmal, macht das Verzeichnis world-writable und terminiert. Erst dann startet Zitadel.
Wichtig: Beide Volumes (zitadel_db_data und zitadel_login_pat) sind eng gekoppelt. Wenn die Datenbank neu initialisiert wird, muss auch das PAT-Volume zurückgesetzt werden, weil Zitadel das PAT nur beim ersten Init schreibt.
docker volume rm myapp_zitadel_db_data myapp_zitadel_login_patFehler 3: Die falsche BaseURI für Login V2
Zitadel konstruiert die Login-URL so:
{LOGINV2_BASEURI}/login?authRequest={id}Die Next.js Login-App wird mit einem NEXT_PUBLIC_BASE_PATH=/ui/v2/login gestartet. Das bedeutet, alle Routes sind unter /ui/v2/login/login, /ui/v2/login/register usw. erreichbar.
Wenn die BaseURI nur http://localhost:9000 gesetzt ist, konstruiert Zitadel:
http://localhost:9000/login?authRequest=...Diese URL gibt 404 zurück. Die Next.js-App kennt /login nicht, nur /ui/v2/login/login.
# Wrong:
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: "http://localhost:9000"
# Correct:
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: "http://localhost:9000/ui/v2/login"Dann konstruiert Zitadel:
http://localhost:9000/ui/v2/login/login?authRequest=...Das ist die korrekte Route der Next.js-App.
Nach jeder Änderung an ZITADEL_DEFAULTINSTANCE_* oder ZITADEL_FIRSTINSTANCE_* müssen beide Volumes zurückgesetzt werden. Zitadel schreibt diese Werte nur beim start-from-init-Lauf in die Datenbank.
Die vollständige Docker Compose Konfiguration
services:
pat-init:
image: alpine:3
command: sh -c "chmod 777 /pat"
volumes:
- zitadel_login_pat:/pat
zitadel_db:
image: postgres:18-alpine
environment:
POSTGRES_USER: zitadel
POSTGRES_PASSWORD: zitadel
POSTGRES_DB: zitadel
volumes:
- zitadel_db_data:/var/lib/postgresql
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U zitadel -d zitadel']
interval: 5s
timeout: 5s
retries: 10
zitadel:
image: ghcr.io/zitadel/zitadel:v4.15.0
command: start-from-init --masterkeyFromEnv
environment:
ZITADEL_MASTERKEY: 'MasterkeyNeedsToHave32Characters'
ZITADEL_DATABASE_POSTGRES_HOST: zitadel_db
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: zitadel
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
ZITADEL_EXTERNALDOMAIN: localhost
ZITADEL_EXTERNALPORT: 8080
ZITADEL_EXTERNALSECURE: false
ZITADEL_TLS_ENABLED: false
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: 'http://localhost:9000/ui/v2/login'
ZITADEL_FIRSTINSTANCE_PATPATH: /pat/login-client.pat
ports:
- '8080:8080'
depends_on:
pat-init:
condition: service_completed_successfully
zitadel_db:
condition: service_healthy
volumes:
- zitadel_login_pat:/pat
zitadel_login:
image: ghcr.io/zitadel/zitadel-login:v4.15.0
environment:
ZITADEL_API_URL: 'http://zitadel:8080'
ZITADEL_SERVICE_USER_TOKEN_FILE: /pat/login-client.pat
NEXT_PUBLIC_BASE_PATH: '/ui/v2/login'
ports:
- '9000:3000'
depends_on:
- zitadel
volumes:
- zitadel_login_pat:/pat
volumes:
zitadel_db_data:
zitadel_login_pat:Startsequenz
Die korrekte Startreihenfolge ist:
pat-initläuft und terminiert (Volume-Rechte setzen)zitadel_dbstartet und wird healthyzitadelstartet, führtstart-from-initdurch, schreibt PAT ins Volumezitadel_loginstartet und liest PAT
zitadel ist für zitadel_login nicht als Health-Check-Dependency konfiguriert. In der Praxis braucht zitadel_login einige Sekunden, bis Zitadel vollständig hochgefahren ist, bevor der Login-Flow funktioniert. Das ist beim ersten Start normal.
Interaktiver PKCE-Flow und Container-Topologie
Die Sandbox unten zeigt den kompletten PKCE-Fluss und die Container-Topologie in einer kompakten, article-freundlichen Ansicht.
Zitadel Console einrichten
Die Konsole ist unter http://localhost:8080/ui/console/ erreichbar. Der erste Login mit dem Admin-User funktioniert über Zitadel Console selbst.
Für die Anwendung werden zwei separate OAuth-Clients angelegt:
- Web-App (Frontend): PKCE, kein Client Secret, redirect_uri auf
http://localhost:5173 - Native App (Browser Extension): PKCE, kein Client Secret, redirect_uri auf die Extension-URL
Die Chrome Extension erhält eine andere App, weil chrome.identity.launchWebAuthFlow andere Redirect-URI-Anforderungen hat als eine normale Web-App. Dazu mehr im Artikel über PKCE in Chrome Extensions.
Abbildung: Zitadel Console nach der Einrichtung. Ein Projekt mit zwei separaten OAuth-Apps: eine fuer das React-Frontend (Web) und eine fuer die Chrome Extension (Native/PKCE).
Alle Artikel der Serie
- Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
- RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
- AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
- Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
- Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
- Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
- Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
- tsconfig und Vite:
Node16vs.bundler, warum Vite eigene Regeln hat: Artikel lesen - Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
- Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
- Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
- Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
- PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
- Instagram Karussell vollständig erfassen mit MutationObserver: Artikel lesen
- Notiz und Tags beim Screenshot-Speichern: Artikel lesen
- Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
- Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
- Zitadel Login V2 in Docker Compose: drei versteckte Fehler (dieser Artikel)
- PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
- React Frontend mit react-oidc-context und Zitadel: Artikel lesen
- Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
Du baust ein DSGVO-konformes Auth-Setup mit Zitadel und Docker? Lass uns das gemeinsam einschätzen.



