Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 165 additions & 41 deletions posts/2026-02-08-how-to-run-zitadel-in-docker-for-iam/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
```
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"status": "validated",
"validatedAt": "2026-06-04"
"validatedAt": "2026-06-25"
}
Loading