From 6dcbd9589a1e898415c5d1dd903f5709b7edcc6f Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 15:05:42 +0200 Subject: [PATCH] Serve the Zitadel login from the Login V2 container Zitadel v4 redirects every sign-in to /ui/v2/login, which is served by a separate container (zitadel-login) that this stack didn't run, so logging in on a fresh instance ended in a 404. - Add zitadel-login, with its own service account (login-client) whose PAT Zitadel writes to a volume only that container mounts. - Configure the Login V2 base URL (ZITADEL_LOGIN_BASE_URL): its own port in dev, same hostname as Zitadel behind the edge in prod. - Pin zitadel and zitadel-login to v4.19.1 and add healthchecks. - Run Zitadel as root, as Zitadel's own compose does, so it can write the PATs into root-owned mounts (the Linux bind mount and the new volume). - Route /ui/v2/login on the auth vhost to the login container and drop the no-op grpc_set_header. - Document the extra steps for an instance created before this. --- .env.example | 5 ++++ .env.prod.example | 3 +++ README.md | 36 +++++++++++++++++++++++++-- compose.dev.yml | 6 +++++ compose.yml | 54 ++++++++++++++++++++++++++++++++++++++++- edge/conf.d/musify.conf | 8 +++++- 6 files changed, 108 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index f059b1c..88ab36e 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters ZITADEL_EXTERNAL_DOMAIN=host.docker.internal ZITADEL_EXTERNAL_PORT=8080 ZITADEL_EXTERNAL_SECURE=false +ZITADEL_PUBLIC_SCHEME=http +# Login V2 UI (zitadel-login). In dev it has its own port; the URL has no +# trailing slash. Only read when the Zitadel instance is created. +ZITADEL_LOGIN_HOST_PORT=3100 +ZITADEL_LOGIN_BASE_URL=http://host.docker.internal:3100/ui/v2/login ZITADEL_DATABASE_PASSWORD=zitadel # Console login: @zitadel. ZITADEL_ADMIN_USERNAME=admin diff --git a/.env.prod.example b/.env.prod.example index 41c54cf..5de0990 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -20,6 +20,9 @@ ZITADEL_EXTERNAL_DOMAIN=auth.example.com ZITADEL_EXTERNAL_PORT=443 ZITADEL_EXTERNAL_SECURE=true ZITADEL_TLS_MODE=external +ZITADEL_PUBLIC_SCHEME=https +# Login V2 UI, routed by the edge under the same hostname. No trailing slash. +ZITADEL_LOGIN_BASE_URL=https://auth.example.com/ui/v2/login ZITADEL_DATABASE_PASSWORD=CHANGE_ME ZITADEL_ORG_NAME=Iker Demo ZITADEL_ADMIN_USERNAME=admin@ikerdemo.cat diff --git a/README.md b/README.md index 1172a40..5c19ffa 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ cp .env.example .env docker compose -f compose.yml -f compose.dev.yml up -d ``` -That starts Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger, with their +That starts Postgres, Zitadel (and its login UI), SeaweedFS, RabbitMQ and Jaeger, with their ports published to `localhost`. Add `--profile tools` to also start pgAdmin. In production, the base `compose.yml` file **is** the production shape @@ -60,6 +60,7 @@ external network to reach `postgres`, `zitadel`, `seaweedfs`, `rabbitmq` and |---|---|---| | PostgreSQL | `5432` | one instance, one database per project | | Zitadel | `8080` | OIDC/OAuth2, console at `/ui/console` | +| Zitadel login | `3100` | Login V2 UI at `/ui/v2/login` (under `8080`'s hostname in prod) | | SeaweedFS | `8333` / `8888` / `9333` | S3 API / filer / master | | RabbitMQ | `5672` / `15672` | AMQP / management UI | | Jaeger | `16686` / `4317` / `4318` | UI / OTLP gRPC / OTLP HTTP | @@ -95,6 +96,37 @@ with an example value that assumes these repos happen to sit as sibling folders. If yours don't, change it. Not versioned, and regenerated whenever the Zitadel volume is reset. +### The Zitadel login (Login V2) + +Zitadel v4 serves its sign-in pages from a separate container, +`zitadel-login` (Next.js), under `/ui/v2/login`. Zitadel redirects every +login there, so it has to be running for anyone to sign in. It calls the +Zitadel API with its own service account (`login-client`), whose PAT Zitadel +writes on init to the `zitadel_bootstrap` volume, which only `zitadel-login` +mounts. + +- **Dev**: no proxy, so the login has its own port (`ZITADEL_LOGIN_HOST_PORT`, + `3100`) and `ZITADEL_LOGIN_BASE_URL` points the browser at it. +- **Prod**: the edge routes `/ui/v2/login` on Zitadel's hostname to + `infra-zitadel-login`, and everything else to Zitadel. +- `zitadel` and `zitadel-login` are pinned to the same version; bump both + together. + +`ZITADEL_LOGIN_BASE_URL` and the `login-client` PAT are only applied when the +instance is created. On an instance created before `zitadel-login` existed: + +1. In the console, create a service user `login-client`, give it the + instance role **IAM Login Client**, and create a PAT for it. +2. Copy it into the volume and restart the login: + + ```bash + docker cp login-client.pat infra-zitadel:/bootstrap/login-client.pat + docker restart infra-zitadel-login + ``` + +3. If the instance still uses the old login, set Login V2 as required with + base URI `${ZITADEL_LOGIN_BASE_URL}/` in the instance's feature settings. + ## The public edge `edge/compose.yml` is the only thing that publishes host ports 80/443: one @@ -138,7 +170,7 @@ re-creates its piece on the next `up`, since both are idempotent. ``` Infrastructure/ -├─ compose.yml # postgres, zitadel, seaweedfs, rabbitmq, jaeger: the prod shape +├─ compose.yml # postgres, zitadel (+ login), seaweedfs, rabbitmq, jaeger: the prod shape ├─ edge/ # shared public nginx + certbot (prod only): compose.yml, conf.d/, snippets/ ├─ compose.dev.yml # dev overlay: publishes ports, adds pgAdmin ├─ .env.example / .env.prod.example diff --git a/compose.dev.yml b/compose.dev.yml index 77d2974..b235237 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -15,6 +15,12 @@ services: ports: - "${ZITADEL_EXTERNAL_PORT}:8080" + # No edge in dev to put the login under Zitadel's own port, so it gets its + # own (ZITADEL_LOGIN_BASE_URL in .env points the browser at it). + zitadel-login: + ports: + - "${ZITADEL_LOGIN_HOST_PORT}:3000" + seaweedfs: ports: - "9333:9333" # master / admin UI diff --git a/compose.yml b/compose.yml index efd219e..6fa4981 100644 --- a/compose.yml +++ b/compose.yml @@ -45,8 +45,13 @@ services: # setup (both idempotent) and then starts, so the same command works on a # cold and on a warm volume. zitadel: - image: ghcr.io/zitadel/zitadel:latest + # Pinned together with zitadel-login below: bump both at once. + image: ghcr.io/zitadel/zitadel:v4.19.1 container_name: infra-zitadel + # The image runs as uid 1000, but Docker creates both the bind mount + # (on Linux) and the bootstrap volume as root, so Zitadel couldn't write + # the PATs into them. Same as Zitadel's own compose. + user: "0" # `disabled` (dev, plain http) makes Zitadel advertise http URLs; behind a # TLS-terminating proxy it must be `external` or the console calls # http:// and the CSP blocks it. @@ -86,11 +91,57 @@ services: ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin Service Account ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z" ZITADEL_FIRSTINSTANCE_PATPATH: /output/admin-sa.pat + # Service account the login UI (zitadel-login) calls the API with. Its + # PAT goes to a volume only that container mounts, not to /output. + 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: "2099-01-01T00:00:00Z" + ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /bootstrap/login-client.pat + # Login V2: sign-in pages are served by zitadel-login, not by Zitadel + # itself. Like the FIRSTINSTANCE values, these are only read when the + # instance is created (see README for an existing instance). + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "true" + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: ${ZITADEL_LOGIN_BASE_URL}/ + ZITADEL_OIDC_DEFAULTLOGINURLV2: ${ZITADEL_LOGIN_BASE_URL}/login?authRequest= + ZITADEL_OIDC_DEFAULTLOGOUTURLV2: ${ZITADEL_LOGIN_BASE_URL}/logout?post_logout_redirect= + ZITADEL_SAML_DEFAULTLOGINURLV2: ${ZITADEL_LOGIN_BASE_URL}/login?samlRequest= depends_on: postgres: condition: service_healthy volumes: - ./zitadel/.output:/output + - zitadel_bootstrap:/bootstrap + healthcheck: + test: ["CMD", "/app/zitadel", "ready"] + interval: 10s + timeout: 30s + retries: 12 + start_period: 20s + networks: [infra-net] + restart: unless-stopped + + # Login V2 UI (Next.js), served under /ui/v2/login. It calls the API + # directly on infra-net, so it has to present the public Host/scheme for + # Zitadel to find the instance. + zitadel-login: + image: ghcr.io/zitadel/zitadel-login:v4.19.1 + container_name: infra-zitadel-login + environment: + ZITADEL_API_URL: http://zitadel:8080 + NEXT_PUBLIC_BASE_PATH: /ui/v2/login + ZITADEL_SERVICE_USER_TOKEN_FILE: /bootstrap/login-client.pat + CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_EXTERNAL_DOMAIN},X-Forwarded-Proto:${ZITADEL_PUBLIC_SCHEME} + 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 + depends_on: + zitadel: + condition: service_healthy + volumes: + - zitadel_bootstrap:/bootstrap:ro networks: [infra-net] restart: unless-stopped @@ -166,5 +217,6 @@ services: volumes: postgres_data: + zitadel_bootstrap: seaweedfs_data: rabbitmq_data: diff --git a/edge/conf.d/musify.conf b/edge/conf.d/musify.conf index b8d14b2..497669f 100644 --- a/edge/conf.d/musify.conf +++ b/edge/conf.d/musify.conf @@ -80,11 +80,17 @@ server { ssl_certificate_key /etc/letsencrypt/live/musify.ikerdemo.cat/privkey.pem; include /etc/nginx/snippets/tls.conf; + # Login V2 UI, a separate container (see ../compose.yml). + location /ui/v2/login { + set $upstream http://infra-zitadel-login:3000; + proxy_pass $upstream; + include /etc/nginx/snippets/proxy.conf; + } + location / { set $upstream http://zitadel:8080; proxy_pass $upstream; include /etc/nginx/snippets/proxy.conf; - grpc_set_header Host $host; } }