mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
co-authored by
Mridang Agarwalla
parent
54095b64b1
commit
bac224c56d
@@ -14,6 +14,7 @@ You are working in the ZITADEL monorepo. This codebase uses specific conventions
|
||||
- `proto/AGENTS.md`: API schema and generation guidance.
|
||||
- `packages/AGENTS.md`: Shared client/proto package workflows.
|
||||
- `tests/functional-ui/AGENTS.md`: Cypress functional UI test workflows.
|
||||
- `deploy/compose/AGENTS.md`: Docker Compose deployment invariants, file conventions, and rejected alternatives.
|
||||
|
||||
## Behavior
|
||||
- Before Go-related work, inspect `go.mod` for required Go version/toolchain.
|
||||
@@ -21,3 +22,4 @@ You are working in the ZITADEL monorepo. This codebase uses specific conventions
|
||||
- For backend changes, note we are in transition: relational data is becoming the system of record, while event writes are still required for history/audit.
|
||||
- Respect terminology defined in the `Technical Glossary` section of `AGENTS.md` when generating UI text or documentation.
|
||||
- Distinguish between `apps/login` (Next.js) and `console` (Angular) when suggesting frontend code.
|
||||
- For deployment-related work, consult the directory-specific agents files (e.g., `deploy/compose/AGENTS.md`) for invariants, file conventions, and rejected alternatives.
|
||||
|
||||
@@ -50,6 +50,8 @@ scopes:
|
||||
# API layer
|
||||
- grpc
|
||||
- proto
|
||||
# Deployment
|
||||
- deploy
|
||||
# Cross-cutting
|
||||
- build
|
||||
- ci
|
||||
|
||||
@@ -76,6 +76,22 @@ If a translation is requested for a language not listed above, follow these prio
|
||||
3. **Priority 3 (Tenant):** If 'Instance' is ambiguous, use the local word for 'Tenant' (e.g., 租户 in Chinese).
|
||||
4. **Strict Ban:** NEVER use words that mean "an illustration", "a case", "a sample", or "an example."
|
||||
|
||||
### 5. Deployment Targets
|
||||
|
||||
ZITADEL supports multiple deployment methods. Each has its own directory and conventions:
|
||||
|
||||
| Target | Location | Status | Notes |
|
||||
|--------|----------|--------|-------|
|
||||
| Docker Compose | `deploy/compose/` | Supported | Single-node, graduated from quickstart to semi-production. See `deploy/compose/AGENTS.md` for directory-specific rules. |
|
||||
| Kubernetes (Helm) | External ([zitadel-charts](https://github.com/zitadel/zitadel-charts)) | Supported | Official Helm chart for production workloads. Docs at `apps/docs/content/self-hosting/deploy/kubernetes/`. |
|
||||
| apt/rpm packages | Planned | Not yet available | Future packaging target. |
|
||||
|
||||
When generating deployment-related content:
|
||||
- Docker Compose is the recommended path for **getting started** and **homelab/single-node** deployments
|
||||
- Kubernetes is the recommended path for **production** workloads
|
||||
- Always reference the correct deployment method for the user's context
|
||||
- The same `ZITADEL_*` environment variable model applies across all deployment methods
|
||||
|
||||
## Command Rules
|
||||
Run commands from the repository root.
|
||||
|
||||
@@ -85,10 +101,11 @@ Run commands from the repository root.
|
||||
- Known exception: `@zitadel/console` has no configured `test` target.
|
||||
|
||||
## Verified Common Targets
|
||||
- `@zitadel/api`: `prod`, `build`, `generate`, `generate-install`, `lint`, `test`, `test-unit`, `test-integration`
|
||||
- `@zitadel/login`: `dev`, `build`, `lint`, `test`, `test-unit`, `test-integration`
|
||||
- `@zitadel/api`: `prod`, `build`, `build-linux`, `pack`, `generate`, `generate-install`, `lint`, `test`, `test-unit`, `test-integration`
|
||||
- `@zitadel/login`: `dev`, `build`, `pack`, `lint`, `test`, `test-unit`, `test-integration`
|
||||
- `@zitadel/docs`: `dev`, `build`, `generate`, `install-proto-plugins`, `check-links`, `check-types`, `test`, `lint`
|
||||
- `@zitadel/console`: `dev`, `build`, `generate`, `install-proto-plugins`, `lint`
|
||||
- `@zitadel/compose`: `test-config`, `test-run`, `test-e2e`, `test`, `test-full`, `stop`
|
||||
|
||||
## Proto Plugin Binaries
|
||||
All proto plugins are installed to `.artifacts/bin/<GOOS>/<GOARCH>/` and Nx-cached. `generate` targets wire up the correct install dependency and prepend `.artifacts/bin/` to `$PATH` — no manual install step is needed.
|
||||
|
||||
@@ -17,6 +17,8 @@ The **API App** (`apps/api`) is the Nx application target for building and runni
|
||||
- **Test (all)**: `pnpm nx run @zitadel/api:test`
|
||||
- **Test (unit)**: `pnpm nx run @zitadel/api:test-unit`
|
||||
- **Test (integration)**: `pnpm nx run @zitadel/api:test-integration`
|
||||
- **Build Linux**: `pnpm nx run @zitadel/api:build-linux` — cross-compiles a Linux binary for Docker packaging
|
||||
- **Pack (Docker)**: `pnpm nx run @zitadel/api:pack` — builds a local Docker image `zitadel/zitadel:local`. Requires Docker daemon.
|
||||
|
||||
## Generation Notes
|
||||
- `@zitadel/api:generate` can update generated, tracked files (stubs/assets/statik). Run it intentionally.
|
||||
|
||||
+4
-1
@@ -2,11 +2,14 @@
|
||||
# This stage produces a runnable image that can be used for debugging.
|
||||
FROM debian:latest AS builder
|
||||
ARG TARGETPLATFORM
|
||||
# BINARY allows callers to pass a differently-named local build artifact.
|
||||
# Defaults to 'zitadel' for release builds; set to 'zitadel.local' for local dev builds.
|
||||
ARG BINARY=zitadel
|
||||
|
||||
RUN apt-get update && apt-get install ca-certificates -y
|
||||
|
||||
COPY apps/api/entrypoint.sh /app/entrypoint.sh
|
||||
COPY ./.artifacts/bin/${TARGETPLATFORM}/zitadel /app/zitadel
|
||||
COPY ./.artifacts/bin/${TARGETPLATFORM}/${BINARY} /app/zitadel
|
||||
|
||||
RUN useradd -s "" --home / zitadel && \
|
||||
chown zitadel /app/zitadel && \
|
||||
|
||||
@@ -83,6 +83,34 @@
|
||||
"{workspaceRoot}/.artifacts/bin/*/*/zitadel.local"
|
||||
]
|
||||
},
|
||||
"build-linux": {
|
||||
"description": "Compiles the Go-based API backend into a Linux executable binary for Docker packaging.",
|
||||
"dependsOn": [
|
||||
"generate",
|
||||
"build-console"
|
||||
],
|
||||
"command": "bash -c 'CGO_ENABLED=0 GOOS=linux go build -o .artifacts/bin/linux/$(go env GOARCH)/zitadel.local -ldflags=\"-s -w\"'",
|
||||
"inputs": [
|
||||
{
|
||||
"runtime": "go env GOARCH"
|
||||
},
|
||||
"runtime",
|
||||
{
|
||||
"dependentTasksOutputFiles": "internal/api/ui/console/static/**/*"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
"{workspaceRoot}/.artifacts/bin/linux/*/zitadel.local"
|
||||
]
|
||||
},
|
||||
"pack": {
|
||||
"description": "Builds a local Docker image for the ZITADEL API (zitadel/zitadel:local). Requires Docker daemon.",
|
||||
"dependsOn": [
|
||||
"build-linux"
|
||||
],
|
||||
"command": "bash -c 'docker build --build-arg TARGETPLATFORM=linux/$(go env GOARCH) --build-arg BINARY=zitadel.local -f apps/api/Dockerfile -t zitadel/zitadel:local .'",
|
||||
"cache": false
|
||||
},
|
||||
"generate": {
|
||||
"description": "Generates the code needed to start a full-featured API: gRPC and OpenAPI stubs, static files for the embedded login v1, asset routes and documentation.",
|
||||
"dependsOn": [
|
||||
|
||||
@@ -1,90 +1,201 @@
|
||||
---
|
||||
title: Set up Zitadel with Docker Compose
|
||||
description: "Deploy a ZITADEL instance locally using Docker Compose for rapid testing and development."
|
||||
title: Set up ZITADEL with Docker Compose
|
||||
sidebar_label: Docker Compose
|
||||
description: Deploy ZITADEL with Docker Compose — from a 2-minute localhost quickstart to a hardened homelab setup with TLS, caching, and observability.
|
||||
---
|
||||
|
||||
import NoteInstanceNotFound from './troubleshooting/_note_instance_not_found.mdx';
|
||||
import DockerCompose from './docker-compose.yaml';
|
||||
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
|
||||
|
||||
import Disclaimer from './_disclaimer.mdx'
|
||||
import DefaultUser from './_defaultuser.mdx'
|
||||
import Next from './_next.mdx'
|
||||
|
||||
This guide is the entrypoint for running the Zitadel platform locally for the first time.
|
||||
It is for demonstration and development purposes only and does not set up a production-ready and security hardened instance of Zitadel.
|
||||
Once Zitadel up is and running, learn more about a production setup in the [What's Next section](#next-steps).
|
||||
This guide takes you from zero to a running ZITADEL instance in minutes and then shows you how to harden it for a homelab or semi-production deployment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The setup is likely to work with other software versions too, but it is tested against the following environment:
|
||||
- Docker Engine 24+ with the Compose plugin (`docker compose`)
|
||||
- A machine with at least 2 GB RAM
|
||||
|
||||
- Ubuntu 24.04.1
|
||||
- Docker 28.3.2
|
||||
- Docker Compose v2.38.2
|
||||
- curl 8.5.0
|
||||
See [Requirements](/self-hosting/manage/requirements) for supported database, cache, and proxy versions.
|
||||
|
||||
## Docker compose
|
||||
## Stage 1 — Quickstart (2 minutes)
|
||||
|
||||
The following commands set up services for a PostgreSQL database, a Zitadel API and a Zitadel login:
|
||||
Download the two required files, copy the example config, and start:
|
||||
|
||||
1. Download the `docker-compose.yaml` file from the Zitadel repository:
|
||||
```shell
|
||||
curl -L https://raw.githubusercontent.com/zitadel/zitadel/main/apps/docs/content/self-hosting/deploy/docker-compose.yaml -o docker-compose.yaml
|
||||
```
|
||||
2. Make sure the containers use the latest image versions.
|
||||
```shell
|
||||
docker compose pull
|
||||
```
|
||||
3. Run the PostgreSQL database, the Zitadel API, and the Zitadel login.
|
||||
```shell
|
||||
docker compose up --detach --wait
|
||||
```
|
||||
4. Verify the containers are running and healthy.
|
||||
```shell
|
||||
docker compose ps
|
||||
```
|
||||
```shell
|
||||
mkdir zitadel-compose && cd zitadel-compose
|
||||
|
||||
# Download the compose file and example environment
|
||||
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.yml &&
|
||||
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/.env.example
|
||||
|
||||
# Create your environment file and start
|
||||
cp .env.example .env
|
||||
docker compose up -d --wait
|
||||
```
|
||||
|
||||
That's it. Visit [http://localhost:8080](http://localhost:8080) to open the login screen.
|
||||
|
||||
<DefaultUser components={props.components} />
|
||||
|
||||
## What's next
|
||||
<Callout type="info">
|
||||
The base stack runs: **Traefik** ([reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy)) → **ZITADEL API** (Go) + **ZITADEL Login** (Next.js) → **PostgreSQL**.
|
||||
All routing, including [gRPC over HTTP/2](/self-hosting/manage/http2), is handled automatically by Traefik — no extra configuration needed.
|
||||
</Callout>
|
||||
|
||||
Before proceeding, review the downloaded `docker-compose.yaml` file:
|
||||
The comments give context to the used variables and show examples for commonly used configuration variants.
|
||||
## Stage 2 — Homelab / Semi-Production
|
||||
|
||||
<details>
|
||||
<summary>docker-compose.yaml</summary>
|
||||
<DynamicCodeBlock lang="yaml" code={DockerCompose} />
|
||||
</details>
|
||||
### Use your own domain
|
||||
|
||||
Here are some natural steps forward to go from the current setup to production:
|
||||
Edit `.env` and set your real domain (see [Custom Domains](/self-hosting/manage/custom-domain) for details):
|
||||
|
||||
- **Use a different master key**: Set up an instance from scratch and use a different master key. For example, generate one by running `tr -dc A-Za-z0-9 </dev/urandom | head -c 32`
|
||||
- **Use files for secrets instead of environment variables, and don't commit them to Git**: Learn how to [configure the platform](/self-hosting/manage/configure)
|
||||
- **Run the containers as non-root users**: Remove the `user: "0"` lines from the `docker-compose.yaml` file.
|
||||
- **Configure a different external domain or IP**: Beware that the login only works with HTTPS on non-localhost domains. [Run and configure a reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy)
|
||||
- **Serve the API and the UI together on the same port**: [Run and configure a reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy)
|
||||
- **Encrypt Traffic**: [Run and configure a reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy)
|
||||
```dotenv
|
||||
ZITADEL_DOMAIN=auth.example.com
|
||||
```
|
||||
|
||||
For more detailed recommendations, read the [Zitadel Production Setup Guide](/self-hosting/manage/production).
|
||||
### Enable TLS
|
||||
|
||||
<details>
|
||||
<summary>Read this if the environment uses the login v1</summary>
|
||||
<p>
|
||||
The login v2 is the next generation of the Zitadel login.
|
||||
Its code and deployment are easily customizable.
|
||||
Unlike the login v1, it runs in its own process.
|
||||
The login v2 is enabled by default in new installations.
|
||||
But if an existing `docker-compose.yaml` file is updated by following this guide,
|
||||
an additional `zitadel-login` service is now running which is actually not doing anything, yet.
|
||||
The following steps give guidance on how to enable the login v2 in an environment.
|
||||
</p>
|
||||
<ol>
|
||||
<li>Read the login v2 related comments in the docker-compose.yaml.</li>
|
||||
<li><a href="../../self-hosting/manage/login-client#create-login-client">Manually create a login client personal access token (PAT) for the now running v2 login</a>.</li>
|
||||
<li>Move the PAT to `./login-client.pat`</li>
|
||||
<li>Restart the login process: `docker compose restart login`</li>
|
||||
<li><a href="../../self-hosting/manage/login-client#require-login-v2">Enable the Login UI for all users</a></li>
|
||||
</ol>
|
||||
</details>
|
||||
Pick a TLS mode and download the matching overlay file:
|
||||
|
||||
| Mode | Overlay file | When to use |
|
||||
|------|-------------|-------------|
|
||||
| Let's Encrypt | `docker-compose.mode-letsencrypt.yml` | Public domain, automatic certs |
|
||||
| External TLS | `docker-compose.mode-external-tls.yml` | Behind a load balancer, CDN, or WAF that terminates TLS |
|
||||
| Local TLS | `docker-compose.mode-local-tls.yml` | Self-signed certs for LAN-only access |
|
||||
|
||||
Download the overlay you need, then start with both files:
|
||||
|
||||
```shell
|
||||
# Download the overlay (replace with your chosen mode)
|
||||
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.mode-letsencrypt.yml
|
||||
|
||||
# Start with the overlay
|
||||
docker compose --env-file .env \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.mode-letsencrypt.yml \
|
||||
up -d --wait
|
||||
```
|
||||
|
||||
Set `LETSENCRYPT_EMAIL` in `.env` to receive certificate expiry notifications.
|
||||
|
||||
### Harden secrets
|
||||
|
||||
<Callout type="warn">
|
||||
The masterkey [encrypts sensitive data at rest](/concepts/architecture/secrets). Once ZITADEL has been initialized with a masterkey, it **cannot be changed** without losing access to encrypted data. Generate it **before first start** and store it safely.
|
||||
</Callout>
|
||||
|
||||
```shell
|
||||
# Generate a secure masterkey (must be exactly 32 characters)
|
||||
ZITADEL_MASTERKEY=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)
|
||||
echo "ZITADEL_MASTERKEY=$ZITADEL_MASTERKEY" >> .env
|
||||
|
||||
# Set strong database passwords
|
||||
echo "POSTGRES_ADMIN_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" >> .env
|
||||
echo "POSTGRES_ZITADEL_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" >> .env
|
||||
```
|
||||
|
||||
### External URL settings
|
||||
|
||||
These three settings **must match your public endpoint exactly**:
|
||||
|
||||
| Setting | Meaning | Example |
|
||||
|---------|---------|---------|
|
||||
| `ZITADEL_DOMAIN` | The public domain users type in | `auth.example.com` |
|
||||
| `ZITADEL_EXTERNALPORT` | The port visible to users | `443` for HTTPS |
|
||||
| `ZITADEL_EXTERNALSECURE` | Whether the public URL uses HTTPS | `true` for any TLS mode |
|
||||
|
||||
If these don't match reality, ZITADEL returns **"Instance not found"** errors. This is the most common deployment issue — see [TLS Modes](/self-hosting/manage/tls_modes) for details.
|
||||
|
||||
<NoteInstanceNotFound components={props.components} />
|
||||
|
||||
### Enable caching with Redis
|
||||
|
||||
See [Cache Configuration](/self-hosting/manage/cache) for all available options. Add these to `.env`:
|
||||
|
||||
```dotenv
|
||||
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED=true
|
||||
ZITADEL_CACHES_INSTANCE_CONNECTOR=redis
|
||||
ZITADEL_CACHES_MILESTONES_CONNECTOR=redis
|
||||
ZITADEL_CACHES_ORGANIZATION_CONNECTOR=redis
|
||||
```
|
||||
|
||||
Then start with the `cache` profile:
|
||||
|
||||
```shell
|
||||
docker compose --env-file .env -f docker-compose.yml --profile cache up -d --wait
|
||||
```
|
||||
|
||||
## Stage 3 — Beyond Compose
|
||||
|
||||
### Production-like init/setup/start split
|
||||
|
||||
For controlled upgrades, separate [database initialization from the running API](/self-hosting/manage/updating_scaling):
|
||||
|
||||
```shell
|
||||
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.prodlike.yml &&
|
||||
|
||||
docker compose --env-file .env \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.prodlike.yml \
|
||||
up -d --wait
|
||||
```
|
||||
|
||||
This creates three ZITADEL containers:
|
||||
1. `zitadel-init` — runs database migrations (one-shot)
|
||||
2. `zitadel-setup` — configures the instance (one-shot)
|
||||
3. `zitadel-api` — starts the API server (long-running)
|
||||
|
||||
### Enable observability
|
||||
|
||||
Set in `.env`:
|
||||
|
||||
```dotenv
|
||||
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE=grpc
|
||||
```
|
||||
|
||||
Download the collector configuration and start with the `observability` profile:
|
||||
|
||||
```shell
|
||||
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/otel-collector-config.yaml &&
|
||||
|
||||
docker compose --env-file .env -f docker-compose.yml --profile observability up -d --wait
|
||||
```
|
||||
|
||||
Traces are logged to the collector's stdout by default (`docker compose logs otel-collector`).
|
||||
To forward traces to your own backend (Grafana Tempo, Jaeger, OpenObserve, etc.), set `OTEL_BACKEND_ENDPOINT` in `.env` and uncomment the `otlp` exporter in `otel-collector-config.yaml`.
|
||||
See [Metrics](/self-hosting/manage/metrics/overview) for Prometheus scraping and available metric types.
|
||||
|
||||
### Scale API replicas
|
||||
|
||||
<Callout type="info">
|
||||
Scaling requires the **prodlike overlay** so that migrations run once in `zitadel-init` instead of on every replica.
|
||||
</Callout>
|
||||
|
||||
```shell
|
||||
docker compose --env-file .env \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.prodlike.yml \
|
||||
up -d --scale zitadel-api=3
|
||||
```
|
||||
|
||||
### Updating ZITADEL
|
||||
|
||||
Edit `ZITADEL_VERSION` in `.env`, then:
|
||||
|
||||
```shell
|
||||
docker compose --env-file .env -f docker-compose.yml pull
|
||||
docker compose --env-file .env -f docker-compose.yml up -d --wait
|
||||
```
|
||||
|
||||
<Callout>
|
||||
`ZITADEL_FIRSTINSTANCE_*` and `ZITADEL_DEFAULTINSTANCE_*` environment variables are only applied during the **initial setup**.
|
||||
To change settings on an existing installation, use the Admin Console or Admin API.
|
||||
</Callout>
|
||||
|
||||
### Moving to Kubernetes
|
||||
|
||||
Docker Compose is ideal for getting started and homelab deployments.
|
||||
For production workloads, review the [Production Checklist](/self-hosting/manage/productionchecklist) and deploy with the official [Helm chart for Kubernetes](/self-hosting/deploy/kubernetes).
|
||||
|
||||
The compose pack and the Helm chart share the same application configuration model (`ZITADEL_*` environment variables), so migration is straightforward.
|
||||
|
||||
<Next components={props.components} />
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
services:
|
||||
zitadel:
|
||||
restart: unless-stopped
|
||||
image: ghcr.io/zitadel/zitadel:latest
|
||||
command: start-from-init --masterkey "MasterkeyNeedsToHave32Characters"
|
||||
environment:
|
||||
# See "What's next" to learn about how to serve Zitadel on a different domain or IP.
|
||||
ZITADEL_EXTERNALDOMAIN: localhost
|
||||
|
||||
# See "What's next" to learn about how to enable TLS.
|
||||
ZITADEL_EXTERNALSECURE: false
|
||||
ZITADEL_TLS_ENABLED: false
|
||||
|
||||
# Database connection settings.
|
||||
ZITADEL_DATABASE_POSTGRES_HOST: db
|
||||
ZITADEL_DATABASE_POSTGRES_PORT: 5432
|
||||
# The database is created by the init job if it does not exist.
|
||||
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
|
||||
# The admin user must already exist in the database.
|
||||
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
|
||||
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres
|
||||
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
|
||||
# The zitadel user is created by the init job if it does not exist.
|
||||
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
|
||||
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel
|
||||
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
|
||||
|
||||
# By configuring a login application, the setup job creates a user of type machine with the role IAM_LOGIN_CLIENT.
|
||||
# It writes a PAT to the path specified in ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH.
|
||||
# The PAT is passed to the login container via the environment variable ZITADEL_SERVICE_USER_TOKEN_FILE.
|
||||
ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /current-dir/login-client.pat
|
||||
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false
|
||||
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client
|
||||
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Automatically Initialized IAM_LOGIN_CLIENT
|
||||
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: '2029-01-01T00:00:00Z'
|
||||
# Activate the login v2 on an installation from scratch.
|
||||
# To activate the login v2 on an existing installation, read the "What's next" section.
|
||||
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true # To use the login v1, set this to false.
|
||||
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:3000/ui/v2/login/
|
||||
# Configure the redirection paths to the login v2.
|
||||
ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?authRequest=
|
||||
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:3000/ui/v2/login/logout?post_logout_redirect=
|
||||
ZITADEL_SAML_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?samlRequest=
|
||||
|
||||
# By configuring a machine, the setup job creates a user of type machine with the role IAM_OWNER.
|
||||
# It writes a personal access token (PAT) to the path specified in ZITADEL_FIRSTINSTANCE_PATPATH.
|
||||
# The PAT can be used to provision resources with [Terraform](/guides/manage/terraform-provider), for example.
|
||||
# ZITADEL_FIRSTINSTANCE_PATPATH: /current-dir/admin.pat
|
||||
# ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: admin
|
||||
# ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Automatically Initialized IAM_OWNER
|
||||
# ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: '2029-01-01T00:00:00Z'
|
||||
|
||||
# To change the initial human admin users username and password, uncomment the following lines.
|
||||
# The first login name is formatted like this: <username>@<org_name>.<external_domain>
|
||||
# With the following incommented configuration, this would be [email protected]
|
||||
# Visit http://localhost:8080/ui/console to check if the login name works.
|
||||
# If you can't log in, check the available login names:
|
||||
# echo "select * from projections.login_names3;" | psql -h localhost -U postgres -d zitadel
|
||||
# The postgres users password is postgres.
|
||||
# ZITADEL_FIRSTINSTANCE_ORG_NAME: My Organization
|
||||
# ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: root
|
||||
# ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: RootPassword1!
|
||||
|
||||
# Enable debug logs
|
||||
# ZITADEL_LOG_LEVEL: debug
|
||||
# Write Access Logs to stdout.
|
||||
# ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED: true
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- /app/zitadel
|
||||
- ready
|
||||
interval: 10s
|
||||
timeout: 60s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
user: "0"
|
||||
volumes:
|
||||
- .:/current-dir:delegated
|
||||
ports:
|
||||
- 8080:8080
|
||||
- 3000:3000
|
||||
networks:
|
||||
- zitadel
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
login:
|
||||
restart: unless-stopped
|
||||
image: ghcr.io/zitadel/zitadel-login:latest
|
||||
# If you can't use the network_mode service:zitadel, you can pass the environment variables ZITADEL_API_URL=http://zitadel:8080 and CUSTOM_REQUEST_HEADERS=Host:localhost instead.
|
||||
environment:
|
||||
- ZITADEL_API_URL=http://localhost:8080
|
||||
- NEXT_PUBLIC_BASE_PATH=/ui/v2/login
|
||||
- ZITADEL_SERVICE_USER_TOKEN_FILE=/current-dir/login-client.pat
|
||||
network_mode: service:zitadel
|
||||
user: "0"
|
||||
volumes:
|
||||
- .:/current-dir:ro
|
||||
depends_on:
|
||||
zitadel:
|
||||
condition: service_healthy
|
||||
restart: false
|
||||
|
||||
db:
|
||||
restart: unless-stopped
|
||||
image: postgres:17
|
||||
environment:
|
||||
PGUSER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- pg_isready
|
||||
- -d
|
||||
- zitadel
|
||||
- -U
|
||||
- postgres
|
||||
interval: 10s
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
networks:
|
||||
- zitadel
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- 'data:/var/lib/postgresql/data:rw'
|
||||
|
||||
networks:
|
||||
zitadel:
|
||||
|
||||
volumes:
|
||||
data:
|
||||
@@ -14,3 +14,38 @@ import UpstreamHeader from "../../../self-hosting/deploy/troubleshooting/_upstre
|
||||
## upstream sent too big header while reading response header from upstream
|
||||
|
||||
<UpstreamHeader components={props.components} />
|
||||
|
||||
## Container restarts after upgrading to a new version
|
||||
|
||||
When you upgrade ZITADEL to a newer version, the setup phase runs database migrations automatically.
|
||||
|
||||
**Which command to use for upgrades:**
|
||||
|
||||
- `start-from-init` (used in the quickstart) runs **init → setup → start** in sequence. The init phase is only needed once ever (for a fresh database), but it is safe to re-run because it skips resources that already exist. Use `start-from-init` only for the quickstart or when you're sure the database needs initialising from scratch.
|
||||
- **For upgrades on existing installations**, prefer `start-from-setup` (runs setup → start) or run `zitadel setup` followed by `zitadel start` as separate steps. This avoids running the init phase unnecessarily and is the recommended production approach. See [Update and Scale ZITADEL](/self-hosting/manage/updating_scaling) for details.
|
||||
|
||||
If the container exits with code 1 and the logs show a database migration error, check the following:
|
||||
|
||||
- Make sure the PostgreSQL version meets the [requirements](/self-hosting/manage/database). PostgreSQL 18 support requires ZITADEL v4.11.0 or later.
|
||||
- Log messages like `role "zitadel" already exists` or `database "zitadel" already exists` during `start-from-init` re-runs are **expected and harmless** — the init phase skips those steps.
|
||||
- If the container still fails after a version upgrade, check whether the version you are upgrading to lists any known migration issues in the [release notes](https://github.com/zitadel/zitadel/releases).
|
||||
|
||||
## Instance settings (FIRSTINSTANCE / DEFAULTINSTANCE) not taking effect after restart
|
||||
|
||||
Environment variables prefixed with `ZITADEL_FIRSTINSTANCE_` and `ZITADEL_DEFAULTINSTANCE_` are only applied during the **initial creation** of a ZITADEL instance.
|
||||
Changing these variables and restarting the container will not update an already-existing instance.
|
||||
|
||||
To change instance-level settings on a running installation:
|
||||
- Use the [Admin Console](https://your-domain/ui/console/instance) to update settings interactively.
|
||||
- Use the Admin API to automate changes.
|
||||
|
||||
**Login V2**: Starting with ZITADEL v4, the Login V2 UI is enabled by default for new installations.
|
||||
If you are running an existing installation and want to enable it, follow the [Login Client guide](/self-hosting/manage/login-client).
|
||||
|
||||
## ZITADEL service becomes unhealthy without visible error messages
|
||||
|
||||
If the `zitadel` container shows as `unhealthy` but the log output does not reveal an obvious error:
|
||||
|
||||
1. Increase log verbosity by setting `ZITADEL_LOG_LEVEL: debug` and `ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED: true`.
|
||||
2. Check that the healthcheck command can reach ZITADEL. The built-in `zitadel ready` command uses HTTP when `ZITADEL_TLS_ENABLED=false` and HTTPS otherwise. Ensure that this variable matches your TLS settings.
|
||||
3. If ZITADEL is running behind a reverse proxy, make sure the proxy forwards traffic correctly before the first healthcheck succeeds. See [Run and configure a reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy).
|
||||
|
||||
@@ -21,14 +21,15 @@ services:
|
||||
environment:
|
||||
- POSTGRES_USER=root
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=zitadel
|
||||
networks:
|
||||
- 'zitadel'
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready", "-d", "db_prod"]
|
||||
test: ["CMD-SHELL", "pg_isready -d zitadel -U root"]
|
||||
interval: 10s
|
||||
timeout: 60s
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
start_period: 20s
|
||||
volumes:
|
||||
- 'data:/var/lib/postgresql/data:rw'
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: ZITADEL Requirements
|
||||
sidebar_label: Requirements
|
||||
---
|
||||
|
||||
This page lists the infrastructure versions that ZITADEL is tested against and officially supports.
|
||||
|
||||
## Database
|
||||
|
||||
ZITADEL requires a PostgreSQL database. Versions **14 to 18** are supported.
|
||||
|
||||
See the [Database](/self-hosting/manage/database) page for full configuration options, how to pre-create users and databases, and how to rotate credentials.
|
||||
|
||||
## Cache (optional)
|
||||
|
||||
ZITADEL supports Redis as an optional cache to speed up lookups of frequently needed objects.
|
||||
Redis must run in **standalone mode** (Cluster and Sentinel are not yet supported).
|
||||
|
||||
See the [Caches](/self-hosting/manage/cache) page for configuration options.
|
||||
|
||||
## Container runtime
|
||||
|
||||
- **Docker Compose**: v2.x (Compose V1 `docker-compose` CLI is not supported)
|
||||
- **Kubernetes**: see the [Kubernetes deployment guide](/self-hosting/deploy/kubernetes)
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
Any reverse proxy that supports HTTP/2 upstream connections (h2c or h2) works with ZITADEL.
|
||||
The following proxies are tested and have dedicated configuration guides:
|
||||
|
||||
- **Traefik**: v3.x
|
||||
- **NGINX**: v1.x stable releases
|
||||
- **Caddy**: v2.x
|
||||
- **Apache httpd**: 2.4.x
|
||||
|
||||
See the [Reverse Proxy Configuration](/self-hosting/manage/reverseproxy/reverse_proxy) page for details and Docker Compose examples.
|
||||
@@ -28,6 +28,21 @@ You can either setup your environment for <a href={'#tls-mode-external'}>TLS mod
|
||||
|
||||
<ProxyGuideTLSMode components={props.components} mode="enabled" configfilename="enabled-tls.Caddyfile" configfilecontent={ConfigEnabled} providername={providername} link={link} lower={lower}></ProxyGuideTLSMode>
|
||||
|
||||
## Known issues
|
||||
|
||||
### TE: trailers header causes requests to hang
|
||||
|
||||
When a browser sends a `TE: trailers` HTTP header (common with HTTP/2), Caddy may forward it upstream where it conflicts with ZITADEL's gRPC-gateway handling.
|
||||
Requests that result in an error response will hang indefinitely instead of returning the error to the client.
|
||||
|
||||
**Workaround**: strip the header in Caddy before it reaches ZITADEL:
|
||||
|
||||
```
|
||||
reverse_proxy h2c://zitadel:8080 {
|
||||
header_up -TE
|
||||
}
|
||||
```
|
||||
|
||||
## More Information
|
||||
|
||||
<ProxyGuideMore components={props.components} ></ProxyGuideMore>
|
||||
|
||||
@@ -3,6 +3,36 @@ title: Reverse Proxy Configuration
|
||||
description: "Setup and configure reverse proxies like Traefik, NGINX, and Caddy to manage external traffic for self-hosted ZITADEL."
|
||||
---
|
||||
|
||||
ZITADEL exposes a single HTTP/2 port (default `8080`) that serves both gRPC and HTTP APIs, as well as the management console.
|
||||
Running a reverse proxy in front of ZITADEL is the recommended approach for production deployments because it:
|
||||
|
||||
- Terminates TLS and provides HTTPS to clients
|
||||
- Exposes standard ports (`80`/`443`) while keeping ZITADEL on an internal port
|
||||
- Handles certificate renewal (e.g. via Let's Encrypt)
|
||||
- Enables path-based routing between the ZITADEL API and the Login UI
|
||||
|
||||
## HTTP/2 and h2c requirements
|
||||
|
||||
ZITADEL relies on HTTP/2 for all gRPC communication.
|
||||
When the reverse proxy terminates TLS, it must forward traffic to ZITADEL using **h2c** (unencrypted HTTP/2), not HTTP/1.1.
|
||||
Not all reverse proxies support h2c by default — check the proxy-specific guides below for configuration examples.
|
||||
|
||||
For more details, see [HTTP/2 Support in ZITADEL](/self-hosting/manage/http2).
|
||||
|
||||
## TLS modes
|
||||
|
||||
ZITADEL supports three TLS modes that affect how the reverse proxy should be configured:
|
||||
|
||||
| Mode | Description | Typical use |
|
||||
|------|-------------|-------------|
|
||||
| `disabled` | ZITADEL listens on plain HTTP, reverse proxy handles nothing special | Local development, h2c passthrough |
|
||||
| `external` | ZITADEL listens on plain HTTP; reverse proxy terminates TLS | Most production setups |
|
||||
| `enabled` | ZITADEL itself terminates TLS (end-to-end encryption) | Environments requiring mutual TLS |
|
||||
|
||||
For more details, see [TLS Modes](/self-hosting/manage/tls_modes).
|
||||
|
||||
## Proxy guides
|
||||
|
||||
Check out one of the following guides to configure your favorite reverse proxy:
|
||||
|
||||
- [Traefik](/self-hosting/manage/reverseproxy/traefik)
|
||||
@@ -12,3 +42,14 @@ Check out one of the following guides to configure your favorite reverse proxy:
|
||||
- [Cloudflare](/self-hosting/manage/reverseproxy/cloudflare)
|
||||
- [Cloudflare Tunnel](/self-hosting/manage/reverseproxy/cloudflare_tunnel)
|
||||
- [Fronting Zitadel Cloud](/self-hosting/manage/reverseproxy/zitadel_cloud)
|
||||
|
||||
## Login UI routing
|
||||
|
||||
When running both the ZITADEL API and the Login V2 UI (`zitadel-login`) behind a reverse proxy, the proxy must route traffic to the correct upstream:
|
||||
|
||||
- `/ui/v2/login` → Login UI container (port `3000` by default)
|
||||
- All other paths → ZITADEL API container (port `8080` by default)
|
||||
|
||||
The proxy-specific guides include Docker Compose examples that demonstrate this split routing.
|
||||
|
||||
For tested proxy versions, see [ZITADEL Requirements](/self-hosting/manage/requirements#reverse-proxy).
|
||||
|
||||
@@ -773,6 +773,7 @@ export const guidesSidebar: readonly SidebarItem[] = [
|
||||
label: "Production & Operations",
|
||||
collapsed: false,
|
||||
items: [
|
||||
"self-hosting/manage/requirements",
|
||||
"self-hosting/manage/production",
|
||||
"self-hosting/manage/productionchecklist",
|
||||
"self-hosting/manage/usage_control",
|
||||
|
||||
@@ -22,3 +22,4 @@ The **Login App** (`apps/login`) provides the user interface for authentication
|
||||
- **Test (all)**: `pnpm nx run @zitadel/login:test`
|
||||
- **Test (unit)**: `pnpm nx run @zitadel/login:test-unit`
|
||||
- **Test (integration)**: `pnpm nx run @zitadel/login:test-integration`
|
||||
- **Pack (Docker)**: `pnpm nx run @zitadel/login:pack` — builds a local Docker image `zitadel/zitadel-login:local`. Requires Docker daemon.
|
||||
|
||||
+31
-1
@@ -40,6 +40,22 @@
|
||||
"{projectRoot}/.next/standalone"
|
||||
]
|
||||
},
|
||||
"pack": {
|
||||
"description": "Builds a local Docker image for the ZITADEL Login UI (zitadel/zitadel-login:local). Requires Docker daemon.",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"command": "docker build -f apps/login/Dockerfile -t zitadel/zitadel-login:local apps/login",
|
||||
"cache": false,
|
||||
"inputs": [
|
||||
"default",
|
||||
"{workspaceRoot}/pnpm-lock.yaml",
|
||||
"{projectRoot}/Dockerfile",
|
||||
"!{projectRoot}/.env.*",
|
||||
"!{projectRoot}/integration/**/*",
|
||||
"!{projectRoot}/acceptance/**/*"
|
||||
]
|
||||
},
|
||||
"build-vercel": {
|
||||
"description": "Builds the Next.js Login application for Vercel deployment",
|
||||
"cache": true,
|
||||
@@ -152,7 +168,21 @@
|
||||
"description": "Stops the gRPC mock container used for integration tests.",
|
||||
"command": "nx run @zitadel/login-api-mock:down"
|
||||
},
|
||||
"pack": {
|
||||
"test-acceptance": {
|
||||
"description": "Runs the Playwright acceptance test suite in apps/login/acceptance/ against a running ZITADEL stack. Requires LOGIN_BASE_URL, ZITADEL_API_URL, and ZITADEL_ADMIN_TOKEN to be set (or apps/login/.env.test.local to exist). Run the setup script at acceptance/setup/setup.sh first to seed config.",
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "pnpm exec playwright test --config acceptance/playwright.config.ts"
|
||||
},
|
||||
"inputs": [
|
||||
"{projectRoot}/acceptance/**",
|
||||
"!{projectRoot}/acceptance/playwright-report/**",
|
||||
"!{projectRoot}/acceptance/test-results/**",
|
||||
"{workspaceRoot}/pnpm-lock.yaml"
|
||||
]
|
||||
},
|
||||
"pack-archive": {
|
||||
"description": "Packages the standalone Login application build into an archive",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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]
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@zitadel/compose",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.0",
|
||||
"dotenv": "^16.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
tls:
|
||||
stores:
|
||||
default:
|
||||
defaultCertificate:
|
||||
certFile: /certs/local.crt
|
||||
keyFile: /certs/local.key
|
||||
Generated
+9
@@ -631,6 +631,15 @@ importers:
|
||||
specifier: 5.9.2
|
||||
version: 5.9.2
|
||||
|
||||
deploy/compose:
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.58.0
|
||||
version: 1.58.0
|
||||
dotenv:
|
||||
specifier: ^16.0.0
|
||||
version: 16.6.1
|
||||
|
||||
packages/zitadel-client:
|
||||
dependencies:
|
||||
'@bufbuild/protobuf':
|
||||
|
||||
@@ -3,6 +3,7 @@ packages:
|
||||
- tests/*
|
||||
- packages/*
|
||||
- apps/*
|
||||
- deploy/compose
|
||||
|
||||
exclude:
|
||||
- benchmark # because of the node 18 or 20 dependency
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
"@zitadel/api:pack-linux-*",
|
||||
"@zitadel/api:pack-darwin-*",
|
||||
"@zitadel/api:pack-windows-*",
|
||||
"@zitadel/login:pack"
|
||||
"@zitadel/login:pack-archive"
|
||||
],
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
|
||||
Reference in New Issue
Block a user