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

Inhalt
- Das Ziel ist ein einmaliger Setup ohne manuelle Schritte
- Exit Code 22
- Die Ursache der Host-Header-Validierung
- Fix-Versuch mit Shell-Funktion statt Variable
- Warum ich den Fix trotzdem verworfen habe
- Die Alternative mit ZITADEL_SETUP.md und AUTH_ENABLED=false
- Alle Artikel der Serie
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 22Ich 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.jsonDas 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 instanceDas 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:8080Der 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:
- Auf Zitadel warten bis es bereit ist (eigener Retry-Loop mit Timeout)
- Den PAT aus dem Volume lesen und validieren
- Das Projekt anlegen, bei bereits vorhandenem Projekt nicht abbrechen
- Zwei OIDC-Anwendungen erstellen
- Client-IDs aus JSON-Responses parsen
.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:
- Projekt in der Konsole anlegen
- Frontend-App: Typ “Web”, PKCE aktivieren, Redirect-URI eintragen
- Extension-App: Typ “Native”, PKCE aktivieren, Extension-URL eintragen
- Token-Typ auf JWT umstellen (Zitadel sendet standardmäßig Opaque Tokens, das Backend erwartet JWT)
- 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=falseDer Stack startet jetzt ohne Fehler. Kein Bootstrap-Container, keine Shell-Funktion, kein Exit Code 22.

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



