diff --git a/posts/2026-02-08-how-to-run-zitadel-in-docker-for-iam/README.md b/posts/2026-02-08-how-to-run-zitadel-in-docker-for-iam/README.md index cef04df2b0..2dba9230e6 100644 --- a/posts/2026-02-08-how-to-run-zitadel-in-docker-for-iam/README.md +++ b/posts/2026-02-08-how-to-run-zitadel-in-docker-for-iam/README.md @@ -10,7 +10,7 @@ Description: Deploy Zitadel in Docker as a cloud-native identity and access mana Zitadel is a cloud-native identity and access management (IAM) platform built in Go. It provides user management, authentication, authorization, and single sign-on capabilities. Zitadel follows a modern architecture with event sourcing, which means every state change is stored as an immutable event. This gives you a complete audit trail of all identity operations. Compared to Keycloak, Zitadel is lighter and easier to operate. Docker is the recommended way to run Zitadel, and the setup requires minimal configuration. -This guide covers deploying Zitadel in Docker, configuring it for application authentication, and integrating it with web applications. +This guide covers deploying Zitadel in Docker, configuring it for application authentication, and integrating it with web applications. It also covers a change introduced in Zitadel v4: the login UI now runs as its own `zitadel-login` container, separate from the main Zitadel image. ## Quick Start @@ -27,14 +27,15 @@ docker run -d \ -e POSTGRES_DB=postgres \ postgres:17-alpine -# Start Zitadel (evaluation only) +# Start Zitadel (evaluation only - single container with the bundled legacy login) docker run -d \ --name zitadel \ --network zitadel \ -p 8080:8080 \ -e ZITADEL_DATABASE_POSTGRES_DSN="postgresql://root:postgres@zitadel-db:5432/postgres?sslmode=disable" \ -e ZITADEL_EXTERNALSECURE=false \ - ghcr.io/zitadel/zitadel:latest start-from-init \ + -e ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED=false \ + ghcr.io/zitadel/zitadel:v4.15.3 start-from-init \ --masterkey "MasterkeyNeedsToHave32Characters" \ --tlsMode disabled ``` @@ -44,74 +45,182 @@ Wait about 30 seconds, then access http://localhost:8080/ui/console?login_hint=z - Username: `zitadel-admin@zitadel.localhost` - Password: `Password1!` +Two details in that command reflect how Zitadel has changed, and both matter: + +- The image is pinned to `v4.15.3` instead of `latest`. Zitadel v4 is a major release with breaking changes, and `latest` will eventually roll forward to v5 and beyond. Pin a tag so this setup keeps behaving the way the tutorial describes. +- `ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED=false` tells the instance to use the legacy login UI that is still bundled inside the main Zitadel container (served at `/ui/login`). Without it, this single container would have no login screen at all. The next section explains why. + +## Where Is the Login Service? Zitadel v4 and Login V2 + +If you have looked at Zitadel's official Docker Compose example, you may have noticed a second container named `zitadel-login` that the quick start above does not have. On current versions that is not a detail you can skip, so here is what changed. + +Through Zitadel v2 and v3, the login screens were rendered by the Go core itself and served from the same container at `/ui/login`. There was nothing else to run. Starting with Zitadel v4 (which went GA in mid-2025), the login experience was rewritten as **Login V2**: a standalone Next.js application that ships as its own container image, `ghcr.io/zitadel/zitadel-login`. It listens on port 3000, is served under the path `/ui/v2/login`, and talks back to the Zitadel core over the API. It authenticates as a machine user named `login-client` that holds the `IAM_LOGIN_CLIENT` role, using a personal access token (PAT) that the core generates automatically on first startup. + +The catch is the default. **New v4 instances are created with Login V2 marked as required**, which means the core no longer serves login pages itself - it redirects every sign-in to `/ui/v2/login`. If you run only the core container and nothing is serving that path, the interactive login breaks even though the API and the console assets are still up. That is exactly the gap a single-container setup leaves, and why the quick start above had to opt back into the bundled login. + +You have two ways to get a working login: + +1. **Run the separate `zitadel-login` container** alongside the core, behind a reverse proxy that serves both under one origin. This is the supported v4 architecture and what the production compose below sets up. +2. **Opt back into the bundled legacy login** by setting `ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED=false`, as the quick start does. The login screens are then served from the core at `/ui/login` again, which is convenient for local evaluation. Login V1 still ships in v4, but it is on a deprecation path for a future major release, so prefer Login V2 for anything long-lived. + ## Production Setup with PostgreSQL -For production, use PostgreSQL instead of a single-container test setup: +For production, run the full v4 architecture: PostgreSQL, the Zitadel core, the separate Login V2 container, and a reverse proxy that serves all of them under a single origin. Traefik is used here because Zitadel's own Compose example uses it, but any reverse proxy that can route by path will do: ```yaml -# docker-compose.yml - Zitadel with PostgreSQL -version: "3.8" +# docker-compose.yml - Zitadel v4 with PostgreSQL, the Login V2 container, and Traefik +name: zitadel services: - zitadel: - image: ghcr.io/zitadel/zitadel:latest - container_name: zitadel - command: start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled + # Reverse proxy: routes /ui/v2/login to the login container and everything + # else to the Zitadel core, all on one origin (http://localhost:8080). + proxy: + image: traefik:v3.6.8 + restart: unless-stopped + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=zitadel + - --entrypoints.web.address=:80 + - --ping=true + - --ping.entrypoint=web ports: - - "8080:8080" + - "8080:80" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - zitadel + depends_on: + zitadel: + condition: service_healthy + zitadel-login: + condition: service_healthy + + # Zitadel core: API and console. Serves everything except the Login V2 UI. + zitadel: + image: ghcr.io/zitadel/zitadel:v4.15.3 + restart: unless-stopped + user: "0" + command: start-from-init --masterkey "MasterkeyNeedsToHave32Characters" environment: - # Database configuration - ZITADEL_DATABASE_POSTGRES_HOST: postgres - ZITADEL_DATABASE_POSTGRES_PORT: 5432 - ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel - ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel - ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_password - ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres - ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres_password - ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable - # External domain configuration - ZITADEL_DOMAIN: localhost + ZITADEL_PORT: 8080 + ZITADEL_EXTERNALDOMAIN: localhost ZITADEL_EXTERNALPORT: 8080 ZITADEL_EXTERNALSECURE: "false" - # First instance settings - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: admin - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: "Admin123!" + ZITADEL_TLS_ENABLED: "false" + ZITADEL_DATABASE_POSTGRES_DSN: "postgresql://postgres:postgres@postgres:5432/zitadel?sslmode=disable" + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" + # Bootstrap the login-client machine user and write its PAT to a shared volume. + ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel/bootstrap/login-client.pat + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Login client + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z" + # Tell the core where the Login V2 UI lives so it can redirect sign-in there. + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "true" + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: "http://localhost:8080/ui/v2/login/" + ZITADEL_OIDC_DEFAULTLOGINURLV2: "http://localhost:8080/ui/v2/login/login?authRequest=" + ZITADEL_OIDC_DEFAULTLOGOUTURLV2: "http://localhost:8080/ui/v2/login/logout?post_logout_redirect=" + 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.loadbalancer.server.port=8080 + - traefik.http.services.zitadel.loadbalancer.server.scheme=h2c + - traefik.http.routers.zitadel.rule=Host(`localhost`) && !PathPrefix(`/ui/v2/login`) + - traefik.http.routers.zitadel.entrypoints=web + - traefik.http.routers.zitadel.service=zitadel + - traefik.http.routers.zitadel.priority=100 + + # Login V2: the standalone Next.js login UI, shipped as its own image. + zitadel-login: + image: ghcr.io/zitadel/zitadel-login:v4.15.3 restart: unless-stopped + user: "0" + environment: + ZITADEL_API_URL: http://zitadel:8080 + NEXT_PUBLIC_BASE_PATH: /ui/v2/login + # Reads the PAT the core wrote, to authenticate as the IAM_LOGIN_CLIENT user. + ZITADEL_SERVICE_USER_TOKEN_FILE: /zitadel/bootstrap/login-client.pat + CUSTOM_REQUEST_HEADERS: Host:localhost,X-Forwarded-Proto: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: + condition: service_healthy + labels: + - traefik.enable=true + - traefik.docker.network=zitadel + - traefik.http.services.zitadel-login.loadbalancer.server.port=3000 + - traefik.http.routers.zitadel-login.rule=Host(`localhost`) && PathPrefix(`/ui/v2/login`) + - traefik.http.routers.zitadel-login.entrypoints=web + - traefik.http.routers.zitadel-login.service=zitadel-login + - traefik.http.routers.zitadel-login.priority=250 postgres: - image: postgres:16-alpine - container_name: zitadel-db + image: postgres:17-alpine + restart: unless-stopped environment: POSTGRES_DB: zitadel POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres_password - volumes: - - postgres-data:/var/lib/postgresql/data + POSTGRES_PASSWORD: postgres healthcheck: - test: ["CMD", "pg_isready", "-U", "postgres"] - interval: 5s - timeout: 5s + test: ["CMD-SHELL", "pg_isready -d zitadel -U postgres"] + interval: 10s + timeout: 30s retries: 10 - restart: unless-stopped + start_period: 20s + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - zitadel + +networks: + zitadel: + name: zitadel volumes: postgres-data: + zitadel-bootstrap: ``` Start the stack: ```bash -# Launch Zitadel -docker compose up -d +# Launch the full stack and wait for every container to report healthy +docker compose up -d --wait -# Monitor startup - wait for "server is listening" -docker compose logs -f zitadel +# Monitor startup if you want to watch the logs +docker compose logs -f zitadel zitadel-login ``` +Once the containers are healthy, open http://localhost:8080/ui/console. Signing in redirects you to the Login V2 UI at `/ui/v2/login`, which the proxy routes to the `zitadel-login` container. Log in with the default admin account `zitadel-admin@zitadel.localhost` and password `Password1!`. + +A few things are worth understanding about how the pieces fit together: + +- On first startup the core creates the `login-client` machine user and writes its PAT to the shared `zitadel-bootstrap` volume. The `zitadel-login` container reads that same file to authenticate against the core's API. +- The `ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI` and `ZITADEL_OIDC_DEFAULTLOGINURLV2` values tell the core where to send users for login. They must match the public URL the proxy exposes (`http://localhost:8080/ui/v2/login`). +- Traefik routes `/ui/v2/login` to the login container on port 3000 and sends everything else to the core on port 8080 over h2c (the core speaks HTTP/2 cleartext for its gRPC and Connect APIs). + Use `start-from-init` only for the initial database setup. For upgrades on an existing production database, run the setup and runtime phases separately or use `start-from-setup`. ## Creating a Project and Application @@ -330,12 +439,27 @@ curl -X PUT http://localhost:8080/management/v1/policies/label \ ## Enabling TLS for Production -For production deployments, enable TLS: +In the multi-container layout above, terminate TLS at the reverse proxy and let it talk to the core over plain HTTP internally. Switch the public scheme to HTTPS so the core issues correct URLs, and point the login URLs at your real domain: + +```yaml +# On the zitadel (core) service +environment: + ZITADEL_EXTERNALSECURE: "true" + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: "https://auth.example.com/ui/v2/login/" + ZITADEL_OIDC_DEFAULTLOGINURLV2: "https://auth.example.com/ui/v2/login/login?authRequest=" + ZITADEL_OIDC_DEFAULTLOGOUTURLV2: "https://auth.example.com/ui/v2/login/logout?post_logout_redirect=" + +# On the zitadel-login service, advertise HTTPS to the core +# CUSTOM_REQUEST_HEADERS: Host:auth.example.com,X-Forwarded-Proto:https +``` + +Configure the certificates on Traefik (for example with a Let's Encrypt resolver on a `websecure` entrypoint). Zitadel's official Compose repository ships TLS overlays for exactly this. If you are not running a reverse proxy, the core can serve TLS itself instead: ```yaml -# Updated environment for TLS +# Serving TLS directly from the core (no proxy in front) environment: ZITADEL_EXTERNALSECURE: "true" + ZITADEL_TLS_ENABLED: "true" ZITADEL_TLS_CERTPATH: /certs/fullchain.pem ZITADEL_TLS_KEYPATH: /certs/privkey.pem volumes: diff --git a/posts/2026-02-08-how-to-run-zitadel-in-docker-for-iam/validation-summary.md b/posts/2026-02-08-how-to-run-zitadel-in-docker-for-iam/validation-summary.md index ed2e2db63c..4f9911ec9b 100644 --- a/posts/2026-02-08-how-to-run-zitadel-in-docker-for-iam/validation-summary.md +++ b/posts/2026-02-08-how-to-run-zitadel-in-docker-for-iam/validation-summary.md @@ -43,5 +43,21 @@ Tutorial / deployment guide - The service-account JWT example used a client ID for `iss` and `sub`. ZITADEL service-account private key JWT uses the service account `userId`, so I corrected the variable and claims. - The conclusion referred generically to the Management API after examples had been updated to current v2 APIs. I changed that wording to "Zitadel APIs." +## Re-review 2026-06-25 (issue #148: "where is the zitadel-login service") +A reader pointed out that the Docker setup has no login service. This is a real gap on current Zitadel: +- `ghcr.io/zitadel/zitadel:latest` now tracks the Zitadel v4 line (v4.15.3 at time of re-review). Zitadel v4 split the login UI into a separate Next.js application shipped as its own image, `ghcr.io/zitadel/zitadel-login` (Login V2), served at `/ui/v2/login` on port 3000. +- New v4 instances default to `LoginV2 Required=true`, so the core redirects sign-in to `/ui/v2/login`. A single-container deployment with no login service therefore has a broken interactive login (the API and console assets still work). +- Login V1 is still bundled in v4 and can be re-enabled with `ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED=false`. It is not removed/deprecated yet; removal is only planned for a future major release. + +Changes made: +- Pinned the image to `v4.15.3` in both the quick-start `docker run` and the production Compose (was `:latest`), so the tutorial does not silently break when `:latest` moves to a new major. +- Quick start now sets `ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED=false` so the single-container evaluation setup keeps a working login (bundled Login V1 at `/ui/login`). +- Added a "Where Is the Login Service?" section explaining the v2/v3 vs v4 login architecture. +- Rewrote the production Compose to the correct v4 architecture: PostgreSQL + Zitadel core + the separate `zitadel-login` container + a Traefik reverse proxy, with the `login-client` PAT bootstrap and shared volume. Modeled on Zitadel's official `deploy/compose/docker-compose.yml`. +- Updated the TLS section for the proxy-terminated layout and kept a direct-on-core TLS option. +- Standardized the PostgreSQL image to `postgres:17-alpine` (the production Compose previously used `postgres:16-alpine` while the quick start used 17). + +Sources re-checked: Zitadel `deploy/compose/docker-compose.yml` and `.env.example` (v4.15.3 tag), `apps/docs/.../login-client.mdx`, `cmd/defaults.yaml`, and the GHCR `zitadel-login` package tags. + ## Review Notes The post still uses local HTTP and disabled TLS for development examples. That is appropriate for a local tutorial, but production deployments should use TLS, strong generated secrets, and the official production checklist. diff --git a/posts/2026-02-08-how-to-run-zitadel-in-docker-for-iam/validation.json b/posts/2026-02-08-how-to-run-zitadel-in-docker-for-iam/validation.json index be4d676e12..617f738e1b 100644 --- a/posts/2026-02-08-how-to-run-zitadel-in-docker-for-iam/validation.json +++ b/posts/2026-02-08-how-to-run-zitadel-in-docker-for-iam/validation.json @@ -1,4 +1,4 @@ { "status": "validated", - "validatedAt": "2026-06-04" + "validatedAt": "2026-06-25" }