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. - `proto/AGENTS.md`: API schema and generation guidance.
- `packages/AGENTS.md`: Shared client/proto package workflows. - `packages/AGENTS.md`: Shared client/proto package workflows.
- `tests/functional-ui/AGENTS.md`: Cypress functional UI test 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 ## Behavior
- Before Go-related work, inspect `go.mod` for required Go version/toolchain. - 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. - 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. - 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. - 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 # API layer
- grpc - grpc
- proto - proto
# Deployment
- deploy
# Cross-cutting # Cross-cutting
- build - build
- ci - 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). 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." 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 ## Command Rules
Run commands from the repository root. 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. - Known exception: `@zitadel/console` has no configured `test` target.
## Verified Common Targets ## Verified Common Targets
- `@zitadel/api`: `prod`, `build`, `generate`, `generate-install`, `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`, `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/docs`: `dev`, `build`, `generate`, `install-proto-plugins`, `check-links`, `check-types`, `test`, `lint`
- `@zitadel/console`: `dev`, `build`, `generate`, `install-proto-plugins`, `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 ## 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. 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 (all)**: `pnpm nx run @zitadel/api:test`
- **Test (unit)**: `pnpm nx run @zitadel/api:test-unit` - **Test (unit)**: `pnpm nx run @zitadel/api:test-unit`
- **Test (integration)**: `pnpm nx run @zitadel/api:test-integration` - **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 ## Generation Notes
- `@zitadel/api:generate` can update generated, tracked files (stubs/assets/statik). Run it intentionally. - `@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. # This stage produces a runnable image that can be used for debugging.
FROM debian:latest AS builder FROM debian:latest AS builder
ARG TARGETPLATFORM 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 RUN apt-get update && apt-get install ca-certificates -y
COPY apps/api/entrypoint.sh /app/entrypoint.sh 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 && \ RUN useradd -s "" --home / zitadel && \
chown zitadel /app/zitadel && \ chown zitadel /app/zitadel && \
+28
View File
@@ -83,6 +83,34 @@
"{workspaceRoot}/.artifacts/bin/*/*/zitadel.local" "{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": { "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.", "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": [ "dependsOn": [
+177 -66
View File
@@ -1,90 +1,201 @@
--- ---
title: Set up Zitadel with Docker Compose title: Set up ZITADEL with Docker Compose
description: "Deploy a ZITADEL instance locally using Docker Compose for rapid testing and development."
sidebar_label: 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 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 DefaultUser from './_defaultuser.mdx'
import Next from './_next.mdx' import Next from './_next.mdx'
This guide is the entrypoint for running the Zitadel platform locally for the first time. 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.
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).
## Prerequisites ## 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 See [Requirements](/self-hosting/manage/requirements) for supported database, cache, and proxy versions.
- Docker 28.3.2
- Docker Compose v2.38.2
- curl 8.5.0
## 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
```shell mkdir zitadel-compose && cd zitadel-compose
curl -L https://raw.githubusercontent.com/zitadel/zitadel/main/apps/docs/content/self-hosting/deploy/docker-compose.yaml -o docker-compose.yaml
``` # Download the compose file and example environment
2. Make sure the containers use the latest image versions. curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.yml &&
```shell curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/.env.example
docker compose pull
``` # Create your environment file and start
3. Run the PostgreSQL database, the Zitadel API, and the Zitadel login. cp .env.example .env
```shell docker compose up -d --wait
docker compose up --detach --wait ```
```
4. Verify the containers are running and healthy. That's it. Visit [http://localhost:8080](http://localhost:8080) to open the login screen.
```shell
docker compose ps
```
<DefaultUser components={props.components} /> <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: ## Stage 2 — Homelab / Semi-Production
The comments give context to the used variables and show examples for commonly used configuration variants.
<details> ### Use your own domain
<summary>docker-compose.yaml</summary>
<DynamicCodeBlock lang="yaml" code={DockerCompose} />
</details>
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` ```dotenv
- **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) ZITADEL_DOMAIN=auth.example.com
- **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)
For more detailed recommendations, read the [Zitadel Production Setup Guide](/self-hosting/manage/production). ### Enable TLS
<details> Pick a TLS mode and download the matching overlay file:
<summary>Read this if the environment uses the login v1</summary>
<p> | Mode | Overlay file | When to use |
The login v2 is the next generation of the Zitadel login. |------|-------------|-------------|
Its code and deployment are easily customizable. | Let's Encrypt | `docker-compose.mode-letsencrypt.yml` | Public domain, automatic certs |
Unlike the login v1, it runs in its own process. | External TLS | `docker-compose.mode-external-tls.yml` | Behind a load balancer, CDN, or WAF that terminates TLS |
The login v2 is enabled by default in new installations. | Local TLS | `docker-compose.mode-local-tls.yml` | Self-signed certs for LAN-only access |
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. Download the overlay you need, then start with both files:
The following steps give guidance on how to enable the login v2 in an environment.
</p> ```shell
<ol> # Download the overlay (replace with your chosen mode)
<li>Read the login v2 related comments in the docker-compose.yaml.</li> curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.mode-letsencrypt.yml
<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> # Start with the overlay
<li>Restart the login process: `docker compose restart login`</li> docker compose --env-file .env \
<li><a href="../../self-hosting/manage/login-client#require-login-v2">Enable the Login UI for all users</a></li> -f docker-compose.yml \
</ol> -f docker-compose.mode-letsencrypt.yml \
</details> 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 ## upstream sent too big header while reading response header from upstream
<UpstreamHeader components={props.components} /> <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: environment:
- POSTGRES_USER=root - POSTGRES_USER=root
- POSTGRES_PASSWORD=postgres - POSTGRES_PASSWORD=postgres
- POSTGRES_DB=zitadel
networks: networks:
- 'zitadel' - 'zitadel'
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready", "-d", "db_prod"] test: ["CMD-SHELL", "pg_isready -d zitadel -U root"]
interval: 10s interval: 10s
timeout: 60s timeout: 30s
retries: 5 retries: 5
start_period: 10s start_period: 20s
volumes: volumes:
- 'data:/var/lib/postgresql/data:rw' - '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> <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 ## More Information
<ProxyGuideMore components={props.components} ></ProxyGuideMore> <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." 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: Check out one of the following guides to configure your favorite reverse proxy:
- [Traefik](/self-hosting/manage/reverseproxy/traefik) - [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](/self-hosting/manage/reverseproxy/cloudflare)
- [Cloudflare Tunnel](/self-hosting/manage/reverseproxy/cloudflare_tunnel) - [Cloudflare Tunnel](/self-hosting/manage/reverseproxy/cloudflare_tunnel)
- [Fronting Zitadel Cloud](/self-hosting/manage/reverseproxy/zitadel_cloud) - [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", label: "Production & Operations",
collapsed: false, collapsed: false,
items: [ items: [
"self-hosting/manage/requirements",
"self-hosting/manage/production", "self-hosting/manage/production",
"self-hosting/manage/productionchecklist", "self-hosting/manage/productionchecklist",
"self-hosting/manage/usage_control", "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 (all)**: `pnpm nx run @zitadel/login:test`
- **Test (unit)**: `pnpm nx run @zitadel/login:test-unit` - **Test (unit)**: `pnpm nx run @zitadel/login:test-unit`
- **Test (integration)**: `pnpm nx run @zitadel/login:test-integration` - **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" "{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": { "build-vercel": {
"description": "Builds the Next.js Login application for Vercel deployment", "description": "Builds the Next.js Login application for Vercel deployment",
"cache": true, "cache": true,
@@ -152,7 +168,21 @@
"description": "Stops the gRPC mock container used for integration tests.", "description": "Stops the gRPC mock container used for integration tests.",
"command": "nx run @zitadel/login-api-mock:down" "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", "description": "Packages the standalone Login application build into an archive",
"dependsOn": [ "dependsOn": [
"build" "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 specifier: 5.9.2
version: 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: packages/zitadel-client:
dependencies: dependencies:
'@bufbuild/protobuf': '@bufbuild/protobuf':
+1
View File
@@ -3,6 +3,7 @@ packages:
- tests/* - tests/*
- packages/* - packages/*
- apps/* - apps/*
- deploy/compose
exclude: exclude:
- benchmark # because of the node 18 or 20 dependency - benchmark # because of the node 18 or 20 dependency
+1 -1
View File
@@ -22,7 +22,7 @@
"@zitadel/api:pack-linux-*", "@zitadel/api:pack-linux-*",
"@zitadel/api:pack-darwin-*", "@zitadel/api:pack-darwin-*",
"@zitadel/api:pack-windows-*", "@zitadel/api:pack-windows-*",
"@zitadel/login:pack" "@zitadel/login:pack-archive"
], ],
"executor": "nx:run-commands", "executor": "nx:run-commands",
"options": { "options": {