mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-05 05:42:16 +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
@@ -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
|
||||
Reference in New Issue
Block a user