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

· Webentwicklung  · 6 minuten Lesezeit

Warum ich den Zitadel Bootstrap entfernt habe wegen Host-Header-Validierung in Docker

Der Bootstrap-Container beendete sich mit Exit Code 22. Die Ursache war keine falsche API-URL, sondern Zitadels Host-Header-Validierung innerhalb des Docker-Netzwerks.

Der Bootstrap-Container beendete sich mit Exit Code 22. Die Ursache war keine falsche API-URL, sondern Zitadels Host-Header-Validierung innerhalb des Docker-Netzwerks.

Inhalt

Das Ziel ist ein einmaliger Setup ohne manuelle Schritte

Die Idee war naheliegend. Ein einziger docker-compose up --build soll den gesamten Stack hochfahren, inklusive Zitadel als Identity Provider. Zitadel schreibt beim Start einen Admin-PAT (Personal Access Token) ins Volume. Ein Bootstrap-Container liest diesen PAT, legt das Projekt an, erstellt die OIDC-Anwendungen für Frontend und Extension, und trägt die generierten Client-IDs in die .env-Dateien ein.

Danach: alles läuft, niemand muss manuell in die Zitadel-Konsole gehen.

Der Bootstrap-Container scheiterte von Anfang an.

Exit Code 22

curl gibt Exit Code 22 zurück, wenn der Server einen HTTP-Fehler sendet und die Option --fail gesetzt ist. Das Skript verwendete set -e, was den gesamten Prozess beim ersten fehlgeschlagenen Befehl beendet, bevor irgendeine Diagnoseinformation ausgegeben werden kann.

Das Log war nicht hilfreich:

zitadel-bootstrap exited with code 22

Ich musste --fail entfernen und den Response-Body manuell aufzeichnen:

# Show full response body and HTTP status for debugging
curl -s -o /tmp/response.json -w "\nHTTP %{http_code}\n" \
  -H "Authorization: Bearer ${PAT}" \
  http://zitadel:8080/management/v1/projects

cat /tmp/response.json

Das Ergebnis: HTTP 404 auf jeden Management-API-Call.

Die Ursache der Host-Header-Validierung

Zitadel v4 ist eine Multi-Instanz-Plattform. Intern unterscheidet Zitadel Instanzen anhand des Host-HTTP-Headers. Wenn eine Instanz mit ZITADEL_EXTERNALDOMAIN=localhost konfiguriert ist, erwartet Zitadel für jeden Request einen Host-Header, der localhost enthält.

Der Bootstrap-Container schickte Requests an http://zitadel:8080. Der HTTP-Client setzt den Host-Header automatisch auf den Hostnamen aus der URL. Das ergibt Host: zitadel:8080.

Zitadel sieht einen Request für die Instanz zitadel:8080, die es nicht gibt. Keine passende Instanz, also 404.

Request target:  http://zitadel:8080/management/v1/projects
Host header set: zitadel:8080     <- Docker internal alias
Host expected:   localhost        <- ZITADEL_EXTERNALDOMAIN value
Result:          HTTP 404         <- no matching instance

Das ist kein Bug in Zitadel, sondern ein erwartetes Verhalten. Der zitadel_login-Container im selben Setup umgeht das Problem explizit:

# docker-compose.override.yml
environment:
  - CUSTOM_REQUEST_HEADERS=Host:localhost:8080,X-Zitadel-Public-Host:localhost:8080

Der Bootstrap-Container hatte nie einen entsprechenden Header.

Fix-Versuch mit Shell-Funktion statt Variable

Shell-Variablen lassen sich nicht direkt als Befehle mit mehreren Parametern nutzen. CURL="curl -H 'Host: localhost'" und danach $CURL http://... funktioniert nicht, weil Shell-Expansion den String nicht in separate Argumente aufteilt.

Die korrekte Lösung ist eine Shell-Funktion:

ZITADEL_HOST="localhost:8080"

# Shell function avoids argument-splitting issue with stored flags
zcurl() {
  curl -sf -H "Host: ${ZITADEL_HOST}" "$@"
}

# Works: Host header is correctly set to "localhost:8080"
zcurl -X POST http://zitadel:8080/management/v1/projects \
  -H "Authorization: Bearer ${PAT}" \
  -H "Content-Type: application/json" \
  -d '{"name": "Local Insight"}'

Das funktioniert technisch. Zitadel findet die Instanz, die API-Calls liefern 200.

Warum ich den Fix trotzdem verworfen habe

Das Skript wurde mit jedem weiteren Schritt fragiler. Es musste:

  1. Auf Zitadel warten bis es bereit ist (eigener Retry-Loop mit Timeout)
  2. Den PAT aus dem Volume lesen und validieren
  3. Das Projekt anlegen, bei bereits vorhandenem Projekt nicht abbrechen
  4. Zwei OIDC-Anwendungen erstellen
  5. Client-IDs aus JSON-Responses parsen
  6. .env-Dateien patchen ohne bestehende Werte zu überschreiben

Für jeden dieser Schritte gab es eigene Fehlerfälle. Das Skript war nicht idempotent: wenn Schritt 3 gelang, Schritt 4 jedoch fehlschlug, war beim nächsten Start unklar, was passieren würde. Existiert das Projekt bereits? Gibt es Konflikte bei den OIDC-Apps?

Dann die entscheidende Frage: Wie oft wird dieser Bootstrap tatsächlich ausgeführt?

Auf einer frischen Entwicklungsumgebung: einmal. In der Produktion auf Hetzner: einmal. Das rechtfertigt keinen komplexen, fehleranfälligen Automationsmechanismus. Die Komplexität war höher als das Problem, das sie lösen sollte.

Die Alternative mit ZITADEL_SETUP.md und AUTH_ENABLED=false

Ich habe die notwendigen Schritte einmalig in der Zitadel-Konsole unter http://localhost:8080/ui/console/ durchgeführt und als Schritt-für-Schritt-Anleitung in ZITADEL_SETUP.md im Repository dokumentiert:

  1. Projekt in der Konsole anlegen
  2. Frontend-App: Typ “Web”, PKCE aktivieren, Redirect-URI eintragen
  3. Extension-App: Typ “Native”, PKCE aktivieren, Extension-URL eintragen
  4. Token-Typ auf JWT umstellen (Zitadel sendet standardmäßig Opaque Tokens, das Backend erwartet JWT)
  5. Client-IDs in .env-Dateien eintragen, Stack neu starten

Das dauert etwa fünf Minuten. Es gibt keinen Container, der mit Exit Code 22 scheitern kann.

Das zweite Teil der Lösung: AUTH_ENABLED=false als sicherer Standardwert. Das Backend injiziert dann eine synthetische Identität (dev-user-local). Wer sofort entwickeln will, tut das ohne Auth-Overhead. Wer Auth einrichten will, folgt der Anleitung und schaltet es danach ein.

# docker-compose.override.yml
backend:
  environment:
    # Disabled by default — enable after completing ZITADEL_SETUP.md
    - AUTH_ENABLED=false
# frontend/.env and extension/.env
# Set to "true" only after completing the Zitadel setup
VITE_AUTH_ENABLED=false

Der Stack startet jetzt ohne Fehler. Kein Bootstrap-Container, keine Shell-Funktion, kein Exit Code 22.

Sequenzdiagramm: Zitadel Host-Header-Validierung im Docker-Netzwerk

Das Diagramm zeigt den Pfad, der zum 404-Fehler fuehrt: Der Bootstrap-Container adressiert Zitadel ueber den Docker-internen Alias zitadel:8080, der HTTP-Client setzt den Host-Header auf diesen Alias, aber Zitadel erwartet localhost als Instanzkennung. Eine existierende Instanz fuer zitadel:8080 gibt es nicht, also antwortet Zitadel mit 404 auf jeden Management-API-Call.


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: Lazy-Loading, Observer-before-click, Timeout-Fallback: 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: Artikel lesen
  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
  22. Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
  23. MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
  24. access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen
  25. Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen
  26. Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen
  27. Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup (dieser Artikel)
  28. Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen
  29. Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen
  30. Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen
  31. DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen
  32. Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen
  33. Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen
  34. Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen

Du richtest Zitadel in Docker ein und kämpfst mit ähnlichen Problemen? Lass uns das gemeinsam einschätzen.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen