Zum Inhalt

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
  35. Traefik als einziger Einstiegspunkt im Docker Compose Stack: Artikel lesen
  36. Zitadel hinter Traefik richtig verdrahten mit Issuer, JWKS und Login V2: Artikel lesen
  37. Frontend reparieren wenn der nginx Healthcheck an localhost scheitert: Artikel lesen
  38. Observability für meinen Docker Compose Stack mit Bull Board und Dozzle: Artikel lesen
  39. Qdrant Dashboard sicher öffnen mit lokalem Traefik und SSH Tunnel: Artikel lesen
  40. Diagnose: Warum mein Chunking trotz Tokenisierung noch scheiterte: Artikel lesen
  41. Entscheidung: Warum ich den Chunk auf 1500 Tokens gesetzt habe: Artikel lesen
  42. Implementierung: Wie ich den Embedding Workflow in mehrere saubere Schritte zerlegt habe: Artikel lesen
  43. Validierung: Wie ich Chunking, Speicherung und Suche wieder zusammenbringe: Artikel lesen
  44. ‘Capture Intake als eigenes Modul, weniger Orchestrierung im Express Endpoint’: Artikel lesen

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

Ähnliche Beiträge

Was soll Ihr Portal können?

Beschreiben Sie Ihren Workflow. Wir klären gemeinsam, welcher nächste Schritt sinnvoll ist.

Workflow besprechen