Neu veröffentlicht: E-Commerce mit Power Pages, Stripe & Analytics

· 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.

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

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_upgrade

Die 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/postgresql

Das 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 denied

Die 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_healthy

pat-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_pat

Fehler 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:

  1. pat-init läuft und terminiert (Volume-Rechte setzen)
  2. zitadel_db startet und wird healthy
  3. zitadel startet, führt start-from-init durch, schreibt PAT ins Volume
  4. zitadel_login startet 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.

Zitadel Console: Projekt mit Frontend und Extension App 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

  1. Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
  2. RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
  3. AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
  4. Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
  5. Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
  6. Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
  7. Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
  8. tsconfig und Vite: Node16 vs. bundler, warum Vite eigene Regeln hat: Artikel lesen
  9. Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
  10. Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
  11. Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
  12. Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
  13. PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
  14. Instagram Karussell vollständig erfassen mit MutationObserver: Artikel lesen
  15. Notiz und Tags beim Screenshot-Speichern: Artikel lesen
  16. Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
  17. Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
  18. Zitadel Login V2 in Docker Compose: drei versteckte Fehler (dieser Artikel)
  19. PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
  20. React Frontend mit react-oidc-context und Zitadel: Artikel lesen
  21. 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.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen