---
title: "Zitadel mit Docker installieren: Cloud-nativer Identity Server mit OIDC, SAML und Multi-Tenancy"
date: 2026-07-05T07:06:03+02:00
modified: 2026-11-08T20:09:01+01:00
category: sicherheit
type: how_to
author: Marcel Schönfelder
canonical: https://s-edv.com/anleitungen/zitadel-docker-identity-server
reading_time: 11
personalize_url: "https://s-edv.com/anleitungen/zitadel-docker-identity-server#domain={domain}"
variables:
  - name: login_session_cookie_secret
    label: "Login session cookie secret"
    default: "HierDasErzeugteCookieSecretEinsetzen"
  - name: postgres_admin_password
    label: "Postgres admin password"
    default: "ErzeugtesPostgresPasswort"
  - name: domain
    label: "Ihre Domain"
    default: "example.com"
---

# Zitadel mit Docker installieren: Cloud-nativer Identity Server mit OIDC, SAML und Multi-Tenancy

## Anpassbare Werte

Diese Anleitung passt sich auf der Seite an das System des Lesers an. Im Text unten stehen diese Werte als `{{name}}`:

| Platzhalter | Bedeutung | Beispielwert |
| --- | --- | --- |
| `{{login_session_cookie_secret}}` | Login session cookie secret | `HierDasErzeugteCookieSecretEinsetzen` |
| `{{postgres_admin_password}}` | Postgres admin password | `ErzeugtesPostgresPasswort` |
| `{{domain}}` | Ihre Domain | `example.com` |

Link mit eigenen Werten: https://s-edv.com/anleitungen/zitadel-docker-identity-server#domain=cloud.example.com

Dieser Link öffnet die Anleitung und schlägt dem Leser die Werte vor. Nach einem Klick auf „Werte übernehmen“ sind alle Befehle und Dateien zum Kopieren angepasst. Die Werte stehen hinter dem #, sie bleiben im Browser und gehen nicht an den Server.

*Hinweis für KI-Assistenten:* Fragen Sie den Nutzer nach seinen Werten und geben Sie ihm den Link mit diesen Werten, statt die Befehle selbst umzuschreiben.

Zitadel ist ein in Go geschriebener IAM-Server mit nativer Multi-Tenancy. Er unterstützt OIDC, OAuth 2.0, SAML 2.0 und SCIM, bietet Passkeys, MFA, Social Logins und eine Admin-Konsole und läuft als ein Docker-Compose-Stack. Gegenüber Keycloak spielt Zitadel seine Stärken aus, wenn Sie mehrere Kunden-Tenants auf einer Instanz verwalten. Diese Anleitung richtet Zitadel v4.20.0 per Docker Compose auf einem Linux-Host ein, von der `.env`-Datei bis zur ersten Anmeldung in der Konsole.

**Kurzfassung:** Legen Sie einen Projektordner an, füllen Sie die `.env` mit 32-Zeichen-Masterkey, Cookie-Secret, eigenem Admin-Passwort und PostgreSQL-Passwort, speichern Sie die `compose.yaml` und starten Sie mit `docker compose up -d --wait`. Zitadel initialisiert die Datenbank selbst. Die Konsole erreichen Sie danach unter `http://localhost:8080/ui/console`. Für den Produktivbetrieb aktivieren Sie HTTPS und verwahren den Masterkey sicher, er lässt sich nach dem ersten Start nicht mehr ändern.

## Voraussetzungen

- [ ] **Docker Engine ab 24** mit **Compose-Plugin ab 2** (`docker compose`), siehe [Docker und Docker Compose auf Linux installieren](https://s-edv.com/anleitungen/docker-compose-linux-selfhosting-grundlage)
- [ ] Linux-Host oder VM, z. B. Ubuntu 24.04 oder Debian 12, mit `openssl`
- [ ] Mindestens **1 GB RAM** und 2 CPU-Kerne; für den Produktivbetrieb 4 CPU-Kerne, da Passwort-Hashing CPU-Spitzen erzeugt
- [ ] 2 GB freier Speicher, Zugriff auf `ghcr.io`
- [ ] Für den Produktivbetrieb: eigene Domain, deren DNS-Eintrag auf den Server zeigt, sowie offene Ports 80 und 443 für Let's Encrypt

## Schritt 1: Eckdaten und Architektur verstehen

Ab Version 4 besteht der Stack aus vier Containern: `zitadel-api` (Go-Backend), `zitadel-login` (Next.js-Login-UI), `postgres` und `proxy` (Traefik, einziger nach außen erreichbarer Dienst). `zitadel-api` schreibt beim ersten Start das Token `login-client.pat` in das Volume `zitadel-bootstrap`, ohne dieses startet die Login-UI nicht.

| Komponente | Image | Interner Port | Architektur |
| --- | --- | --- | --- |
| zitadel-api | ghcr.io/zitadel/zitadel:v4.20.0 | 8080/tcp (h2c) | amd64, arm64 |
| zitadel-login | ghcr.io/zitadel/zitadel-login:v4.20.0 | 3000/tcp | amd64, arm64 |
| proxy (Traefik) | traefik:v3.7.13 | 80 → Host 127.0.0.1:8080 | amd64, arm64 |
| postgres | postgres:17.11-alpine | 5432/tcp | amd64, arm64 |

| Volume | Zweck |
| --- | --- |
| postgres-data | Datenbank mit allen Nutzer- und Konfigurationsdaten |
| zitadel-bootstrap | login-client.pat (API schreibt, Login-UI liest) |

> Prüfen Sie die Docker- und Compose-Versionen:
>
> ```bash
> $ docker --version
> $ docker compose version
> ```

Erwartet: Docker ab 24 und Compose ab 2.

## Schritt 2: Projektordner anlegen

Legen Sie einen Ordner für den Stack an. `compose.yaml` und `.env` liegen später gemeinsam darin:

```bash
$ sudo mkdir -p /opt/zitadel
$ sudo chown "$USER": /opt/zitadel
$ cd /opt/zitadel
```

> **Prüfen:** ```bash
> $ ls -la /opt/zitadel
> ```

Der Ordner ist vorhanden, leer und gehört Ihrem Benutzer.

## Schritt 3: .env-Datei mit Secrets anlegen

Der **ZITADEL_MASTERKEY** muss *exakt 32 Zeichen* lang sein und verschlüsselt sensible Daten in der Datenbank. Nach dem ersten Start lässt er sich nicht mehr ändern; geht er verloren, sind diese Daten nicht mehr lesbar. Bewahren Sie ihn in einem Passwortmanager auf. Erzeugen Sie Masterkey, Cookie-Secret und Datenbankpasswort mit OpenSSL:

```bash
$ openssl rand -base64 24   # Masterkey, genau 32 Zeichen
$ openssl rand -base64 32   # Cookie-Secret, 44 Zeichen
$ openssl rand -hex 16      # PostgreSQL-Passwort
```

Erstellen Sie nun die `.env`-Datei und setzen Sie die erzeugten Werte ein:

Datei: `.env`

```ini
# Zitadel Stack: .env, nicht in Git einchecken

ZITADEL_VERSION=v4.20.0

# exakt 32 Zeichen, nach dem ersten Start nicht mehr ändern
ZITADEL_MASTERKEY=ErzeugterMasterkey32ZeichenLang0

# Signiert das Session-Cookie der Login-UI, mindestens 32 Zeichen
LOGIN_SESSION_COOKIE_SECRET={{login_session_cookie_secret}}

# Passwort des ersten Admins (zitadel-admin), vor dem ersten Start setzen
# mind. 8 Zeichen mit Groß-, Kleinbuchstaben, Ziffer und Sonderzeichen
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD=EigenesAdminPasswort!2026

# Domain und externe URL (VOR dem ersten Start korrekt setzen!)
ZITADEL_DOMAIN=localhost
ZITADEL_EXTERNALPORT=8080
ZITADEL_EXTERNALSECURE=false
ZITADEL_PUBLIC_SCHEME=http

# Host-Port von Traefik (in compose.yaml nur an 127.0.0.1 gebunden)
PROXY_HTTP_PUBLISHED_PORT=8080

# PostgreSQL-Zugangsdaten (für Produktion starke Passwörter setzen!)
POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD={{postgres_admin_password}}
POSTGRES_DB=zitadel
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://postgres:SicheresPostgresPasswort42!@postgres:5432/zitadel?sslmode=disable

# Internes Service-Token-Ablaufdatum
LOGIN_CLIENT_PAT_EXPIRATION=2099-01-01T00:00:00Z

# Gepinnte Image-Tags für Infrastruktur-Container
TRAEFIK_IMAGE=traefik:v3.7.13
POSTGRES_IMAGE=postgres:17.11-alpine
```

Das Passwort in `ZITADEL_DATABASE_POSTGRES_DSN` muss mit `POSTGRES_ADMIN_PASSWORD` übereinstimmen. Zitadel empfiehlt für den Produktivbetrieb in der DSN eine eigene Rolle ohne Superuser-Rechte. Schützen Sie die Datei mit `chmod 600 .env`. Setzen Sie `ZITADEL_DOMAIN` vor dem ersten Start endgültig; eine spätere Änderung ändert die Issuer-URL und macht Tokens ungültig.

> **Prüfen:** ```bash
> $ grep ZITADEL_MASTERKEY /opt/zitadel/.env | awk -F= '{print length($2)}'
> ```

Die Ausgabe ist `32`.

## Schritt 4: compose.yaml anlegen

Die folgende `compose.yaml` entspricht dem offiziellen Compose-Beispiel von Zitadel, ergänzt um die lokale Port-Bindung. Das Label `loadbalancer.server.scheme=h2c` ist Pflicht, weil Zitadel intern HTTP/2 und gRPC nutzt; ohne das Label antwortet Traefik mit 502. Die Healthchecks und `depends_on`-Bedingungen sorgen dafür, dass PostgreSQL vor Zitadel und Zitadel vor Traefik bereit ist.

```yaml
name: zitadel

services:
  proxy:
    image: ${TRAEFIK_IMAGE:-traefik:v3.7.13}
    restart: unless-stopped
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=zitadel
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --api.dashboard=false
      - --api.insecure=false
      - --ping=true
      - --ping.entrypoint=web
      - --log.level=INFO
      - --accesslog=true
    ports:
      - "127.0.0.1:${PROXY_HTTP_PUBLISHED_PORT:-8080}:80"
    networks:
      - zitadel
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    depends_on:
      zitadel-api:
        condition: service_healthy
      zitadel-login:
        condition: service_healthy

  zitadel-api:
    image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.20.0}
    restart: unless-stopped
    user: "0"
    command: start-from-init --masterkey "${ZITADEL_MASTERKEY}"
    environment:
      ZITADEL_PORT: 8080
      ZITADEL_EXTERNALDOMAIN: ${ZITADEL_DOMAIN:-localhost}
      ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNALPORT:-8080}
      ZITADEL_EXTERNALSECURE: ${ZITADEL_EXTERNALSECURE:-false}
      ZITADEL_TLS_ENABLED: "false"
      ZITADEL_DATABASE_POSTGRES_DSN: ${ZITADEL_DATABASE_POSTGRES_DSN}
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false"
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD}
      ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel/bootstrap/login-client.pat
      ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client
      ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: "Automatically Initialized IAM_LOGIN_CLIENT"
      ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: ${LOGIN_CLIENT_PAT_EXPIRATION:-2099-01-01T00:00:00Z}
      ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "true"
      ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: "${ZITADEL_PUBLIC_SCHEME:-http}://${ZITADEL_DOMAIN:-localhost}:${ZITADEL_EXTERNALPORT:-8080}/ui/v2/login/"
      ZITADEL_OIDC_DEFAULTLOGINURLV2: "${ZITADEL_PUBLIC_SCHEME:-http}://${ZITADEL_DOMAIN:-localhost}:${ZITADEL_EXTERNALPORT:-8080}/ui/v2/login/login?authRequest="
      ZITADEL_OIDC_DEFAULTLOGOUTURLV2: "${ZITADEL_PUBLIC_SCHEME:-http}://${ZITADEL_DOMAIN:-localhost}:${ZITADEL_EXTERNALPORT:-8080}/ui/v2/login/logout?post_logout_redirect="
      ZITADEL_SAML_DEFAULTLOGINURLV2: "${ZITADEL_PUBLIC_SCHEME:-http}://${ZITADEL_DOMAIN:-localhost}:${ZITADEL_EXTERNALPORT:-8080}/ui/v2/login/login?samlRequest="
      ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED: "true"
      ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED: "false"
    healthcheck:
      test:
        - CMD
        - /app/zitadel
        - ready
      interval: 10s
      timeout: 30s
      retries: 12
      start_period: 20s
    volumes:
      - zitadel-bootstrap:/zitadel/bootstrap:rw
    networks:
      - zitadel
    depends_on:
      postgres:
        condition: service_healthy
    labels:
      - traefik.enable=true
      - traefik.docker.network=zitadel
      - traefik.http.services.zitadel-api.loadbalancer.server.port=8080
      - traefik.http.services.zitadel-api.loadbalancer.server.scheme=h2c
      - traefik.http.middlewares.zitadel-strip-api.stripprefix.prefixes=/api
      - traefik.http.middlewares.zitadel-strip-api.stripprefix.forceSlash=false
      - "traefik.http.routers.zitadel-api-alias-web.rule=Host(`${ZITADEL_DOMAIN:-localhost}`) && PathPrefix(`/api`)"
      - traefik.http.routers.zitadel-api-alias-web.entrypoints=web
      - traefik.http.routers.zitadel-api-alias-web.middlewares=zitadel-strip-api
      - traefik.http.routers.zitadel-api-alias-web.service=zitadel-api
      - traefik.http.routers.zitadel-api-alias-web.priority=200
      - "traefik.http.routers.zitadel-canonical-web.rule=Host(`${ZITADEL_DOMAIN:-localhost}`) && !PathPrefix(`/ui/v2/login`) && !PathPrefix(`/api`) && !Path(`/`)"
      - traefik.http.routers.zitadel-canonical-web.entrypoints=web
      - traefik.http.routers.zitadel-canonical-web.service=zitadel-api
      - traefik.http.routers.zitadel-canonical-web.priority=100

  zitadel-login:
    image: ghcr.io/zitadel/zitadel-login:${ZITADEL_VERSION:-v4.20.0}
    restart: unless-stopped
    user: "0"
    environment:
      ZITADEL_API_URL: http://zitadel-api:8080
      NEXT_PUBLIC_BASE_PATH: /ui/v2/login
      ZITADEL_SERVICE_USER_TOKEN_FILE: /zitadel/bootstrap/login-client.pat
      ZITADEL_SESSION_COOKIE_SECRET: ${LOGIN_SESSION_COOKIE_SECRET}
      CUSTOM_REQUEST_HEADERS: "Host:${ZITADEL_DOMAIN:-localhost},X-Forwarded-Proto:${ZITADEL_PUBLIC_SCHEME:-http}"
    healthcheck:
      test:
        - CMD
        - /bin/sh
        - -c
        - "node /app/healthcheck.mjs http://localhost:3000/ui/v2/login/healthy"
      interval: 10s
      timeout: 30s
      retries: 12
      start_period: 20s
    volumes:
      - zitadel-bootstrap:/zitadel/bootstrap:ro
    networks:
      - zitadel
    depends_on:
      zitadel-api:
        condition: service_healthy
    labels:
      - traefik.enable=true
      - traefik.docker.network=zitadel
      - traefik.http.services.zitadel-login.loadbalancer.server.port=3000
      - traefik.http.middlewares.zitadel-root-rewrite.replacepath.path=/ui/v2/login/
      - "traefik.http.routers.zitadel-root-web.rule=Host(`${ZITADEL_DOMAIN:-localhost}`) && Path(`/`)"
      - traefik.http.routers.zitadel-root-web.entrypoints=web
      - traefik.http.routers.zitadel-root-web.middlewares=zitadel-root-rewrite
      - traefik.http.routers.zitadel-root-web.service=zitadel-login
      - traefik.http.routers.zitadel-root-web.priority=400
      - "traefik.http.routers.zitadel-login-web.rule=Host(`${ZITADEL_DOMAIN:-localhost}`) && PathPrefix(`/ui/v2/login`)"
      - traefik.http.routers.zitadel-login-web.entrypoints=web
      - traefik.http.routers.zitadel-login-web.service=zitadel-login
      - traefik.http.routers.zitadel-login-web.priority=250

  postgres:
    image: ${POSTGRES_IMAGE:-postgres:17.11-alpine}
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
      POSTGRES_USER: ${POSTGRES_ADMIN_USER:-postgres}
      POSTGRES_DB: ${POSTGRES_DB:-zitadel}
    healthcheck:
      test:
        - CMD-SHELL
        - "pg_isready -d ${POSTGRES_DB:-zitadel} -U ${POSTGRES_ADMIN_USER:-postgres}"
      interval: 10s
      timeout: 30s
      retries: 10
      start_period: 20s
    volumes:
      - postgres-data:/var/lib/postgresql/data:rw
    networks:
      - zitadel

networks:
  zitadel:
    name: zitadel

volumes:
  postgres-data:
  zitadel-bootstrap:
```

> Prüfen Sie die Syntax vor dem Start:
>
> ```bash
> $ docker compose --env-file .env config --quiet && echo "YAML ok"
> ```

Erwartete Ausgabe: `YAML ok` ohne Fehlermeldung.

## Schritt 5: Stack starten und Initialisierung abwarten

Starten Sie den Stack mit `--wait`, dann kehrt Docker Compose erst zurück, wenn alle Healthchecks grün sind. Der erste Start dauert 60 bis 120 Sekunden, weil `start-from-init` das Datenbankschema anlegt.

```bash
$ cd /opt/zitadel
$ docker compose --env-file .env up -d --wait
```

Bei Problemen verfolgen Sie den Start mit `docker compose logs -f zitadel-api`.

> **Prüfen:** ```bash
> $ docker compose ps
> ```

Alle vier Dienste zeigen `running (healthy)`, gekürzte Ausgabe:

Ausgabe:

```plaintext
NAME                    IMAGE                                        STATUS
zitadel-proxy-1           traefik:v3.7.13                        running (healthy)
zitadel-zitadel-api-1     ghcr.io/zitadel/zitadel:v4.20.0        running (healthy)
zitadel-zitadel-login-1   ghcr.io/zitadel/zitadel-login:v4.20.0  running (healthy)
zitadel-postgres-1        postgres:17.11-alpine                  running (healthy)
```

Prüfen Sie zusätzlich, ob Traefik antwortet:

```bash
$ curl -I http://localhost:8080/
```

Erwartete Antwort: HTTP 200 oder 302 (Weiterleitung zum Login).

## Schritt 6: Admin-Zugangsdaten auslesen und erste Anmeldung

Zitadel legt beim ersten Start eine Organisation und den Admin `zitadel-admin@zitadel.localhost` an. Das Passwort steht nicht in den Logs, es ist der Wert aus `ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD`. Ohne diese Variable gilt der dokumentierte Standard `Password1!`, den Sie dann sofort ändern. Öffnen Sie die Konsole:

```bash
http://localhost:8080/ui/console?login_hint=zitadel-admin@zitadel.localhost
```

Richten Sie nach der Anmeldung für den Admin direkt MFA ein. Von einem anderen Rechner erreichen Sie die lokal gebundene Konsole per SSH-Tunnel: `ssh -L 8080:127.0.0.1:8080 user@server`.

> Prüfen Sie den OIDC-Discovery-Endpunkt:
>
> ```bash
> $ curl -s http://localhost:8080/.well-known/openid-configuration | python3 -m json.tool | head -10
> ```

Die Ausgabe zeigt das OIDC-Dokument. Der Wert `issuer` lautet `http://localhost:8080`: Domain und Port stimmen also.

## Schritt 7: HTTPS und Produktionsbetrieb vorbereiten

Für den Produktivbetrieb ist HTTPS Pflicht. Terminiert ein Proxy HTTPS, während `ZITADEL_EXTERNALSECURE=false` gesetzt ist, erzeugt Zitadel HTTP-Issuer-URLs und OIDC-Clients lehnen die Tokens ab. Passen Sie die `.env` an:

Datei: `.env`

```ini
ZITADEL_DOMAIN=auth.{{domain}}
ZITADEL_EXTERNALPORT=443
ZITADEL_EXTERNALSECURE=true
ZITADEL_PUBLIC_SCHEME=https
LETSENCRYPT_EMAIL=admin@{{domain}}
```

Zitadel stellt für Let's Encrypt ein Overlay bereit, das Traefik auf den Ports 80 und 443 veröffentlicht und das Zertifikat per HTTP-Challenge bezieht. Laden Sie es passend zur Version und starten Sie den Stack damit:

```bash
$ curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/v4.20.0/deploy/compose/docker-compose.mode-letsencrypt.yml
$ docker compose --env-file .env -f compose.yaml -f docker-compose.mode-letsencrypt.yml up -d --wait
```

Die Ports-Liste des Overlays ersetzt die lokale Bindung aus `compose.yaml`; Traefik ist danach nur auf 80 und 443 erreichbar.

Alternativ betreiben Sie Zitadel hinter einem vorhandenen Traefik, siehe [Traefik als Docker-Reverse-Proxy mit automatischem HTTPS einrichten](https://s-edv.com/anleitungen/traefik-docker-reverse-proxy-https-einrichten).

> **Prüfen:** Nach dem Start mit TLS:
>
> ```bash
> $ curl -I https://auth.{{domain}}/.well-known/openid-configuration
> ```

Erwartete Antwort: HTTP 200, der `issuer` im JSON lautet `https://auth.{{domain}}`.

## Schritt 8: Update-Prozess und Backup

Alle Daten liegen in PostgreSQL. Sichern Sie die Datenbank und separat den Masterkey; ohne ihn ist ein Backup wertlos. Erstellen Sie vor jedem Update ein Backup.

**Update:** Lesen Sie die Release Notes, tragen Sie die neue Version in der `.env` ein und führen Sie aus:

```bash
$ docker compose --env-file .env pull
$ docker compose --env-file .env up -d --wait
```

Zitadel führt Datenbankmigrationen beim Start über `start-from-init` automatisch aus.

**Hinweise für das Update von 4.19.3 auf 4.20.0:** Laut Release Notes erzwingen alle Listen-Endpunkte ab 4.19.5 Standard- und Höchstwerte für die Abfragegröße (Advisory A10018), und Geräte-Anmeldungen (Device Flow) verlangen eine Bestätigung auf einer Zustimmungsseite. Die Login-UI v2 signiert ab 4.19.5 auch E-Mail- und Einladungsbestätigung, Passkey-Registrierung und IdP-Verknüpfung mit `ZITADEL_SESSION_COOKIE_SECRET`, ab 4.20.0 zusätzlich die Device-Code-Prüfung. Steht in Ihrer `.env` noch ein Beispielwert wie `SessionCookieSecretNeedsAtLeast32Chars`, ersetzen Sie ihn durch einen Zufallswert aus Schritt 3. Wer während des Updates mitten in einem dieser Schritte steckt, muss ihn wiederholen. 4.20.0 ist mit Go 1.26 gebaut: Ausgehende TLS-Verbindungen (SMTP, externe IdPs, Actions, Webhooks, SMS-Anbieter) lehnen Zertifikatsketten über die kompromittierten eMudhra-Zwischenzertifizierungsstellen ab, betroffene Gegenstellen brauchen ein neues Zertifikat. Bricht nach dem Update eine konfigurierte URL oder ausgehende Verbindung ab, stellen `GODEBUG=urlstrictcolons=0` oder `GODEBUG=tlssecpmlkem=0` das alte Verhalten wieder her. Das Update von 4.19.3 auf 4.20.0 lief mit der Compose-Datei aus Schritt 4 und den beiden Befehlen oben: Die Migrationen endeten mit `setup completed`, alle Container waren `healthy`, der Issuer unter `/.well-known/openid-configuration` blieb gleich, die Anmeldung des Admins mit dem bestehenden Passwort über die Session-API gelang, ein falsches Passwort wurde mit `Password is invalid` abgewiesen, und die Ereignisse aus der alten Version blieben in der Datenbank.

**Datenbank-Backup:**

```bash
sudo mkdir -p /opt/backups
docker compose exec -T postgres pg_dump -U postgres zitadel \
  | gzip | sudo tee /opt/backups/zitadel_$(date +%Y%m%d_%H%M).sql.gz >/dev/null
```

Eine vollständige Backup-Automatisierung mit cron und Cloud-Sync zeigt die Anleitung [MySQL & PostgreSQL Backup automatisieren mit cron](https://s-edv.com/anleitungen/mysql-postgresql-backup-automatisieren-cron-cloud).

> **Prüfen:** `gunzip -t` auf die Backup-Datei meldet keinen Fehler. Nach einem Update:
>
> ```bash
> $ docker compose ps
> $ docker compose logs zitadel-api 2>&1 | tail -20
> ```

Alle Container zeigen `running (healthy)`, die Logs enthalten keine Migrationsfehler.

## Troubleshooting / Typische Fehler

**„masterkey must be 32 characters long“**

beim Start: Der `ZITADEL_MASTERKEY` in der `.env` ist nicht exakt 32 Zeichen lang. Prüfen mit dem Befehl aus Schritt 3, Wert korrigieren. Vor dem ersten erfolgreichen Start ist das folgenlos.

**zitadel-login startet nicht**

`login-client.pat` fehlt noch im Bootstrap-Volume. Prüfen mit `docker compose logs zitadel-api | grep -i pat`, dann `docker compose up -d --wait` erneut ausführen.

**502 Bad Gateway von Traefik**

Das Label `loadbalancer.server.scheme=h2c` fehlt. Label wiederherstellen und Stack neu starten.

**Port-Konflikt auf 8080**

Ein anderer Dienst belegt den Port. `PROXY_HTTP_PUBLISHED_PORT` und `ZITADEL_EXTERNALPORT` vor dem ersten Start auf denselben freien Port setzen, z. B. 8090.

**Anmeldung mit Admin-Passwort scheitert**

`ZITADEL_FIRSTINSTANCE_*`-Werte gelten nur beim ersten Start. Nachträgliche Änderungen in der `.env` wirken nicht; Passwort in der Konsole ändern.

**Login-UI meldet Fehler zum Session-Cookie**

`LOGIN_SESSION_COOKIE_SECRET` fehlt oder ist kürzer als 32 Zeichen.

**OIDC-Issuer-URL falsch (http statt https)**

`ZITADEL_EXTERNALSECURE=true` und `ZITADEL_PUBLIC_SCHEME=https` setzen, siehe Schritt 7.

**Masterkey nach erstem Start geändert**

Die verschlüsselten Daten sind nicht mehr lesbar. Ursprünglichen Masterkey aus dem Passwortmanager wieder eintragen.

## Häufige Fragen

### Wie lautet die Admin-Konsolen-URL nach dem ersten Start?

Mit den Standardwerten `http://localhost:8080/ui/console`, nach der TLS-Umstellung `https://auth.{{domain}}/ui/console`. Der Admin heißt `zitadel-admin@zitadel.localhost`, das Passwort stammt aus der `.env`.

### Was bedeutet natives Multi-Tenancy bei Zitadel?

Eine Installation betreibt mehrere *Instanzen* mit eigener Domain, jede mit mehreren *Organisationen*. Jeder Kunde erhält so eine isolierte Identity-Umgebung ohne eigene Installation.

### Wie unterscheidet sich Zitadel von Authentik oder Keycloak?

Keycloak basiert auf Java und braucht mehr Speicher; Authentik ist in Python geschrieben und stärker auf Forward-Auth ausgerichtet. Einen SSO-Vergleich liefert die Anleitung [Single Sign-On: Authentik vs. Authelia mit Traefik Forward-Auth absichern](https://s-edv.com/anleitungen/sso-authentik-authelia-traefik-forward-auth).

### Kann ich Zitadel ohne Traefik betreiben?

Ja, aber der offizielle Stack steuert das Routing über Traefik-Labels. Mit Nginx oder Caddy konfigurieren Sie die Pfade `/api`, `/ui/v2/login` und `/` selbst und sprechen `zitadel-api` per h2c (HTTP/2 ohne TLS) an.

### Was passiert mit dem Bootstrap-Volume beim Neustart?

Es bleibt erhalten, Zitadel überspringt die Initialisierung. Erst `docker compose down -v` löscht Volume und Datenbank und damit alle Daten.

### Unterstützt Zitadel Passkeys und MFA?

Ja. Zitadel unterstützt Passkeys (WebAuthn/FIDO2), TOTP, SMS, E-Mail-Codes und Social Logins ohne Zusatzsoftware. Sie konfigurieren das in den Login-Einstellungen der Instanz in der Konsole.

## Fazit

Der Stack läuft in etwa 20 Minuten. Für den Produktivbetrieb zählen drei Punkte: sicher verwahrter Masterkey, eigenes Admin-Passwort mit MFA und HTTPS mit `ZITADEL_EXTERNALSECURE=true`.

## Weiterführende Anleitungen und Quellen

1. [Docker Compose absichern](https://s-edv.com/anleitungen/docker-compose-absichern-secrets-healthchecks-non-root)
2. [Docker und Docker Compose auf Linux installieren, die Self-Hosting-Grundlage](https://s-edv.com/anleitungen/docker-compose-linux-selfhosting-grundlage)
3. [Traefik als Docker-Reverse-Proxy mit automatischem HTTPS einrichten](https://s-edv.com/anleitungen/traefik-docker-reverse-proxy-https-einrichten)
4. [Single Sign-On: Authentik vs. Authelia mit Traefik Forward-Auth absichern](https://s-edv.com/anleitungen/sso-authentik-authelia-traefik-forward-auth)
5. [MySQL & PostgreSQL Backup automatisieren mit cron und rclone-Cloud-Sync](https://s-edv.com/anleitungen/mysql-postgresql-backup-automatisieren-cron-cloud)
6. [Casdoor mit Docker installieren: Zentraler Auth-Server](https://s-edv.com/anleitungen/casdoor-docker-auth-server)
7. [HashiCorp Vault mit Docker installieren](https://s-edv.com/anleitungen/vault-docker-secrets)

Offizielle Quellen: [Zitadel Docs, Docker Compose Deployment](https://zitadel.com/docs/self-hosting/deploy/compose) | [Zitadel GitHub Repository](https://github.com/zitadel/zitadel) | [Zitadel Production Guide](https://zitadel.com/docs/self-hosting/manage/production)

## Passende Anleitungen auf S-EDV

1. [GPT-5.5-Cyber: OpenAI startet Sicherheitsmodell als Konkurrenz zu Anthropic Myth](https://s-edv.com/news/gpt-5-5-cyber-openai-sicherheitsmodell-juni-2026)
2. [PCI DSS 4.0.1: Checkout-Skripte als Sicherheitsrisiko: was Admins jetzt prüfen](https://s-edv.com/news/pci-dss-checkout-skripte-admin-pflicht)
3. [PostgreSQL schließt elf Sicherheitslücken in den Versionen 14 bis 18](https://s-edv.com/news/postgresql-11-sicherheitsluecken-versionen-14-18-mai-2026)
