chore: improve docker compose template, tests and docs (#11593)

Replaces the single-file `docker-compose.yaml` quickstart with a
production-aware, Traefik-based compose pack in `deploy/compose/`. The
pack covers the full arc from a 2-minute localhost quickstart to a
hardened homelab or semi-production deployment.

### What's in the pack

**Stack**: Traefik (proxy) → ZITADEL API (Go `:8080`) + ZITADEL Login
(Next.js `:3000`) → PostgreSQL

All HTTP/gRPC routing is handled by Traefik via Docker labels — no
manual proxy config needed. The Login V2 UI is enabled by default. Login
URLs are derived automatically from `ZITADEL_DOMAIN`,
`ZITADEL_EXTERNALPORT`, and `ZITADEL_PUBLIC_SCHEME` — no separate URL
variables needed.

**Compose files**

| File | Purpose |
|------|---------|
| `docker-compose.yml` | Base stack — works standalone. Uses explicit
`name: zitadel` network for reliable Traefik service discovery. |
| `docker-compose.mode-letsencrypt.yml` | TLS overlay: ACME HTTP
challenge |
| `docker-compose.mode-external-tls.yml` | TLS overlay: upstream LB/CDN
terminates TLS. Uses `forwardedHeaders.trustedIPs` (configurable via
`TRAEFIK_TRUSTED_IPS`) instead of `insecure=true`. |
| `docker-compose.mode-local-tls.yml` | TLS overlay: self-signed certs
for LAN |
| `docker-compose.prodlike.yml` | Splits init / setup / start for
controlled upgrades |
| `docker-compose.test.yml` | CI overlay: swaps images to locally-built
`:local` tags |

**Optional profiles**: `cache` (Redis), `observability` (OpenTelemetry
Collector)

### Build infra

- New `@zitadel/api:pack` and `@zitadel/login:pack` Nx targets build
local Docker images (`zitadel/zitadel:local`,
`zitadel/zitadel-login:local`) for use in CI and local testing
- `apps/api/Dockerfile` now accepts a `BINARY` build arg so local and
release builds share the same image

### Testing

- New `@zitadel/compose` Nx project with targets: `test-config`
(validates all overlay combinations using `--quiet`), `test-run` (starts
full stack with local images), `test-e2e` (Playwright wiring + protocol
matrix tests through Traefik), `test-full` (end-to-end: build → start →
test → teardown), `stop`
- **`@zitadel/compose` is explicitly excluded from `nx affected` in CI
for now** — the full stack smoke test requires a Docker daemon and
significant resources. The intent is to add a dedicated
`compose_smoke_test` CI job in a follow-up. The targets can be run
locally with `pnpm nx run @zitadel/compose:test-full`.

### Documentation

- **`compose.mdx`**: Complete rewrite with a staged structure (Stage 1
Quickstart → Stage 2 Homelab → Stage 3 Beyond Compose). Documents TLS
modes, profiles, secrets hardening, ExternalDomain/Port/Secure
invariant, upgrades, and the path to Kubernetes
- **New `requirements.mdx`**: Lists supported PostgreSQL versions
(14–18), Redis (standalone), Docker Compose v2.x, and reverse proxy h2c
requirements
- **`reverse_proxy.mdx`**: Added intro covering h2c requirements, TLS
modes table, and Login UI routing split
- **`troubleshooting.mdx`**: New sections for container restarts on
upgrade, FIRSTINSTANCE env vars not taking effect, and diagnosing
unhealthy containers
- **`caddy/index.mdx`**: Known issue and workaround for the `TE:
trailers` header hang
- Removed the old
`apps/docs/content/self-hosting/deploy/docker-compose.yaml` embedded in
the docs

### Breaking change

The old `apps/docs/content/self-hosting/deploy/docker-compose.yaml` file
is deleted. The getting-started docs page
(`/self-hosting/deploy/compose`) now points to the new pack via a `curl
| tar` download command.

---

### Checklist

- [x] `deploy/compose/` smoke test passes end-to-end locally (`pnpm nx
run @zitadel/compose:test-full`)
- [x] Docs build passes (`pnpm nx run @zitadel/docs:build`)
- [ ] Follow-up issue created to add `compose_smoke_test` CI job

---------

Co-authored-by: Mridang Agarwalla <[email protected]>
This commit is contained in:
Florian Forster
2026-02-24 16:33:12 -08:00
committed by GitHub
co-authored by Mridang Agarwalla
parent 54095b64b1
commit bac224c56d
38 changed files with 1968 additions and 210 deletions
+2
View File
@@ -14,6 +14,7 @@ You are working in the ZITADEL monorepo. This codebase uses specific conventions
- `proto/AGENTS.md`: API schema and generation guidance.
- `packages/AGENTS.md`: Shared client/proto package workflows.
- `tests/functional-ui/AGENTS.md`: Cypress functional UI test workflows.
- `deploy/compose/AGENTS.md`: Docker Compose deployment invariants, file conventions, and rejected alternatives.
## Behavior
- Before Go-related work, inspect `go.mod` for required Go version/toolchain.
@@ -21,3 +22,4 @@ You are working in the ZITADEL monorepo. This codebase uses specific conventions
- For backend changes, note we are in transition: relational data is becoming the system of record, while event writes are still required for history/audit.
- Respect terminology defined in the `Technical Glossary` section of `AGENTS.md` when generating UI text or documentation.
- Distinguish between `apps/login` (Next.js) and `console` (Angular) when suggesting frontend code.
- For deployment-related work, consult the directory-specific agents files (e.g., `deploy/compose/AGENTS.md`) for invariants, file conventions, and rejected alternatives.
+2
View File
@@ -50,6 +50,8 @@ scopes:
# API layer
- grpc
- proto
# Deployment
- deploy
# Cross-cutting
- build
- ci
+19 -2
View File
@@ -76,6 +76,22 @@ If a translation is requested for a language not listed above, follow these prio
3. **Priority 3 (Tenant):** If 'Instance' is ambiguous, use the local word for 'Tenant' (e.g., 租户 in Chinese).
4. **Strict Ban:** NEVER use words that mean "an illustration", "a case", "a sample", or "an example."
### 5. Deployment Targets
ZITADEL supports multiple deployment methods. Each has its own directory and conventions:
| Target | Location | Status | Notes |
|--------|----------|--------|-------|
| Docker Compose | `deploy/compose/` | Supported | Single-node, graduated from quickstart to semi-production. See `deploy/compose/AGENTS.md` for directory-specific rules. |
| Kubernetes (Helm) | External ([zitadel-charts](https://github.com/zitadel/zitadel-charts)) | Supported | Official Helm chart for production workloads. Docs at `apps/docs/content/self-hosting/deploy/kubernetes/`. |
| apt/rpm packages | Planned | Not yet available | Future packaging target. |
When generating deployment-related content:
- Docker Compose is the recommended path for **getting started** and **homelab/single-node** deployments
- Kubernetes is the recommended path for **production** workloads
- Always reference the correct deployment method for the user's context
- The same `ZITADEL_*` environment variable model applies across all deployment methods
## Command Rules
Run commands from the repository root.
@@ -85,10 +101,11 @@ Run commands from the repository root.
- Known exception: `@zitadel/console` has no configured `test` target.
## Verified Common Targets
- `@zitadel/api`: `prod`, `build`, `generate`, `generate-install`, `lint`, `test`, `test-unit`, `test-integration`
- `@zitadel/login`: `dev`, `build`, `lint`, `test`, `test-unit`, `test-integration`
- `@zitadel/api`: `prod`, `build`, `build-linux`, `pack`, `generate`, `generate-install`, `lint`, `test`, `test-unit`, `test-integration`
- `@zitadel/login`: `dev`, `build`, `pack`, `lint`, `test`, `test-unit`, `test-integration`
- `@zitadel/docs`: `dev`, `build`, `generate`, `install-proto-plugins`, `check-links`, `check-types`, `test`, `lint`
- `@zitadel/console`: `dev`, `build`, `generate`, `install-proto-plugins`, `lint`
- `@zitadel/compose`: `test-config`, `test-run`, `test-e2e`, `test`, `test-full`, `stop`
## Proto Plugin Binaries
All proto plugins are installed to `.artifacts/bin/<GOOS>/<GOARCH>/` and Nx-cached. `generate` targets wire up the correct install dependency and prepend `.artifacts/bin/` to `$PATH` — no manual install step is needed.
+2
View File
@@ -17,6 +17,8 @@ The **API App** (`apps/api`) is the Nx application target for building and runni
- **Test (all)**: `pnpm nx run @zitadel/api:test`
- **Test (unit)**: `pnpm nx run @zitadel/api:test-unit`
- **Test (integration)**: `pnpm nx run @zitadel/api:test-integration`
- **Build Linux**: `pnpm nx run @zitadel/api:build-linux` — cross-compiles a Linux binary for Docker packaging
- **Pack (Docker)**: `pnpm nx run @zitadel/api:pack` — builds a local Docker image `zitadel/zitadel:local`. Requires Docker daemon.
## Generation Notes
- `@zitadel/api:generate` can update generated, tracked files (stubs/assets/statik). Run it intentionally.
+4 -1
View File
@@ -2,11 +2,14 @@
# This stage produces a runnable image that can be used for debugging.
FROM debian:latest AS builder
ARG TARGETPLATFORM
# BINARY allows callers to pass a differently-named local build artifact.
# Defaults to 'zitadel' for release builds; set to 'zitadel.local' for local dev builds.
ARG BINARY=zitadel
RUN apt-get update && apt-get install ca-certificates -y
COPY apps/api/entrypoint.sh /app/entrypoint.sh
COPY ./.artifacts/bin/${TARGETPLATFORM}/zitadel /app/zitadel
COPY ./.artifacts/bin/${TARGETPLATFORM}/${BINARY} /app/zitadel
RUN useradd -s "" --home / zitadel && \
chown zitadel /app/zitadel && \
+28
View File
@@ -83,6 +83,34 @@
"{workspaceRoot}/.artifacts/bin/*/*/zitadel.local"
]
},
"build-linux": {
"description": "Compiles the Go-based API backend into a Linux executable binary for Docker packaging.",
"dependsOn": [
"generate",
"build-console"
],
"command": "bash -c 'CGO_ENABLED=0 GOOS=linux go build -o .artifacts/bin/linux/$(go env GOARCH)/zitadel.local -ldflags=\"-s -w\"'",
"inputs": [
{
"runtime": "go env GOARCH"
},
"runtime",
{
"dependentTasksOutputFiles": "internal/api/ui/console/static/**/*"
}
],
"outputs": [
"{workspaceRoot}/.artifacts/bin/linux/*/zitadel.local"
]
},
"pack": {
"description": "Builds a local Docker image for the ZITADEL API (zitadel/zitadel:local). Requires Docker daemon.",
"dependsOn": [
"build-linux"
],
"command": "bash -c 'docker build --build-arg TARGETPLATFORM=linux/$(go env GOARCH) --build-arg BINARY=zitadel.local -f apps/api/Dockerfile -t zitadel/zitadel:local .'",
"cache": false
},
"generate": {
"description": "Generates the code needed to start a full-featured API: gRPC and OpenAPI stubs, static files for the embedded login v1, asset routes and documentation.",
"dependsOn": [
+175 -64
View File
@@ -1,90 +1,201 @@
---
title: Set up Zitadel with Docker Compose
description: "Deploy a ZITADEL instance locally using Docker Compose for rapid testing and development."
title: Set up ZITADEL with Docker Compose
sidebar_label: Docker Compose
description: Deploy ZITADEL with Docker Compose — from a 2-minute localhost quickstart to a hardened homelab setup with TLS, caching, and observability.
---
import NoteInstanceNotFound from './troubleshooting/_note_instance_not_found.mdx';
import DockerCompose from './docker-compose.yaml';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import Disclaimer from './_disclaimer.mdx'
import DefaultUser from './_defaultuser.mdx'
import Next from './_next.mdx'
This guide is the entrypoint for running the Zitadel platform locally for the first time.
It is for demonstration and development purposes only and does not set up a production-ready and security hardened instance of Zitadel.
Once Zitadel up is and running, learn more about a production setup in the [What's Next section](#next-steps).
This guide takes you from zero to a running ZITADEL instance in minutes and then shows you how to harden it for a homelab or semi-production deployment.
## Prerequisites
The setup is likely to work with other software versions too, but it is tested against the following environment:
- Docker Engine 24+ with the Compose plugin (`docker compose`)
- A machine with at least 2 GB RAM
- Ubuntu 24.04.1
- Docker 28.3.2
- Docker Compose v2.38.2
- curl 8.5.0
See [Requirements](/self-hosting/manage/requirements) for supported database, cache, and proxy versions.
## Docker compose
## Stage 1 — Quickstart (2 minutes)
The following commands set up services for a PostgreSQL database, a Zitadel API and a Zitadel login:
Download the two required files, copy the example config, and start:
1. Download the `docker-compose.yaml` file from the Zitadel repository:
```shell
curl -L https://raw.githubusercontent.com/zitadel/zitadel/main/apps/docs/content/self-hosting/deploy/docker-compose.yaml -o docker-compose.yaml
```
2. Make sure the containers use the latest image versions.
```shell
docker compose pull
```
3. Run the PostgreSQL database, the Zitadel API, and the Zitadel login.
```shell
docker compose up --detach --wait
```
4. Verify the containers are running and healthy.
```shell
docker compose ps
mkdir zitadel-compose && cd zitadel-compose
# Download the compose file and example environment
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.yml &&
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/.env.example
# Create your environment file and start
cp .env.example .env
docker compose up -d --wait
```
That's it. Visit [http://localhost:8080](http://localhost:8080) to open the login screen.
<DefaultUser components={props.components} />
## What's next
<Callout type="info">
The base stack runs: **Traefik** ([reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy)) → **ZITADEL API** (Go) + **ZITADEL Login** (Next.js) → **PostgreSQL**.
All routing, including [gRPC over HTTP/2](/self-hosting/manage/http2), is handled automatically by Traefik — no extra configuration needed.
</Callout>
Before proceeding, review the downloaded `docker-compose.yaml` file:
The comments give context to the used variables and show examples for commonly used configuration variants.
## Stage 2 — Homelab / Semi-Production
<details>
<summary>docker-compose.yaml</summary>
<DynamicCodeBlock lang="yaml" code={DockerCompose} />
</details>
### Use your own domain
Here are some natural steps forward to go from the current setup to production:
Edit `.env` and set your real domain (see [Custom Domains](/self-hosting/manage/custom-domain) for details):
- **Use a different master key**: Set up an instance from scratch and use a different master key. For example, generate one by running `tr -dc A-Za-z0-9 </dev/urandom | head -c 32`
- **Use files for secrets instead of environment variables, and don't commit them to Git**: Learn how to [configure the platform](/self-hosting/manage/configure)
- **Run the containers as non-root users**: Remove the `user: "0"` lines from the `docker-compose.yaml` file.
- **Configure a different external domain or IP**: Beware that the login only works with HTTPS on non-localhost domains. [Run and configure a reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy)
- **Serve the API and the UI together on the same port**: [Run and configure a reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy)
- **Encrypt Traffic**: [Run and configure a reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy)
```dotenv
ZITADEL_DOMAIN=auth.example.com
```
For more detailed recommendations, read the [Zitadel Production Setup Guide](/self-hosting/manage/production).
### Enable TLS
<details>
<summary>Read this if the environment uses the login v1</summary>
<p>
The login v2 is the next generation of the Zitadel login.
Its code and deployment are easily customizable.
Unlike the login v1, it runs in its own process.
The login v2 is enabled by default in new installations.
But if an existing `docker-compose.yaml` file is updated by following this guide,
an additional `zitadel-login` service is now running which is actually not doing anything, yet.
The following steps give guidance on how to enable the login v2 in an environment.
</p>
<ol>
<li>Read the login v2 related comments in the docker-compose.yaml.</li>
<li><a href="../../self-hosting/manage/login-client#create-login-client">Manually create a login client personal access token (PAT) for the now running v2 login</a>.</li>
<li>Move the PAT to `./login-client.pat`</li>
<li>Restart the login process: `docker compose restart login`</li>
<li><a href="../../self-hosting/manage/login-client#require-login-v2">Enable the Login UI for all users</a></li>
</ol>
</details>
Pick a TLS mode and download the matching overlay file:
| Mode | Overlay file | When to use |
|------|-------------|-------------|
| Let's Encrypt | `docker-compose.mode-letsencrypt.yml` | Public domain, automatic certs |
| External TLS | `docker-compose.mode-external-tls.yml` | Behind a load balancer, CDN, or WAF that terminates TLS |
| Local TLS | `docker-compose.mode-local-tls.yml` | Self-signed certs for LAN-only access |
Download the overlay you need, then start with both files:
```shell
# Download the overlay (replace with your chosen mode)
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.mode-letsencrypt.yml
# Start with the overlay
docker compose --env-file .env \
-f docker-compose.yml \
-f docker-compose.mode-letsencrypt.yml \
up -d --wait
```
Set `LETSENCRYPT_EMAIL` in `.env` to receive certificate expiry notifications.
### Harden secrets
<Callout type="warn">
The masterkey [encrypts sensitive data at rest](/concepts/architecture/secrets). Once ZITADEL has been initialized with a masterkey, it **cannot be changed** without losing access to encrypted data. Generate it **before first start** and store it safely.
</Callout>
```shell
# Generate a secure masterkey (must be exactly 32 characters)
ZITADEL_MASTERKEY=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)
echo "ZITADEL_MASTERKEY=$ZITADEL_MASTERKEY" >> .env
# Set strong database passwords
echo "POSTGRES_ADMIN_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" >> .env
echo "POSTGRES_ZITADEL_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" >> .env
```
### External URL settings
These three settings **must match your public endpoint exactly**:
| Setting | Meaning | Example |
|---------|---------|---------|
| `ZITADEL_DOMAIN` | The public domain users type in | `auth.example.com` |
| `ZITADEL_EXTERNALPORT` | The port visible to users | `443` for HTTPS |
| `ZITADEL_EXTERNALSECURE` | Whether the public URL uses HTTPS | `true` for any TLS mode |
If these don't match reality, ZITADEL returns **"Instance not found"** errors. This is the most common deployment issue — see [TLS Modes](/self-hosting/manage/tls_modes) for details.
<NoteInstanceNotFound components={props.components} />
### Enable caching with Redis
See [Cache Configuration](/self-hosting/manage/cache) for all available options. Add these to `.env`:
```dotenv
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED=true
ZITADEL_CACHES_INSTANCE_CONNECTOR=redis
ZITADEL_CACHES_MILESTONES_CONNECTOR=redis
ZITADEL_CACHES_ORGANIZATION_CONNECTOR=redis
```
Then start with the `cache` profile:
```shell
docker compose --env-file .env -f docker-compose.yml --profile cache up -d --wait
```
## Stage 3 — Beyond Compose
### Production-like init/setup/start split
For controlled upgrades, separate [database initialization from the running API](/self-hosting/manage/updating_scaling):
```shell
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.prodlike.yml &&
docker compose --env-file .env \
-f docker-compose.yml \
-f docker-compose.prodlike.yml \
up -d --wait
```
This creates three ZITADEL containers:
1. `zitadel-init` — runs database migrations (one-shot)
2. `zitadel-setup` — configures the instance (one-shot)
3. `zitadel-api` — starts the API server (long-running)
### Enable observability
Set in `.env`:
```dotenv
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE=grpc
```
Download the collector configuration and start with the `observability` profile:
```shell
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/otel-collector-config.yaml &&
docker compose --env-file .env -f docker-compose.yml --profile observability up -d --wait
```
Traces are logged to the collector's stdout by default (`docker compose logs otel-collector`).
To forward traces to your own backend (Grafana Tempo, Jaeger, OpenObserve, etc.), set `OTEL_BACKEND_ENDPOINT` in `.env` and uncomment the `otlp` exporter in `otel-collector-config.yaml`.
See [Metrics](/self-hosting/manage/metrics/overview) for Prometheus scraping and available metric types.
### Scale API replicas
<Callout type="info">
Scaling requires the **prodlike overlay** so that migrations run once in `zitadel-init` instead of on every replica.
</Callout>
```shell
docker compose --env-file .env \
-f docker-compose.yml \
-f docker-compose.prodlike.yml \
up -d --scale zitadel-api=3
```
### Updating ZITADEL
Edit `ZITADEL_VERSION` in `.env`, then:
```shell
docker compose --env-file .env -f docker-compose.yml pull
docker compose --env-file .env -f docker-compose.yml up -d --wait
```
<Callout>
`ZITADEL_FIRSTINSTANCE_*` and `ZITADEL_DEFAULTINSTANCE_*` environment variables are only applied during the **initial setup**.
To change settings on an existing installation, use the Admin Console or Admin API.
</Callout>
### Moving to Kubernetes
Docker Compose is ideal for getting started and homelab deployments.
For production workloads, review the [Production Checklist](/self-hosting/manage/productionchecklist) and deploy with the official [Helm chart for Kubernetes](/self-hosting/deploy/kubernetes).
The compose pack and the Helm chart share the same application configuration model (`ZITADEL_*` environment variables), so migration is straightforward.
<Next components={props.components} />
@@ -1,136 +0,0 @@
services:
zitadel:
restart: unless-stopped
image: ghcr.io/zitadel/zitadel:latest
command: start-from-init --masterkey "MasterkeyNeedsToHave32Characters"
environment:
# See "What's next" to learn about how to serve Zitadel on a different domain or IP.
ZITADEL_EXTERNALDOMAIN: localhost
# See "What's next" to learn about how to enable TLS.
ZITADEL_EXTERNALSECURE: false
ZITADEL_TLS_ENABLED: false
# Database connection settings.
ZITADEL_DATABASE_POSTGRES_HOST: db
ZITADEL_DATABASE_POSTGRES_PORT: 5432
# The database is created by the init job if it does not exist.
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
# The admin user must already exist in the database.
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
# The zitadel user is created by the init job if it does not exist.
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
# By configuring a login application, the setup job creates a user of type machine with the role IAM_LOGIN_CLIENT.
# It writes a PAT to the path specified in ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH.
# The PAT is passed to the login container via the environment variable ZITADEL_SERVICE_USER_TOKEN_FILE.
ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /current-dir/login-client.pat
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false
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: '2029-01-01T00:00:00Z'
# Activate the login v2 on an installation from scratch.
# To activate the login v2 on an existing installation, read the "What's next" section.
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true # To use the login v1, set this to false.
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:3000/ui/v2/login/
# Configure the redirection paths to the login v2.
ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:3000/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?samlRequest=
# By configuring a machine, the setup job creates a user of type machine with the role IAM_OWNER.
# It writes a personal access token (PAT) to the path specified in ZITADEL_FIRSTINSTANCE_PATPATH.
# The PAT can be used to provision resources with [Terraform](/guides/manage/terraform-provider), for example.
# ZITADEL_FIRSTINSTANCE_PATPATH: /current-dir/admin.pat
# ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: admin
# ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Automatically Initialized IAM_OWNER
# ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: '2029-01-01T00:00:00Z'
# To change the initial human admin users username and password, uncomment the following lines.
# The first login name is formatted like this: <username>@<org_name>.<external_domain>
# With the following incommented configuration, this would be [email protected]
# Visit http://localhost:8080/ui/console to check if the login name works.
# If you can't log in, check the available login names:
# echo "select * from projections.login_names3;" | psql -h localhost -U postgres -d zitadel
# The postgres users password is postgres.
# ZITADEL_FIRSTINSTANCE_ORG_NAME: My Organization
# ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: root
# ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: RootPassword1!
# Enable debug logs
# ZITADEL_LOG_LEVEL: debug
# Write Access Logs to stdout.
# ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED: true
healthcheck:
test:
- CMD
- /app/zitadel
- ready
interval: 10s
timeout: 60s
retries: 5
start_period: 10s
user: "0"
volumes:
- .:/current-dir:delegated
ports:
- 8080:8080
- 3000:3000
networks:
- zitadel
depends_on:
db:
condition: service_healthy
login:
restart: unless-stopped
image: ghcr.io/zitadel/zitadel-login:latest
# If you can't use the network_mode service:zitadel, you can pass the environment variables ZITADEL_API_URL=http://zitadel:8080 and CUSTOM_REQUEST_HEADERS=Host:localhost instead.
environment:
- ZITADEL_API_URL=http://localhost:8080
- NEXT_PUBLIC_BASE_PATH=/ui/v2/login
- ZITADEL_SERVICE_USER_TOKEN_FILE=/current-dir/login-client.pat
network_mode: service:zitadel
user: "0"
volumes:
- .:/current-dir:ro
depends_on:
zitadel:
condition: service_healthy
restart: false
db:
restart: unless-stopped
image: postgres:17
environment:
PGUSER: postgres
POSTGRES_PASSWORD: postgres
healthcheck:
test:
- CMD-SHELL
- pg_isready
- -d
- zitadel
- -U
- postgres
interval: 10s
timeout: 30s
retries: 5
start_period: 20s
networks:
- zitadel
ports:
- 5432:5432
volumes:
- 'data:/var/lib/postgresql/data:rw'
networks:
zitadel:
volumes:
data:
@@ -14,3 +14,38 @@ import UpstreamHeader from "../../../self-hosting/deploy/troubleshooting/_upstre
## upstream sent too big header while reading response header from upstream
<UpstreamHeader components={props.components} />
## Container restarts after upgrading to a new version
When you upgrade ZITADEL to a newer version, the setup phase runs database migrations automatically.
**Which command to use for upgrades:**
- `start-from-init` (used in the quickstart) runs **init → setup → start** in sequence. The init phase is only needed once ever (for a fresh database), but it is safe to re-run because it skips resources that already exist. Use `start-from-init` only for the quickstart or when you're sure the database needs initialising from scratch.
- **For upgrades on existing installations**, prefer `start-from-setup` (runs setup → start) or run `zitadel setup` followed by `zitadel start` as separate steps. This avoids running the init phase unnecessarily and is the recommended production approach. See [Update and Scale ZITADEL](/self-hosting/manage/updating_scaling) for details.
If the container exits with code 1 and the logs show a database migration error, check the following:
- Make sure the PostgreSQL version meets the [requirements](/self-hosting/manage/database). PostgreSQL 18 support requires ZITADEL v4.11.0 or later.
- Log messages like `role "zitadel" already exists` or `database "zitadel" already exists` during `start-from-init` re-runs are **expected and harmless** — the init phase skips those steps.
- If the container still fails after a version upgrade, check whether the version you are upgrading to lists any known migration issues in the [release notes](https://github.com/zitadel/zitadel/releases).
## Instance settings (FIRSTINSTANCE / DEFAULTINSTANCE) not taking effect after restart
Environment variables prefixed with `ZITADEL_FIRSTINSTANCE_` and `ZITADEL_DEFAULTINSTANCE_` are only applied during the **initial creation** of a ZITADEL instance.
Changing these variables and restarting the container will not update an already-existing instance.
To change instance-level settings on a running installation:
- Use the [Admin Console](https://your-domain/ui/console/instance) to update settings interactively.
- Use the Admin API to automate changes.
**Login V2**: Starting with ZITADEL v4, the Login V2 UI is enabled by default for new installations.
If you are running an existing installation and want to enable it, follow the [Login Client guide](/self-hosting/manage/login-client).
## ZITADEL service becomes unhealthy without visible error messages
If the `zitadel` container shows as `unhealthy` but the log output does not reveal an obvious error:
1. Increase log verbosity by setting `ZITADEL_LOG_LEVEL: debug` and `ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED: true`.
2. Check that the healthcheck command can reach ZITADEL. The built-in `zitadel ready` command uses HTTP when `ZITADEL_TLS_ENABLED=false` and HTTPS otherwise. Ensure that this variable matches your TLS settings.
3. If ZITADEL is running behind a reverse proxy, make sure the proxy forwards traffic correctly before the first healthcheck succeeds. See [Run and configure a reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy).
@@ -21,14 +21,15 @@ services:
environment:
- POSTGRES_USER=root
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=zitadel
networks:
- 'zitadel'
healthcheck:
test: ["CMD-SHELL", "pg_isready", "-d", "db_prod"]
test: ["CMD-SHELL", "pg_isready -d zitadel -U root"]
interval: 10s
timeout: 60s
timeout: 30s
retries: 5
start_period: 10s
start_period: 20s
volumes:
- 'data:/var/lib/postgresql/data:rw'
@@ -0,0 +1,36 @@
---
title: ZITADEL Requirements
sidebar_label: Requirements
---
This page lists the infrastructure versions that ZITADEL is tested against and officially supports.
## Database
ZITADEL requires a PostgreSQL database. Versions **14 to 18** are supported.
See the [Database](/self-hosting/manage/database) page for full configuration options, how to pre-create users and databases, and how to rotate credentials.
## Cache (optional)
ZITADEL supports Redis as an optional cache to speed up lookups of frequently needed objects.
Redis must run in **standalone mode** (Cluster and Sentinel are not yet supported).
See the [Caches](/self-hosting/manage/cache) page for configuration options.
## Container runtime
- **Docker Compose**: v2.x (Compose V1 `docker-compose` CLI is not supported)
- **Kubernetes**: see the [Kubernetes deployment guide](/self-hosting/deploy/kubernetes)
## Reverse proxy
Any reverse proxy that supports HTTP/2 upstream connections (h2c or h2) works with ZITADEL.
The following proxies are tested and have dedicated configuration guides:
- **Traefik**: v3.x
- **NGINX**: v1.x stable releases
- **Caddy**: v2.x
- **Apache httpd**: 2.4.x
See the [Reverse Proxy Configuration](/self-hosting/manage/reverseproxy/reverse_proxy) page for details and Docker Compose examples.
@@ -28,6 +28,21 @@ You can either setup your environment for <a href={'#tls-mode-external'}>TLS mod
<ProxyGuideTLSMode components={props.components} mode="enabled" configfilename="enabled-tls.Caddyfile" configfilecontent={ConfigEnabled} providername={providername} link={link} lower={lower}></ProxyGuideTLSMode>
## Known issues
### TE: trailers header causes requests to hang
When a browser sends a `TE: trailers` HTTP header (common with HTTP/2), Caddy may forward it upstream where it conflicts with ZITADEL's gRPC-gateway handling.
Requests that result in an error response will hang indefinitely instead of returning the error to the client.
**Workaround**: strip the header in Caddy before it reaches ZITADEL:
```
reverse_proxy h2c://zitadel:8080 {
header_up -TE
}
```
## More Information
<ProxyGuideMore components={props.components} ></ProxyGuideMore>
@@ -3,6 +3,36 @@ title: Reverse Proxy Configuration
description: "Setup and configure reverse proxies like Traefik, NGINX, and Caddy to manage external traffic for self-hosted ZITADEL."
---
ZITADEL exposes a single HTTP/2 port (default `8080`) that serves both gRPC and HTTP APIs, as well as the management console.
Running a reverse proxy in front of ZITADEL is the recommended approach for production deployments because it:
- Terminates TLS and provides HTTPS to clients
- Exposes standard ports (`80`/`443`) while keeping ZITADEL on an internal port
- Handles certificate renewal (e.g. via Let's Encrypt)
- Enables path-based routing between the ZITADEL API and the Login UI
## HTTP/2 and h2c requirements
ZITADEL relies on HTTP/2 for all gRPC communication.
When the reverse proxy terminates TLS, it must forward traffic to ZITADEL using **h2c** (unencrypted HTTP/2), not HTTP/1.1.
Not all reverse proxies support h2c by default — check the proxy-specific guides below for configuration examples.
For more details, see [HTTP/2 Support in ZITADEL](/self-hosting/manage/http2).
## TLS modes
ZITADEL supports three TLS modes that affect how the reverse proxy should be configured:
| Mode | Description | Typical use |
|------|-------------|-------------|
| `disabled` | ZITADEL listens on plain HTTP, reverse proxy handles nothing special | Local development, h2c passthrough |
| `external` | ZITADEL listens on plain HTTP; reverse proxy terminates TLS | Most production setups |
| `enabled` | ZITADEL itself terminates TLS (end-to-end encryption) | Environments requiring mutual TLS |
For more details, see [TLS Modes](/self-hosting/manage/tls_modes).
## Proxy guides
Check out one of the following guides to configure your favorite reverse proxy:
- [Traefik](/self-hosting/manage/reverseproxy/traefik)
@@ -12,3 +42,14 @@ Check out one of the following guides to configure your favorite reverse proxy:
- [Cloudflare](/self-hosting/manage/reverseproxy/cloudflare)
- [Cloudflare Tunnel](/self-hosting/manage/reverseproxy/cloudflare_tunnel)
- [Fronting Zitadel Cloud](/self-hosting/manage/reverseproxy/zitadel_cloud)
## Login UI routing
When running both the ZITADEL API and the Login V2 UI (`zitadel-login`) behind a reverse proxy, the proxy must route traffic to the correct upstream:
- `/ui/v2/login` → Login UI container (port `3000` by default)
- All other paths → ZITADEL API container (port `8080` by default)
The proxy-specific guides include Docker Compose examples that demonstrate this split routing.
For tested proxy versions, see [ZITADEL Requirements](/self-hosting/manage/requirements#reverse-proxy).
+1
View File
@@ -773,6 +773,7 @@ export const guidesSidebar: readonly SidebarItem[] = [
label: "Production & Operations",
collapsed: false,
items: [
"self-hosting/manage/requirements",
"self-hosting/manage/production",
"self-hosting/manage/productionchecklist",
"self-hosting/manage/usage_control",
+1
View File
@@ -22,3 +22,4 @@ The **Login App** (`apps/login`) provides the user interface for authentication
- **Test (all)**: `pnpm nx run @zitadel/login:test`
- **Test (unit)**: `pnpm nx run @zitadel/login:test-unit`
- **Test (integration)**: `pnpm nx run @zitadel/login:test-integration`
- **Pack (Docker)**: `pnpm nx run @zitadel/login:pack` — builds a local Docker image `zitadel/zitadel-login:local`. Requires Docker daemon.
+31 -1
View File
@@ -40,6 +40,22 @@
"{projectRoot}/.next/standalone"
]
},
"pack": {
"description": "Builds a local Docker image for the ZITADEL Login UI (zitadel/zitadel-login:local). Requires Docker daemon.",
"dependsOn": [
"build"
],
"command": "docker build -f apps/login/Dockerfile -t zitadel/zitadel-login:local apps/login",
"cache": false,
"inputs": [
"default",
"{workspaceRoot}/pnpm-lock.yaml",
"{projectRoot}/Dockerfile",
"!{projectRoot}/.env.*",
"!{projectRoot}/integration/**/*",
"!{projectRoot}/acceptance/**/*"
]
},
"build-vercel": {
"description": "Builds the Next.js Login application for Vercel deployment",
"cache": true,
@@ -152,7 +168,21 @@
"description": "Stops the gRPC mock container used for integration tests.",
"command": "nx run @zitadel/login-api-mock:down"
},
"pack": {
"test-acceptance": {
"description": "Runs the Playwright acceptance test suite in apps/login/acceptance/ against a running ZITADEL stack. Requires LOGIN_BASE_URL, ZITADEL_API_URL, and ZITADEL_ADMIN_TOKEN to be set (or apps/login/.env.test.local to exist). Run the setup script at acceptance/setup/setup.sh first to seed config.",
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "pnpm exec playwright test --config acceptance/playwright.config.ts"
},
"inputs": [
"{projectRoot}/acceptance/**",
"!{projectRoot}/acceptance/playwright-report/**",
"!{projectRoot}/acceptance/test-results/**",
"{workspaceRoot}/pnpm-lock.yaml"
]
},
"pack-archive": {
"description": "Packages the standalone Login application build into an archive",
"dependsOn": [
"build"
+97
View File
@@ -0,0 +1,97 @@
# ⚠️ INSECURE DEFAULTS — for local development only.
# Before exposing this stack to a network, change ZITADEL_MASTERKEY,
# POSTGRES_ADMIN_PASSWORD, and POSTGRES_ZITADEL_PASSWORD.
# See: https://zitadel.com/docs/self-hosting/deploy/compose#homelab
# -----------------------------
# Domain and external URL
# -----------------------------
ZITADEL_DOMAIN=localhost
# Used by the base (non-TLS) compose; TLS overlays also publish 80/443 and may still expose
# this port unless their ports are adjusted.
PROXY_HTTP_PUBLISHED_PORT=8080
ZITADEL_EXTERNALPORT=8080
ZITADEL_EXTERNALSECURE=false
# Overridden by TLS overlays (they hardcode X-Forwarded-Proto: https).
ZITADEL_PUBLIC_SCHEME=http
# -----------------------------
# Security/bootstrap
# -----------------------------
# Must be exactly 32 chars for new deployments.
ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters
LOGIN_CLIENT_PAT_EXPIRATION=2099-01-01T00:00:00Z
# -----------------------------
# Pinned image tags
# To upgrade ZITADEL, bump ZITADEL_VERSION and run:
# docker compose --env-file .env -f docker-compose.yml pull
# docker compose --env-file .env -f docker-compose.yml up -d --wait
# -----------------------------
ZITADEL_VERSION=v4.11.0
TRAEFIK_IMAGE=traefik:v3.6.8
POSTGRES_IMAGE=postgres:17.2-alpine
REDIS_IMAGE=redis:7.4.2-alpine
OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib:0.114.0
# -----------------------------
# Proxy settings
# -----------------------------
TRAEFIK_DASHBOARD_ENABLED=false
TRAEFIK_LOG_LEVEL=INFO
TRAEFIK_ACCESSLOG_ENABLED=true
# Trusted proxy IPs for X-Forwarded-* headers (external-tls mode).
# Comma-separated CIDR ranges of your upstream load balancer / reverse proxy.
TRAEFIK_TRUSTED_IPS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# Let's Encrypt mode
LETSENCRYPT_EMAIL=[email protected]
# -----------------------------
# Postgres settings
# -----------------------------
POSTGRES_DB=zitadel
POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD=postgres
POSTGRES_ZITADEL_USER=zitadel
POSTGRES_ZITADEL_PASSWORD=zitadel
# -----------------------------
# Access logs
# -----------------------------
ZITADEL_ACCESS_LOG_STDOUT_ENABLED=true
# -----------------------------
# Optional Redis cache settings
# Default is disabled.
# Enable with --profile cache and switch connectors to redis.
# -----------------------------
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED=false
ZITADEL_CACHES_CONNECTORS_REDIS_ADDR=redis:6379
ZITADEL_CACHES_CONNECTORS_REDIS_USERNAME=
ZITADEL_CACHES_CONNECTORS_REDIS_PASSWORD=
ZITADEL_CACHES_INSTANCE_CONNECTOR=
ZITADEL_CACHES_MILESTONES_CONNECTOR=
ZITADEL_CACHES_ORGANIZATION_CONNECTOR=
# -----------------------------
# Optional API tracing (OTEL)
# Default is disabled. Enable with --profile observability.
# The collector receives traces from ZITADEL on port 4317 (gRPC) and 4318 (HTTP)
# and logs them to stdout by default.
# To forward traces to your own backend (Grafana Tempo, Jaeger, OpenObserve, etc.),
# uncomment the otlp exporter in otel-collector-config.yaml and set:
# OTEL_BACKEND_ENDPOINT=http://your-backend:4317
# -----------------------------
ZITADEL_INSTRUMENTATION_SERVICENAME=zitadel-api
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE=none
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_ENDPOINT=otel-collector:4317
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_INSECURE=true
# -----------------------------
# Future Login OTEL placeholders
# Current login image may ignore these.
# -----------------------------
LOGIN_OTEL_SERVICE_NAME=zitadel-login
LOGIN_OTEL_EXPORTER_OTLP_ENDPOINT=
LOGIN_OTEL_EXPORTER_OTLP_PROTOCOL=grpc
+65
View File
@@ -0,0 +1,65 @@
# Test environment for NX smoke tests.
# Used with docker-compose.test.yml overlay to run locally-built images.
# Do NOT use for production deployments.
ZITADEL_DOMAIN=localhost
PROXY_HTTP_PUBLISHED_PORT=8888
ZITADEL_EXTERNALPORT=8888
ZITADEL_EXTERNALSECURE=false
ZITADEL_PUBLIC_SCHEME=http
ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters
LOGIN_CLIENT_PAT_EXPIRATION=2099-01-01T00:00:00Z
# Suppresses interpolation warnings from the base compose file.
# The actual image tags for zitadel-api and zitadel-login are overridden
# by docker-compose.test.yml to use locally-built :local tags.
ZITADEL_VERSION=local
# Images for services not overridden by docker-compose.test.yml
TRAEFIK_IMAGE=traefik:v3.6.8
POSTGRES_IMAGE=postgres:17.2-alpine
REDIS_IMAGE=redis:7.4.2-alpine
OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib:0.114.0
# Traefik
TRAEFIK_DASHBOARD_ENABLED=false
TRAEFIK_LOG_LEVEL=ERROR
TRAEFIK_ACCESSLOG_ENABLED=false
TRAEFIK_TRUSTED_IPS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# Postgres
POSTGRES_DB=zitadel
POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD=postgres
POSTGRES_ZITADEL_USER=zitadel
POSTGRES_ZITADEL_PASSWORD=zitadel
# Disable access logs to reduce noise in test output
ZITADEL_ACCESS_LOG_STDOUT_ENABLED=false
# Tracing disabled
ZITADEL_INSTRUMENTATION_SERVICENAME=zitadel-api
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE=none
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_ENDPOINT=
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_INSECURE=false
# Cache disabled (no Redis profile)
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED=false
ZITADEL_CACHES_CONNECTORS_REDIS_ADDR=redis:6379
ZITADEL_CACHES_CONNECTORS_REDIS_USERNAME=
ZITADEL_CACHES_CONNECTORS_REDIS_PASSWORD=
ZITADEL_CACHES_INSTANCE_CONNECTOR=
ZITADEL_CACHES_MILESTONES_CONNECTOR=
ZITADEL_CACHES_ORGANIZATION_CONNECTOR=
# Playwright smoke test credentials.
# SMOKE_TEST_ADMIN_USERNAME must match ZITADEL's initial admin login name:
# <username>@<org-domain>.<external-domain> → [email protected]
SMOKE_TEST_ADMIN_USERNAME=[email protected]
SMOKE_TEST_ADMIN_PASSWORD=Password1!
# Login OTEL disabled
LOGIN_OTEL_SERVICE_NAME=zitadel-login
LOGIN_OTEL_EXPORTER_OTLP_ENDPOINT=
LOGIN_OTEL_EXPORTER_OTLP_PROTOCOL=grpc
+13
View File
@@ -0,0 +1,13 @@
playwright-report/
test-results/
# Local environment file with secrets
.env
# Local TLS certificates and keys
certs/*.crt
certs/*.cer
certs/*.pem
certs/*.key
!certs/.gitkeep
+63
View File
@@ -0,0 +1,63 @@
## Docker Compose Deployment — AI Agent Instructions
### 1. Scope & Architecture
This directory provides a production-aware single-node Docker Compose deployment for ZITADEL.
The stack contains four core services and two optional profile-based services:
- **Core:** `zitadel-api` (Go API), `zitadel-login` (Next.js Login UI), `postgres`, `proxy` (Traefik)
- **Optional:** `redis` (cache profile), `otel-collector` (observability profile)
### 2. File Conventions
| File | Purpose | Safe to modify? |
|------|---------|----------------|
| `docker-compose.yml` | Base stack — all modes start from this | Yes, with care |
| `docker-compose.mode-*.yml` | TLS mode overlays (one per mode) | Yes |
| `docker-compose.prodlike.yml` | Init/setup/start split overlay | Yes |
| `docker-compose.test.yml` | CI test overlay (local images, no direct ports) | Yes |
| `.env.example` | User-facing config template | Yes — update on version bumps |
| `.env.test` | CI-only test config | Internal only |
| `otel-collector-config.yaml` | OTEL Collector pipeline config | Yes |
| `traefik-local-tls.yml` | Traefik dynamic config for local TLS certs | Yes |
| `project.json` | NX project definition | Auto-managed by NX |
| `README.md` | Developer/contributor reference | Yes |
### 3. Key Invariants
- **Four TLS modes must remain independently composable.** Never merge mode-specific config into the base `docker-compose.yml`. Each `docker-compose.mode-*.yml` overlay must work when composed with the base file alone.
- **gRPC routing uses `Content-Type: application/grpc*` header matching**, not path prefixes. Do NOT introduce `/grpc` path routing.
- **`ZITADEL_EXTERNALDOMAIN`, `ZITADEL_EXTERNALPORT`, `ZITADEL_EXTERNALSECURE` must be consistent** with the actual public endpoint. Mismatches cause "Instance not found" errors — the single most common deployment issue.
- **Profiles (`cache`, `observability`) are opt-in** and must not affect the default stack behavior.
- **The `/api` path alias coexists with canonical ZITADEL paths.** Do NOT remove canonical root-level path routing (e.g., `/.well-known/`, `/oauth/v2/`). The `/api` prefix is a convenience alias.
- **Image versions are pinned in `.env.example`** via `ZITADEL_VERSION` and infrastructure image variables. When bumping versions, update `.env.example`.
- **CI tests must go through Traefik.** The test overlay must NOT expose direct container ports. All smoke test traffic flows through the proxy to validate routing end-to-end.
### 4. Rejected Alternatives
These designs were considered and explicitly rejected — do not re-propose them:
- **Single container** merging API + Login — not aligned with v4 architecture
- **`/grpc` path-prefix routing** — tool/client incompatibility risk
- **Strict `/api`-only rewrite model** — breaks canonical OIDC/SAML protocol paths
- **`network_mode: service:`** for Login — fragile, port conflicts, incompatible with Traefik routing
- **Merged TLS configurations** — each mode must be independently composable without side effects
### 5. Common Commands
| Task | Command |
|------|---------|
| Start (local dev) | `cp .env.example .env && docker compose up -d --wait` |
| Start (Let's Encrypt) | `docker compose --env-file .env -f docker-compose.yml -f docker-compose.mode-letsencrypt.yml up -d --wait` |
| Start (production-like) | `docker compose --env-file .env -f docker-compose.yml -f docker-compose.prodlike.yml up -d --wait` |
| Validate all configs | `docker compose --env-file .env.example -f docker-compose.yml -f <overlay> config > /dev/null` for each overlay |
| Run CI smoke test | `pnpm nx run @zitadel/compose:test-full` |
| Smoke check | `curl -sS http://localhost:8888/.well-known/openid-configuration` |
### 6. Terminology
Follow the root `AGENTS.md` glossary. Key rules:
- **Instance** = a logical ZITADEL tenant/partition, NEVER "example"
- **System** = the entire ZITADEL installation/deployment
- See the [Technical Glossary](../../AGENTS.md) for all user-facing text in any language
+91
View File
@@ -0,0 +1,91 @@
# ZITADEL Docker Compose — Developer Reference
> **User-facing documentation:** [zitadel.com/docs/self-hosting/deploy/compose](https://zitadel.com/docs/self-hosting/deploy/compose)
>
> This README is the contributor/developer reference — architecture decisions, file conventions, and routing logic.
## Architecture
```
┌─────────────────────────┐
Browser ──────►│ Traefik (proxy) │
│ Port 80 / 443 │
└───┬──────────┬──────────┘
│ │
┌──────────▼──┐ ┌───▼──────────┐
│ zitadel-api │ │ zitadel-login │
│ Go :8080 │ │ Next.js :3000 │
└──────┬───────┘ └──────────────┘
│
┌──────▼───────┐
│ PostgreSQL │
└──────────────┘
```
Optional services via profiles: `redis` (`cache`), `otel-collector` (`observability`).
## File Conventions
| File | Role | Notes |
|------|------|-------|
| `docker-compose.yml` | **Base stack** — all modes start from this | Must work standalone with `.env.example` |
| `docker-compose.mode-letsencrypt.yml` | TLS overlay: ACME HTTP challenge | Declares its own `letsencrypt` volume |
| `docker-compose.mode-external-tls.yml` | TLS overlay: upstream LB terminates TLS | Enables forwarded headers |
| `docker-compose.mode-local-tls.yml` | TLS overlay: self-signed certs | Mounts `./certs/` and `traefik-local-tls.yml` |
| `docker-compose.prodlike.yml` | Init/setup/start split | Uses YAML anchors for shared DB env |
| `docker-compose.test.yml` | CI smoke test overlay | Overrides images to `:local` tags |
| `.env.example` | User-facing config template | Copy to `.env` before first run |
| `.env.test` | CI-only config | Used by NX targets: `test-run`, `test-e2e`, `test-full`, `stop` |
| `otel-collector-config.yaml` | OTEL Collector pipeline config | Logs traces to stdout; configure `OTEL_BACKEND_ENDPOINT` to forward to a backend |
| `traefik-local-tls.yml` | Traefik dynamic config for local certs | Referenced by local-tls overlay |
| `project.json` | NX project definition | Targets: `test-config`, `test-run`, `test-e2e`, `test`, `test-full`, `stop` |
| `AGENTS.md` | AI agent instructions for this directory | |
## Routing Rules
Traefik routes all traffic for `${ZITADEL_DOMAIN}` via Docker labels:
| Priority | Rule | Target | Middleware |
|----------|------|--------|------------|
| 400 | `Path(/)` | `zitadel-login` | `replacepath=/ui/v2/login/` |
| 250 | `PathPrefix(/ui/v2/login)` | `zitadel-login` | — |
| 200 | `PathPrefix(/api)` | `zitadel-api` | `stripprefix=/api` |
| 100 | Everything else (OIDC, SAML, gRPC, gRPC-web, API v2 REST, ...) | `zitadel-api` (h2c) | — |
No dedicated gRPC router is needed: Traefik's h2c backend scheme forwards gRPC and gRPC-web transparently. API v2 is served as REST/JSON via the gRPC-gateway at `/v2/...` paths.
Both `web` (HTTP) and `websecure` (HTTPS) entrypoints have identical router sets.
### Why this routing model
- `/api` alias exists for DX — tools can use `https://auth.example.com/api/...`
- Canonical paths (e.g., `/.well-known/openid-configuration`, `/oauth/v2/...`) must remain at root for OIDC/SAML protocol compliance
- gRPC, gRPC-web, and REST all share the catch-all router — no separate gRPC router is needed because the `h2c` backend scheme makes Traefik forward all protocols transparently over HTTP/2
## External Settings Invariant
`ZITADEL_EXTERNALDOMAIN`, `ZITADEL_EXTERNALPORT`, and `ZITADEL_EXTERNALSECURE` **must match the public URL** that users see. If they don't, ZITADEL returns "Instance not found" errors. This is the single most common deployment issue.
## Testing
Local NX targets for testing the compose stack:
| Target | What it does | Requires Docker? |
|--------|-------------|------------------|
| `test-config` | Validates all overlay combinations parse with `docker compose config` | No (just the CLI) |
| `test-run` | Builds local images (`@zitadel/api:pack` + `@zitadel/login:pack`), starts the stack with `docker compose up --wait` | Yes |
| `test-e2e` | Runs the full Playwright suite (`wiring.spec.ts` + `smoke.spec.ts`) against `localhost:8888` through Traefik: per-service wiring checks (login, console, OIDC, SAML, API v1 REST, gRPC h2c, gRPC-web, API v2 REST HTTP/1.1 + HTTP/2) and the browser login flow | Yes (stack must be running) |
| `test` | Lightweight — delegates to `test-config` only. Safe for `nx affected` | No |
| `test-full` | Full pipeline: `test-config` → `test-run` → Playwright wiring + browser tests → teardown | Yes |
| `stop` | Tears down the `zitadel-compose-test` stack and removes volumes | Yes |
## Rejected Alternatives
| Alternative | Why rejected |
|-------------|-------------|
| Strict `/api`-only rewrite | Breaks canonical protocol paths (OIDC, SAML) |
| `/grpc` path-prefix routing | gRPC clients/tools don't use path prefixes |
| Dedicated gRPC router (`HeaderRegexp(Content-Type, ^application/grpc.*)`) | Redundant: h2c backend handles gRPC, gRPC-web, and Connect-RPC natively; the catch-all covers all three |
| Single container (API + Login) | Not aligned with v4 architecture; Login is a separate Next.js process |
| Merged TLS configs | Each TLS mode must remain independently composable |
| `network_mode: service:` for Login | Fragile, port conflicts, doesn't work with Traefik routing |
View File
@@ -0,0 +1,30 @@
services:
proxy:
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=zitadel
- --entrypoints.web.address=:80
- --entrypoints.web.forwardedHeaders.trustedIPs=${TRAEFIK_TRUSTED_IPS}
- --entrypoints.websecure.address=:443
- --api.dashboard=${TRAEFIK_DASHBOARD_ENABLED}
- --api.insecure=false
- --ping=true
- --ping.entrypoint=web
- --log.level=${TRAEFIK_LOG_LEVEL}
- --accesslog=${TRAEFIK_ACCESSLOG_ENABLED}
ports: !override
- 80:80
zitadel-api:
environment:
ZITADEL_EXTERNALPORT: 443
ZITADEL_EXTERNALSECURE: true
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: https://${ZITADEL_DOMAIN}/ui/v2/login/
ZITADEL_OIDC_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?samlRequest=
zitadel-login:
environment:
CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_DOMAIN},X-Forwarded-Proto:https
@@ -0,0 +1,42 @@
services:
proxy:
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=zitadel
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.websecure.http.tls=true
- --entrypoints.websecure.http.tls.certresolver=le
- --certificatesresolvers.le.acme.email=${LETSENCRYPT_EMAIL}
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
- --api.dashboard=${TRAEFIK_DASHBOARD_ENABLED}
- --api.insecure=false
- --ping=true
- --ping.entrypoint=web
- --log.level=${TRAEFIK_LOG_LEVEL}
- --accesslog=${TRAEFIK_ACCESSLOG_ENABLED}
ports: !override
- 80:80
- 443:443
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt:rw
zitadel-api:
environment:
ZITADEL_EXTERNALPORT: 443
ZITADEL_EXTERNALSECURE: true
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: https://${ZITADEL_DOMAIN}/ui/v2/login/
ZITADEL_OIDC_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?samlRequest=
zitadel-login:
environment:
CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_DOMAIN},X-Forwarded-Proto:https
volumes:
letsencrypt:
@@ -0,0 +1,38 @@
services:
proxy:
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=zitadel
- --providers.file.filename=/etc/traefik/dynamic/local-tls.yml
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --entrypoints.websecure.address=:443
- --entrypoints.websecure.http.tls=true
- --api.dashboard=${TRAEFIK_DASHBOARD_ENABLED}
- --api.insecure=false
- --ping=true
- --ping.entrypoint=web
- --log.level=${TRAEFIK_LOG_LEVEL}
- --accesslog=${TRAEFIK_ACCESSLOG_ENABLED}
ports: !override
- 80:80
- 443:443
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./certs:/certs:ro
- ./traefik-local-tls.yml:/etc/traefik/dynamic/local-tls.yml:ro
zitadel-api:
environment:
ZITADEL_EXTERNALPORT: 443
ZITADEL_EXTERNALSECURE: true
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: https://${ZITADEL_DOMAIN}/ui/v2/login/
ZITADEL_OIDC_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?samlRequest=
zitadel-login:
environment:
CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_DOMAIN},X-Forwarded-Proto:https
@@ -0,0 +1,64 @@
x-zitadel-db-env: &zitadel-db-env
ZITADEL_DATABASE_POSTGRES_HOST: postgres
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: ${POSTGRES_DB}
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_ADMIN_USER}
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: ${POSTGRES_ZITADEL_USER}
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${POSTGRES_ZITADEL_PASSWORD}
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
services:
zitadel-init:
image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION}
restart: "no"
command: init
environment:
<<: *zitadel-db-env
depends_on:
postgres:
condition: service_healthy
networks:
- zitadel
zitadel-setup:
image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION}
restart: "no"
command: setup --masterkey "${ZITADEL_MASTERKEY}"
environment:
<<: *zitadel-db-env
ZITADEL_EXTERNALDOMAIN: ${ZITADEL_DOMAIN}
ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNALPORT}
ZITADEL_EXTERNALSECURE: ${ZITADEL_EXTERNALSECURE}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false
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}
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/
ZITADEL_OIDC_DEFAULTLOGINURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/login?samlRequest=
volumes:
- zitadel-bootstrap:/zitadel/bootstrap:rw
depends_on:
postgres:
condition: service_healthy
zitadel-init:
condition: service_completed_successfully
networks:
- zitadel
zitadel-api:
command: start --masterkey "${ZITADEL_MASTERKEY}"
depends_on:
postgres:
condition: service_healthy
zitadel-init:
condition: service_completed_successfully
zitadel-setup:
condition: service_completed_successfully
+26
View File
@@ -0,0 +1,26 @@
# Test overlay: replaces published images with locally-built ones.
# Used by the NX smoke-test targets (@zitadel/compose:test-run, :test-full, :stop).
#
# All test traffic flows through the Traefik proxy (port 8888)
# so the routing rules are validated end-to-end.
#
# Usage:
# docker compose --env-file .env.test -f docker-compose.yml -f docker-compose.test.yml \
# -p zitadel-compose-test up --force-recreate --renew-anon-volumes --wait
services:
zitadel-api:
image: zitadel/zitadel:local
environment:
# Set a known password for the initial admin so Playwright smoke tests
# can authenticate without a setup API call.
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${SMOKE_TEST_ADMIN_PASSWORD}
# Create an IAM_OWNER machine user and write its PAT to the bootstrap volume.
# The PAT can be consumed by external acceptance tests to call the management API.
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: ZITADEL Admin SA
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2519-04-01T08:45:00.000000Z"
ZITADEL_FIRSTINSTANCE_PATPATH: /zitadel/bootstrap/admin.pat
zitadel-login:
image: zitadel/zitadel-login:local
+246
View File
@@ -0,0 +1,246 @@
name: zitadel
services:
proxy:
image: ${TRAEFIK_IMAGE}
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=${TRAEFIK_DASHBOARD_ENABLED}
- --api.insecure=false
- --ping=true
- --ping.entrypoint=web
- --log.level=${TRAEFIK_LOG_LEVEL}
- --accesslog=${TRAEFIK_ACCESSLOG_ENABLED}
ports:
- ${PROXY_HTTP_PUBLISHED_PORT}: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}
restart: unless-stopped
user: "0"
command: start-from-init --masterkey "${ZITADEL_MASTERKEY}"
environment:
ZITADEL_PORT: 8080
ZITADEL_EXTERNALDOMAIN: ${ZITADEL_DOMAIN}
ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNALPORT}
ZITADEL_EXTERNALSECURE: ${ZITADEL_EXTERNALSECURE}
ZITADEL_TLS_ENABLED: false
ZITADEL_DATABASE_POSTGRES_HOST: postgres
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: ${POSTGRES_DB}
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_ADMIN_USER}
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: ${POSTGRES_ZITADEL_USER}
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${POSTGRES_ZITADEL_PASSWORD}
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false
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}
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/
ZITADEL_OIDC_DEFAULTLOGINURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/login?samlRequest=
ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED: ${ZITADEL_ACCESS_LOG_STDOUT_ENABLED}
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE: ${ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE}
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_ENDPOINT: ${ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_ENDPOINT}
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_INSECURE: ${ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_INSECURE}
ZITADEL_INSTRUMENTATION_SERVICENAME: ${ZITADEL_INSTRUMENTATION_SERVICENAME}
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED: ${ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED}
ZITADEL_CACHES_CONNECTORS_REDIS_ADDR: ${ZITADEL_CACHES_CONNECTORS_REDIS_ADDR}
ZITADEL_CACHES_CONNECTORS_REDIS_USERNAME: ${ZITADEL_CACHES_CONNECTORS_REDIS_USERNAME}
ZITADEL_CACHES_CONNECTORS_REDIS_PASSWORD: ${ZITADEL_CACHES_CONNECTORS_REDIS_PASSWORD}
ZITADEL_CACHES_INSTANCE_CONNECTOR: ${ZITADEL_CACHES_INSTANCE_CONNECTOR}
ZITADEL_CACHES_MILESTONES_CONNECTOR: ${ZITADEL_CACHES_MILESTONES_CONNECTOR}
ZITADEL_CACHES_ORGANIZATION_CONNECTOR: ${ZITADEL_CACHES_ORGANIZATION_CONNECTOR}
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
# Note: no dedicated gRPC router needed. All gRPC and Connect-RPC traffic
# is handled by the catch-all router below since the backend already uses h2c.
- traefik.http.routers.zitadel-api-alias-web.rule=Host(`${ZITADEL_DOMAIN}`) && 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-api-alias-websecure.rule=Host(`${ZITADEL_DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.zitadel-api-alias-websecure.entrypoints=websecure
- traefik.http.routers.zitadel-api-alias-websecure.tls=true
- traefik.http.routers.zitadel-api-alias-websecure.middlewares=zitadel-strip-api
- traefik.http.routers.zitadel-api-alias-websecure.service=zitadel-api
- traefik.http.routers.zitadel-api-alias-websecure.priority=200
- traefik.http.routers.zitadel-canonical-web.rule=Host(`${ZITADEL_DOMAIN}`) && !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
- traefik.http.routers.zitadel-canonical-websecure.rule=Host(`${ZITADEL_DOMAIN}`) && !PathPrefix(`/ui/v2/login`) && !PathPrefix(`/api`) && !Path(`/`)
- traefik.http.routers.zitadel-canonical-websecure.entrypoints=websecure
- traefik.http.routers.zitadel-canonical-websecure.tls=true
- traefik.http.routers.zitadel-canonical-websecure.service=zitadel-api
- traefik.http.routers.zitadel-canonical-websecure.priority=100
zitadel-login:
image: ghcr.io/zitadel/zitadel-login:${ZITADEL_VERSION}
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
CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_DOMAIN},X-Forwarded-Proto:${ZITADEL_PUBLIC_SCHEME}
# Future-ready placeholders for upcoming Login OTEL support.
OTEL_SERVICE_NAME: ${LOGIN_OTEL_SERVICE_NAME}
OTEL_EXPORTER_OTLP_ENDPOINT: ${LOGIN_OTEL_EXPORTER_OTLP_ENDPOINT}
OTEL_EXPORTER_OTLP_PROTOCOL: ${LOGIN_OTEL_EXPORTER_OTLP_PROTOCOL}
healthcheck:
test:
- CMD
- /bin/sh
- -c
- node /app/healthcheck.js 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}`) && 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-root-websecure.rule=Host(`${ZITADEL_DOMAIN}`) && Path(`/`)
- traefik.http.routers.zitadel-root-websecure.entrypoints=websecure
- traefik.http.routers.zitadel-root-websecure.tls=true
- traefik.http.routers.zitadel-root-websecure.middlewares=zitadel-root-rewrite
- traefik.http.routers.zitadel-root-websecure.service=zitadel-login
- traefik.http.routers.zitadel-root-websecure.priority=400
- traefik.http.routers.zitadel-login-web.rule=Host(`${ZITADEL_DOMAIN}`) && 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
- traefik.http.routers.zitadel-login-websecure.rule=Host(`${ZITADEL_DOMAIN}`) && PathPrefix(`/ui/v2/login`)
- traefik.http.routers.zitadel-login-websecure.entrypoints=websecure
- traefik.http.routers.zitadel-login-websecure.tls=true
- traefik.http.routers.zitadel-login-websecure.service=zitadel-login
- traefik.http.routers.zitadel-login-websecure.priority=250
postgres:
image: ${POSTGRES_IMAGE}
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
POSTGRES_USER: ${POSTGRES_ADMIN_USER}
POSTGRES_DB: ${POSTGRES_DB}
healthcheck:
test:
- CMD-SHELL
- pg_isready -d ${POSTGRES_DB} -U ${POSTGRES_ADMIN_USER}
interval: 10s
timeout: 30s
retries: 10
start_period: 20s
volumes:
- postgres-data:/var/lib/postgresql/data:rw
networks:
- zitadel
redis:
image: ${REDIS_IMAGE}
restart: unless-stopped
profiles:
- cache
command:
- --save
- ""
- --appendonly
- "no"
networks:
- zitadel
otel-collector:
image: ${OTEL_COLLECTOR_IMAGE}
restart: unless-stopped
profiles:
- observability
command:
- --config=/etc/otelcol/config.yaml
volumes:
- ./otel-collector-config.yaml:/etc/otelcol/config.yaml:ro
networks:
- zitadel
networks:
zitadel:
name: zitadel
volumes:
postgres-data:
zitadel-bootstrap:
+28
View File
@@ -0,0 +1,28 @@
receivers:
otlp:
protocols:
grpc:
http:
processors:
batch:
exporters:
# Traces are logged to stdout in a human-readable format by default.
# To forward to your own OTLP-compatible backend (Grafana Tempo, Jaeger, OpenObserve, etc.),
# uncomment the otlp exporter below and set OTEL_BACKEND_ENDPOINT in .env.
debug:
verbosity: basic
# otlp:
# endpoint: ${env:OTEL_BACKEND_ENDPOINT}
# tls:
# insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [debug]
# To also forward to a backend, add otlp to the list:
# exporters: [debug, otlp]
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@zitadel/compose",
"private": true,
"devDependencies": {
"@playwright/test": "^1.58.0",
"dotenv": "^16.0.0"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig, devices } from "@playwright/test";
import * as dotenv from "dotenv";
import * as path from "path";
// Load .env.test so credentials and port are available when running on the host.
// On CI the same file is passed to the shell environment before invoking NX.
dotenv.config({ path: path.resolve(__dirname, ".env.test") });
export default defineConfig({
testDir: "./tests",
// Fail fast: one retry on flakiness, full trace on first retry for debugging.
retries: 1,
reporter: [["html", { open: "never" }], ["line"]],
use: {
// All navigation goes through Traefik on the published port.
baseURL:
process.env.PLAYWRIGHT_BASE_URL ??
`http://localhost:${process.env.PROXY_HTTP_PUBLISHED_PORT ?? "8888"}`,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+94
View File
@@ -0,0 +1,94 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"name": "@zitadel/compose",
"targets": {
"test-config": {
"description": "Validates that all compose file combinations parse correctly (no Docker daemon needed).",
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"parallel": false,
"commands": [
"docker compose --env-file .env.example -f docker-compose.yml config --quiet",
"docker compose --env-file .env.example -f docker-compose.yml -f docker-compose.mode-letsencrypt.yml config --quiet",
"docker compose --env-file .env.example -f docker-compose.yml -f docker-compose.mode-external-tls.yml config --quiet",
"docker compose --env-file .env.example -f docker-compose.yml -f docker-compose.mode-local-tls.yml config --quiet",
"docker compose --env-file .env.example -f docker-compose.yml -f docker-compose.prodlike.yml config --quiet"
]
},
"inputs": [
"{projectRoot}/docker-compose.yml",
"{projectRoot}/docker-compose.mode-*.yml",
"{projectRoot}/docker-compose.prodlike.yml",
"{projectRoot}/.env.example"
]
},
"test-run": {
"description": "Starts the full compose stack using locally-built images. Waits until all containers are healthy before exiting.",
"dependsOn": [
"@zitadel/api:pack",
"@zitadel/login:pack"
],
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"parallel": false,
"commands": [
"docker compose --env-file .env.test -f docker-compose.yml -f docker-compose.test.yml -p zitadel-compose-test down --volumes --remove-orphans",
"docker compose --env-file .env.test -f docker-compose.yml -f docker-compose.test.yml -p zitadel-compose-test up --force-recreate --wait"
]
}
},
"test-e2e": {
"description": "Runs the Playwright test suite (wiring.spec.ts + smoke.spec.ts) against the running compose stack via localhost:8888 through Traefik. Covers login, console, OIDC, SAML, API v1 REST, gRPC h2c, gRPC-web, API v2 REST (gRPC-gateway JSON) HTTP/1.1 and HTTP/2. Run 'pnpm exec playwright install chromium' once before using this target.",
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "pnpm exec playwright test --config playwright.config.ts"
},
"inputs": [
"{projectRoot}/playwright.config.ts",
"{projectRoot}/tests/**",
"{projectRoot}/.env.test",
"{projectRoot}/package.json"
]
},
"test": {
"description": "Lightweight test target safe for nx affected. Validates compose config syntax only (no Docker daemon needed).",
"dependsOn": [
"test-config"
]
},
"test-full": {
"description": "Builds local images, starts the compose stack, runs Playwright wiring tests (login, console, OIDC, SAML, API v1 REST, gRPC h2c, gRPC-web, API v2 REST HTTP/1.1 and HTTP/2) and the browser login smoke test through Traefik, then tears down.",
"dependsOn": [
"test-config",
"test-run"
],
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"parallel": false,
"commands": [
"pnpm exec playwright install chromium",
"r=0; nx run @zitadel/compose:test-e2e || r=$?; nx run @zitadel/compose:stop; exit $r"
]
},
"inputs": [
"{projectRoot}/docker-compose.yml",
"{projectRoot}/docker-compose.test.yml",
"{projectRoot}/.env.test",
"{projectRoot}/playwright.config.ts",
"{projectRoot}/tests/**"
]
},
"stop": {
"description": "Tears down the compose smoke-test stack and removes all volumes.",
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "docker compose --env-file .env.test -f docker-compose.yml -f docker-compose.test.yml -p zitadel-compose-test down --volumes"
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Compose stack wiring smoke tests
*
* PURPOSE: verify that all compose services are correctly wired together.
* These are INFRASTRUCTURE tests, not login-feature tests.
* They answer: "does the compose stack work end-to-end?"
*
* Test path: browser → Traefik (port 8888) → zitadel-login → zitadel-api → postgres
* A single successful login proves every link in that chain is functioning.
*
* For feature-level login tests (MFA, OIDC flows, IdP, etc.) see:
* apps/login/acceptance/ – run via @zitadel/login:test-acceptance
*
* Credentials are read from SMOKE_TEST_ADMIN_USERNAME / SMOKE_TEST_ADMIN_PASSWORD
* (set in .env.test). The fallback values below match the defaults in .env.test and
* docker-compose.test.yml — they are intentional so the test works out-of-the-box
* without any extra configuration. If you change the seeded admin password, update
* .env.test accordingly.
* The initial admin password is seeded via ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD
* in docker-compose.test.yml.
*/
import { expect, test } from "@playwright/test";
const username =
process.env.SMOKE_TEST_ADMIN_USERNAME ?? "[email protected]";
const password = process.env.SMOKE_TEST_ADMIN_PASSWORD ?? "Password1!";
test.describe("compose stack wiring", () => {
test("admin can complete username/password login (proves Traefik → Login → API → DB chain)", async ({
page,
}) => {
// Step 1 – enter login name.
// Use a long timeout for the first navigation: even though the compose stack
// is healthy, ZITADEL's LoginName page makes server-side gRPC calls to the
// API on first render and can be slow when the instance is still warming up.
await page.goto("/ui/v2/login/loginname");
// The login page renders two inputs with the same test ID (one with autofocus,
// one without — likely an accessibility duplicate). Use .first() to avoid a
// strict-mode violation and target the active, focused input.
const usernameInput = page.getByTestId("username-text-input").first();
await usernameInput.waitFor({ state: "visible", timeout: 120_000 });
await usernameInput.fill(username);
// Wait for the button to be enabled before clicking — the page may still be
// hydrating when the input becomes visible.
await page.getByTestId("submit-button").first().waitFor({ state: "visible" });
await page.getByTestId("submit-button").first().click();
// Step 2 – enter password.
// Wait for the password page to fully render before typing; clicking submit
// above triggers a navigation and the input must be present and interactive.
const passwordInput = page.getByTestId("password-text-input").first();
await passwordInput.waitFor({ state: "visible", timeout: 30_000 });
await passwordInput.fill(password);
await page.getByTestId("submit-button").first().waitFor({ state: "visible" });
await page.getByTestId("submit-button").first().click();
// Step 3 – assert signed-in page (ZITADEL redirects here after a successful
// direct login that has no original auth-request to return to).
await expect(page).toHaveURL(/signedin/, { timeout: 30_000 });
});
});
+558
View File
@@ -0,0 +1,558 @@
/**
* Compose stack wiring tests — per-service reachability through Traefik
*
* PURPOSE: verify that every service and protocol is correctly routed through
* the Traefik reverse proxy. These are INFRASTRUCTURE tests, not feature tests.
* They answer: "is this service alive and reachable through the proxy?"
*
* Services covered:
* - Login UI (zitadel-login, Next.js, /ui/v2/login)
* - Console (zitadel-api, Angular SPA, /ui/console)
* - OIDC (zitadel-api, /.well-known/openid-configuration + /oauth/v2/keys)
* - SAML (zitadel-api, /saml/v2/metadata)
* - Root redirect (Traefik replacepath middleware → /ui/v2/login/)
*
* API protocol × transport matrix (all via Traefik catch-all, priority 100):
* V1 (AdminService/Healthz — unauthenticated, expects success):
* - REST json HTTP/1.1 /admin/v1/healthz
* - REST json h2c /admin/v1/healthz
* - gRPC-web proto HTTP/1.1 /zitadel.admin.v1.AdminService/Healthz
* - gRPC-web json HTTP/1.1 /zitadel.admin.v1.AdminService/Healthz
* - gRPC proto h2c /zitadel.admin.v1.AdminService/Healthz
* - gRPC json h2c /zitadel.admin.v1.AdminService/Healthz
*
* V2 (SessionService/ListSessions — requires auth, expects 401):
* Three access styles, each × HTTP/1.1 and h2c:
*
* 1. REST-style path application/json
* POST /v2/sessions (gRPC-gateway REST mapping)
*
* 2. gRPC-style path application/json (Connect unary JSON)
* POST /zitadel.session.v2.SessionService/ListSessions
* Handled by the Connect handler; application/json = Connect unary.
* Note: application/connect+json is streaming-only (Connect spec).
*
* 3. gRPC-style path application/proto (Connect unary proto)
* POST /zitadel.session.v2.SessionService/ListSessions
* Handled by the Connect handler; application/proto = Connect unary.
* Note: application/connect+proto is streaming-only (Connect spec).
*
* 4. gRPC-style path application/grpc+proto / application/grpc+json (gRPC native, h2c only)
* POST /zitadel.session.v2.SessionService/ListSessions
*
* For end-to-end browser login flow (proves Traefik → Login → API → DB chain):
* see smoke.spec.ts
*
* For feature-level login tests (MFA, OIDC flows, IdPs, etc.):
* see apps/login/acceptance/ — run via @zitadel/login:test-acceptance
*/
import { expect, test, type APIRequestContext, type TestInfo } from "@playwright/test";
import http2 from "node:http2";
import { Buffer } from "node:buffer";
// ---------------------------------------------------------------------------
// HTTP/2 (h2c) helpers
// ---------------------------------------------------------------------------
type H2Response = {
status: number;
headers: Record<string, string>;
body: Buffer;
trailers: Record<string, string>;
};
function h2Request(
baseUrl: string,
method: string,
path: string,
reqHeaders: Record<string, string>,
body?: Buffer | string,
): Promise<H2Response> {
return new Promise((resolve, reject) => {
const client = http2.connect(baseUrl);
const timeout = setTimeout(() => {
client.destroy();
reject(new Error(`h2c ${method} ${path} timed out`));
}, 30_000);
client.once("error", (err) => {
clearTimeout(timeout);
reject(err);
});
const req = client.request({
":method": method,
":path": path,
":scheme": "http",
...reqHeaders,
});
if (body !== undefined) req.write(body);
req.end();
let status = 0;
const chunks: Buffer[] = [];
const trailers: Record<string, string> = {};
const responseHeaders: Record<string, string> = {};
req.on("response", (hdrs) => {
status = Number(hdrs[":status"]);
for (const [k, v] of Object.entries(hdrs)) {
if (!k.startsWith(":")) responseHeaders[k] = String(v);
}
});
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("trailers", (thdrs) => {
for (const [k, v] of Object.entries(thdrs)) {
trailers[k] = String(v);
}
});
req.on("end", () => {
clearTimeout(timeout);
client.close();
resolve({
status,
headers: responseHeaders,
body: Buffer.concat(chunks),
trailers,
});
});
req.on("error", (err) => {
clearTimeout(timeout);
client.close();
reject(err);
});
});
}
function h2Post(
baseUrl: string,
path: string,
reqHeaders: Record<string, string>,
body?: Buffer | string,
): Promise<H2Response> {
return h2Request(baseUrl, "POST", path, reqHeaders, body);
}
function h2Get(
baseUrl: string,
path: string,
reqHeaders: Record<string, string> = {},
): Promise<H2Response> {
return h2Request(baseUrl, "GET", path, reqHeaders);
}
// ---------------------------------------------------------------------------
// gRPC framing helpers (RFC: https://grpc.github.io/grpc/core/md_doc_PROTOCOL-HTTP2.html)
// Frame: [compressed (1 byte)] [message length (4 bytes BE)] [message bytes]
// ---------------------------------------------------------------------------
/** Wrap proto bytes in a gRPC length-prefixed frame. */
function grpcFrame(protoBytes: Buffer = Buffer.alloc(0)): Buffer {
const frame = Buffer.allocUnsafe(5 + protoBytes.length);
frame[0] = 0; // compressed-flag = false
frame.writeUInt32BE(protoBytes.length, 1);
protoBytes.copy(frame, 5);
return frame;
}
/** Parse the first gRPC response frame and return the proto body bytes. */
function parseGrpcFrame(buf: Buffer): Buffer {
if (buf.length < 5) throw new Error(`gRPC response frame too short (${buf.length} bytes)`);
const len = buf.readUInt32BE(1);
if (buf.length < 5 + len) throw new Error(`gRPC response frame truncated: expected ${5 + len} bytes, got ${buf.length}`);
return buf.subarray(5, 5 + len);
}
/** Parse gRPC-web inline trailers from the response body.
* After the data frame(s), gRPC-web appends a trailer frame with flag 0x80. */
function parseGrpcWebTrailers(buf: Buffer): Record<string, string> {
// Skip past data frames to find the trailer frame (flag byte 0x80)
let offset = 0;
while (offset < buf.length) {
if (offset + 5 > buf.length) {
throw new Error(`Truncated gRPC-web frame at offset ${offset}: need 5 header bytes but only ${buf.length - offset} remain`);
}
const flag = buf[offset];
const len = buf.readUInt32BE(offset + 1);
if (flag === 0x80) {
// Trailer frame: ASCII key-value pairs separated by \r\n
const text = buf.subarray(offset + 5, offset + 5 + len).toString("utf8");
const trailers: Record<string, string> = {};
for (const line of text.split("\r\n")) {
const colon = line.indexOf(":");
if (colon > 0) {
trailers[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
}
}
return trailers;
}
offset += 5 + len;
}
return {};
}
// ---------------------------------------------------------------------------
// Soft-fail wrapper — allows new/experimental permutations to warn on failure
// without breaking CI. Tests marked knownWorking: false log a warning instead
// of hard-failing.
// ---------------------------------------------------------------------------
function softTest(
label: string,
knownWorking: boolean,
testFn: (args: { request: APIRequestContext; testInfo: TestInfo }) => Promise<void>,
) {
if (knownWorking) {
test(label, async ({ request }, testInfo) => {
await testFn({ request, testInfo });
});
} else {
test(label, async ({ request }, testInfo) => {
try {
await testFn({ request, testInfo });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
testInfo.annotations.push({
type: "fixme",
description: `[soft-fail] ${msg}`,
});
// eslint-disable-next-line no-console
console.warn(`⚠ SOFT-FAIL: ${label}\n ${msg}`);
}
});
}
}
// ---------------------------------------------------------------------------
// Protocol × transport permutation types
// ---------------------------------------------------------------------------
type Transport = "http1" | "h2c";
type Protocol = "rest" | "grpc-web" | "grpc" | "connectrpc";
type Encoding = "proto" | "json";
type ApiPermutation = {
protocol: Protocol;
encoding: Encoding;
transport: Transport;
/** Whether this permutation is already known to work (hard-fail on regression). */
knownWorking: boolean;
};
// ---------------------------------------------------------------------------
// V1 API permutations — AdminService/Healthz (unauthenticated, expects 200/OK)
// ---------------------------------------------------------------------------
const V1_GRPC_PATH = "/zitadel.admin.v1.AdminService/Healthz";
const V1_REST_PATH = "/admin/v1/healthz";
const v1Permutations: ApiPermutation[] = [
// REST
{ protocol: "rest", encoding: "json", transport: "http1", knownWorking: true },
{ protocol: "rest", encoding: "json", transport: "h2c", knownWorking: true },
// gRPC-web (HTTP/1.1 only — grpc-web is designed for browser HTTP/1.1)
{ protocol: "grpc-web", encoding: "proto", transport: "http1", knownWorking: true },
{ protocol: "grpc-web", encoding: "json", transport: "http1", knownWorking: true },
// gRPC native (h2c only — requires HTTP/2)
{ protocol: "grpc", encoding: "proto", transport: "h2c", knownWorking: true },
{ protocol: "grpc", encoding: "json", transport: "h2c", knownWorking: true },
];
// ---------------------------------------------------------------------------
// V2 API permutations — SessionService/ListSessions (requires auth → 401)
// ---------------------------------------------------------------------------
const V2_GRPC_PATH = "/zitadel.session.v2.SessionService/ListSessions";
const V2_REST_PATH = "/v2/sessions";
const v2Permutations: ApiPermutation[] = [
// REST (gRPC-gateway)
{ protocol: "rest", encoding: "json", transport: "http1", knownWorking: true },
{ protocol: "rest", encoding: "json", transport: "h2c", knownWorking: true },
// ConnectRPC (v2 only — v1 does not register Connect handlers)
{ protocol: "connectrpc", encoding: "proto", transport: "http1", knownWorking: true },
{ protocol: "connectrpc", encoding: "json", transport: "http1", knownWorking: true },
{ protocol: "connectrpc", encoding: "proto", transport: "h2c", knownWorking: true },
{ protocol: "connectrpc", encoding: "json", transport: "h2c", knownWorking: true },
// gRPC native (h2c only)
{ protocol: "grpc", encoding: "proto", transport: "h2c", knownWorking: true },
{ protocol: "grpc", encoding: "json", transport: "h2c", knownWorking: true },
];
// ---------------------------------------------------------------------------
// Tests — service-level reachability (non-API)
// ---------------------------------------------------------------------------
// The base URL comes from playwright.config.ts (PROXY_HTTP_PUBLISHED_PORT, default 8888).
// We resolve it here so h2Post()/h2Get() can connect to the same host.
const BASE =
process.env.PLAYWRIGHT_BASE_URL ??
`http://localhost:${process.env.PROXY_HTTP_PUBLISHED_PORT ?? "8888"}`;
test.describe("traefik routing", () => {
test("GET / → redirects to /ui/v2/login/", async ({ request }) => {
// Playwright follows redirects by default; verify the final destination.
// Note: Next.js normalises /ui/v2/login/ → /ui/v2/login (strips trailing slash).
const resp = await request.get("/");
expect(resp.url()).toContain("/ui/v2/login");
expect(resp.status()).toBe(200);
});
});
test.describe("login ui", () => {
test("GET /ui/v2/login/healthy → 200", async ({ request }) => {
const resp = await request.get("/ui/v2/login/healthy");
expect(resp.status()).toBe(200);
});
test("GET /ui/v2/login/ → 200 HTML", async ({ request }) => {
const resp = await request.get("/ui/v2/login/");
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain("<html");
});
});
test.describe("console", () => {
test("GET /ui/console/ → 200 HTML (Angular SPA shell)", async ({ request }) => {
const resp = await request.get("/ui/console/");
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain("<html");
});
});
test.describe("oidc", () => {
test("GET /.well-known/openid-configuration → valid discovery document", async ({ request }) => {
const resp = await request.get("/.well-known/openid-configuration");
expect(resp.status()).toBe(200);
const doc = await resp.json();
expect(doc).toHaveProperty("issuer");
expect(doc).toHaveProperty("authorization_endpoint");
expect(doc).toHaveProperty("token_endpoint");
expect(doc).toHaveProperty("jwks_uri");
});
test("GET /oauth/v2/keys → JWKS with keys array", async ({ request }) => {
const resp = await request.get("/oauth/v2/keys");
expect(resp.status()).toBe(200);
const body = await resp.json();
expect(body).toHaveProperty("keys");
expect(Array.isArray(body.keys)).toBe(true);
});
});
test.describe("saml", () => {
test("GET /saml/v2/metadata → EntityDescriptor XML", async ({ request }) => {
const resp = await request.get("/saml/v2/metadata");
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain("EntityDescriptor");
});
});
test.describe("api v1 rest extras", () => {
// Additional REST tests beyond the matrix: all three v1 services + /api alias.
for (const svc of ["management", "admin", "auth"]) {
test(`GET /${svc}/v1/healthz → 200`, async ({ request }) => {
const resp = await request.get(`/${svc}/v1/healthz`);
expect(resp.status()).toBe(200);
});
}
test("GET /api/admin/v1/healthz via /api alias → 200 (tests Traefik strip-prefix middleware)", async ({ request }) => {
const resp = await request.get("/api/admin/v1/healthz");
expect(resp.status()).toBe(200);
});
});
// ---------------------------------------------------------------------------
// API V1 protocol × transport matrix
//
// Endpoint: AdminService/Healthz (unauthenticated)
// Expected: success (REST → 200, gRPC → grpc-status 0, gRPC-web → grpc-status 0)
// ---------------------------------------------------------------------------
test.describe("api v1 protocol matrix", () => {
for (const p of v1Permutations) {
const label = `v1 ${p.protocol} ${p.encoding} ${p.transport}`;
softTest(label, p.knownWorking, async ({ request }) => {
switch (p.protocol) {
// ----- REST (gRPC-gateway) -----
case "rest": {
if (p.transport === "http1") {
const resp = await request.get(V1_REST_PATH);
expect(resp.status()).toBe(200);
} else {
const resp = await h2Get(BASE, V1_REST_PATH);
expect(resp.status).toBe(200);
}
break;
}
// ----- gRPC-web (HTTP/1.1) -----
case "grpc-web": {
const contentType =
p.encoding === "proto"
? "application/grpc-web+proto"
: "application/grpc-web+json";
// For json encoding, Healthz request is empty → JSON body is '{}'
const reqBody =
p.encoding === "proto"
? grpcFrame()
: grpcFrame(Buffer.from("{}"));
const resp = await request.post(V1_GRPC_PATH, {
headers: {
"content-type": contentType,
"x-grpc-web": "1",
},
data: reqBody,
});
expect(resp.status()).toBe(200);
// For proto encoding: parse inline trailers and assert grpc-status 0.
// For json encoding: the Go gRPC server has no JSON codec registered and
// returns no well-formed gRPC-web trailer frame. HTTP 200 alone proves
// the request was routed to the backend by Traefik.
if (p.encoding === "proto") {
const buf = Buffer.from(await resp.body());
const trailers = parseGrpcWebTrailers(buf);
expect(trailers["grpc-status"]).toBe("0");
}
break;
}
// ----- gRPC native (h2c) -----
case "grpc": {
const contentType =
p.encoding === "proto"
? "application/grpc+proto"
: "application/grpc+json";
const reqBody =
p.encoding === "proto"
? grpcFrame()
: grpcFrame(Buffer.from("{}"));
const resp = await h2Post(BASE, V1_GRPC_PATH, {
"content-type": contentType,
te: "trailers",
}, reqBody);
expect(resp.status).toBe(200);
// For proto encoding: assert OK (grpc-status 0).
// For json encoding: the Go gRPC server has no JSON codec registered,
// so it returns INTERNAL (13). We only assert the status is defined,
// which proves the request was routed to the backend.
if (p.encoding === "proto") {
expect(resp.trailers["grpc-status"]).toBe("0");
} else {
expect(resp.trailers["grpc-status"]).toBeDefined();
}
break;
}
default:
throw new Error(`unexpected protocol: ${p.protocol}`);
}
});
}
});
// ---------------------------------------------------------------------------
// API V2 protocol × transport matrix
//
// Endpoint: SessionService/ListSessions (requires auth)
// Expected: 401 / UNAUTHENTICATED
// - REST (gRPC-gateway): HTTP 401, body.code === 16
// - ConnectRPC unary: HTTP 401, body.code === "unauthenticated"
// - gRPC native: HTTP 200, grpc-status trailer === "16"
// ---------------------------------------------------------------------------
test.describe("api v2 protocol matrix", () => {
for (const p of v2Permutations) {
const label = `v2 ${p.protocol} ${p.encoding} ${p.transport}`;
softTest(label, p.knownWorking, async ({ request }) => {
switch (p.protocol) {
// ----- REST (gRPC-gateway) -----
case "rest": {
if (p.transport === "http1") {
const resp = await request.post(V2_REST_PATH, {
headers: { "content-type": "application/json" },
data: "{}",
});
expect(resp.status()).toBe(401);
const body = await resp.json();
expect(body.code).toBe(16); // UNAUTHENTICATED
} else {
const resp = await h2Post(BASE, V2_REST_PATH, {
"content-type": "application/json",
}, "{}");
expect(resp.status).toBe(401);
const body = JSON.parse(resp.body.toString("utf8"));
expect(body.code).toBe(16);
}
break;
}
// ----- Connect protocol unary (v2 only — v1 does not register Connect handlers) -----
// Per https://connectrpc.com/docs/multi-protocol/:
// application/json → Connect unary JSON (gRPC-style path)
// application/proto → Connect unary proto (gRPC-style path)
// application/connect+json and application/connect+proto are STREAMING-only.
// Connect unary uses no gRPC length-prefix framing; body is plain JSON or proto bytes.
// Error response format: { "code": "unauthenticated", ... }
case "connectrpc": {
const contentType =
p.encoding === "proto"
? "application/proto"
: "application/json";
// For connect+json, send a JSON body; for connect+proto, send empty bytes
const reqBody = p.encoding === "json" ? "{}" : Buffer.alloc(0);
if (p.transport === "http1") {
const resp = await request.post(V2_GRPC_PATH, {
headers: { "content-type": contentType },
data: reqBody,
});
// ConnectRPC returns the HTTP status directly
expect(resp.status()).toBe(401);
const body = await resp.json();
expect(body.code).toBe("unauthenticated");
} else {
const resp = await h2Post(BASE, V2_GRPC_PATH, {
"content-type": contentType,
}, reqBody);
expect(resp.status).toBe(401);
const body = JSON.parse(resp.body.toString("utf8"));
expect(body.code).toBe("unauthenticated");
}
break;
}
// ----- gRPC native (h2c) -----
case "grpc": {
const contentType =
p.encoding === "proto"
? "application/grpc+proto"
: "application/grpc+json";
const reqBody =
p.encoding === "proto"
? grpcFrame()
: grpcFrame(Buffer.from("{}"));
const resp = await h2Post(BASE, V2_GRPC_PATH, {
"content-type": contentType,
te: "trailers",
}, reqBody);
// gRPC always returns HTTP 200; error is in grpc-status trailer
expect(resp.status).toBe(200);
expect(resp.trailers["grpc-status"]).toBe("16"); // UNAUTHENTICATED
break;
}
default:
throw new Error(`unexpected protocol: ${p.protocol}`);
}
});
}
});
+6
View File
@@ -0,0 +1,6 @@
tls:
stores:
default:
defaultCertificate:
certFile: /certs/local.crt
keyFile: /certs/local.key
+9
View File
@@ -631,6 +631,15 @@ importers:
specifier: 5.9.2
version: 5.9.2
deploy/compose:
devDependencies:
'@playwright/test':
specifier: ^1.58.0
version: 1.58.0
dotenv:
specifier: ^16.0.0
version: 16.6.1
packages/zitadel-client:
dependencies:
'@bufbuild/protobuf':
+1
View File
@@ -3,6 +3,7 @@ packages:
- tests/*
- packages/*
- apps/*
- deploy/compose
exclude:
- benchmark # because of the node 18 or 20 dependency
+1 -1
View File
@@ -22,7 +22,7 @@
"@zitadel/api:pack-linux-*",
"@zitadel/api:pack-darwin-*",
"@zitadel/api:pack-windows-*",
"@zitadel/login:pack"
"@zitadel/login:pack-archive"
],
"executor": "nx:run-commands",
"options": {