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

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

### What's in the pack

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

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

**Compose files**

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

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

### Build infra

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

### Testing

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

### Documentation

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

### Breaking change

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

---

### Checklist

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

---------

Co-authored-by: Mridang Agarwalla <[email protected]>
This commit is contained in:
Florian Forster
2026-02-24 16:33:12 -08:00
committed by GitHub
co-authored by Mridang Agarwalla
parent 54095b64b1
commit bac224c56d
38 changed files with 1968 additions and 210 deletions
+97
View File
@@ -0,0 +1,97 @@
# ⚠️ INSECURE DEFAULTS — for local development only.
# Before exposing this stack to a network, change ZITADEL_MASTERKEY,
# POSTGRES_ADMIN_PASSWORD, and POSTGRES_ZITADEL_PASSWORD.
# See: https://zitadel.com/docs/self-hosting/deploy/compose#homelab
# -----------------------------
# Domain and external URL
# -----------------------------
ZITADEL_DOMAIN=localhost
# Used by the base (non-TLS) compose; TLS overlays also publish 80/443 and may still expose
# this port unless their ports are adjusted.
PROXY_HTTP_PUBLISHED_PORT=8080
ZITADEL_EXTERNALPORT=8080
ZITADEL_EXTERNALSECURE=false
# Overridden by TLS overlays (they hardcode X-Forwarded-Proto: https).
ZITADEL_PUBLIC_SCHEME=http
# -----------------------------
# Security/bootstrap
# -----------------------------
# Must be exactly 32 chars for new deployments.
ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters
LOGIN_CLIENT_PAT_EXPIRATION=2099-01-01T00:00:00Z
# -----------------------------
# Pinned image tags
# To upgrade ZITADEL, bump ZITADEL_VERSION and run:
# docker compose --env-file .env -f docker-compose.yml pull
# docker compose --env-file .env -f docker-compose.yml up -d --wait
# -----------------------------
ZITADEL_VERSION=v4.11.0
TRAEFIK_IMAGE=traefik:v3.6.8
POSTGRES_IMAGE=postgres:17.2-alpine
REDIS_IMAGE=redis:7.4.2-alpine
OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib:0.114.0
# -----------------------------
# Proxy settings
# -----------------------------
TRAEFIK_DASHBOARD_ENABLED=false
TRAEFIK_LOG_LEVEL=INFO
TRAEFIK_ACCESSLOG_ENABLED=true
# Trusted proxy IPs for X-Forwarded-* headers (external-tls mode).
# Comma-separated CIDR ranges of your upstream load balancer / reverse proxy.
TRAEFIK_TRUSTED_IPS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# Let's Encrypt mode
LETSENCRYPT_EMAIL=[email protected]
# -----------------------------
# Postgres settings
# -----------------------------
POSTGRES_DB=zitadel
POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD=postgres
POSTGRES_ZITADEL_USER=zitadel
POSTGRES_ZITADEL_PASSWORD=zitadel
# -----------------------------
# Access logs
# -----------------------------
ZITADEL_ACCESS_LOG_STDOUT_ENABLED=true
# -----------------------------
# Optional Redis cache settings
# Default is disabled.
# Enable with --profile cache and switch connectors to redis.
# -----------------------------
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED=false
ZITADEL_CACHES_CONNECTORS_REDIS_ADDR=redis:6379
ZITADEL_CACHES_CONNECTORS_REDIS_USERNAME=
ZITADEL_CACHES_CONNECTORS_REDIS_PASSWORD=
ZITADEL_CACHES_INSTANCE_CONNECTOR=
ZITADEL_CACHES_MILESTONES_CONNECTOR=
ZITADEL_CACHES_ORGANIZATION_CONNECTOR=
# -----------------------------
# Optional API tracing (OTEL)
# Default is disabled. Enable with --profile observability.
# The collector receives traces from ZITADEL on port 4317 (gRPC) and 4318 (HTTP)
# and logs them to stdout by default.
# To forward traces to your own backend (Grafana Tempo, Jaeger, OpenObserve, etc.),
# uncomment the otlp exporter in otel-collector-config.yaml and set:
# OTEL_BACKEND_ENDPOINT=http://your-backend:4317
# -----------------------------
ZITADEL_INSTRUMENTATION_SERVICENAME=zitadel-api
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE=none
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_ENDPOINT=otel-collector:4317
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_INSECURE=true
# -----------------------------
# Future Login OTEL placeholders
# Current login image may ignore these.
# -----------------------------
LOGIN_OTEL_SERVICE_NAME=zitadel-login
LOGIN_OTEL_EXPORTER_OTLP_ENDPOINT=
LOGIN_OTEL_EXPORTER_OTLP_PROTOCOL=grpc
+65
View File
@@ -0,0 +1,65 @@
# Test environment for NX smoke tests.
# Used with docker-compose.test.yml overlay to run locally-built images.
# Do NOT use for production deployments.
ZITADEL_DOMAIN=localhost
PROXY_HTTP_PUBLISHED_PORT=8888
ZITADEL_EXTERNALPORT=8888
ZITADEL_EXTERNALSECURE=false
ZITADEL_PUBLIC_SCHEME=http
ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters
LOGIN_CLIENT_PAT_EXPIRATION=2099-01-01T00:00:00Z
# Suppresses interpolation warnings from the base compose file.
# The actual image tags for zitadel-api and zitadel-login are overridden
# by docker-compose.test.yml to use locally-built :local tags.
ZITADEL_VERSION=local
# Images for services not overridden by docker-compose.test.yml
TRAEFIK_IMAGE=traefik:v3.6.8
POSTGRES_IMAGE=postgres:17.2-alpine
REDIS_IMAGE=redis:7.4.2-alpine
OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib:0.114.0
# Traefik
TRAEFIK_DASHBOARD_ENABLED=false
TRAEFIK_LOG_LEVEL=ERROR
TRAEFIK_ACCESSLOG_ENABLED=false
TRAEFIK_TRUSTED_IPS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# Postgres
POSTGRES_DB=zitadel
POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD=postgres
POSTGRES_ZITADEL_USER=zitadel
POSTGRES_ZITADEL_PASSWORD=zitadel
# Disable access logs to reduce noise in test output
ZITADEL_ACCESS_LOG_STDOUT_ENABLED=false
# Tracing disabled
ZITADEL_INSTRUMENTATION_SERVICENAME=zitadel-api
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE=none
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_ENDPOINT=
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_INSECURE=false
# Cache disabled (no Redis profile)
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED=false
ZITADEL_CACHES_CONNECTORS_REDIS_ADDR=redis:6379
ZITADEL_CACHES_CONNECTORS_REDIS_USERNAME=
ZITADEL_CACHES_CONNECTORS_REDIS_PASSWORD=
ZITADEL_CACHES_INSTANCE_CONNECTOR=
ZITADEL_CACHES_MILESTONES_CONNECTOR=
ZITADEL_CACHES_ORGANIZATION_CONNECTOR=
# Playwright smoke test credentials.
# SMOKE_TEST_ADMIN_USERNAME must match ZITADEL's initial admin login name:
# <username>@<org-domain>.<external-domain> → [email protected]
SMOKE_TEST_ADMIN_USERNAME=[email protected]
SMOKE_TEST_ADMIN_PASSWORD=Password1!
# Login OTEL disabled
LOGIN_OTEL_SERVICE_NAME=zitadel-login
LOGIN_OTEL_EXPORTER_OTLP_ENDPOINT=
LOGIN_OTEL_EXPORTER_OTLP_PROTOCOL=grpc
+13
View File
@@ -0,0 +1,13 @@
playwright-report/
test-results/
# Local environment file with secrets
.env
# Local TLS certificates and keys
certs/*.crt
certs/*.cer
certs/*.pem
certs/*.key
!certs/.gitkeep
+63
View File
@@ -0,0 +1,63 @@
## Docker Compose Deployment — AI Agent Instructions
### 1. Scope & Architecture
This directory provides a production-aware single-node Docker Compose deployment for ZITADEL.
The stack contains four core services and two optional profile-based services:
- **Core:** `zitadel-api` (Go API), `zitadel-login` (Next.js Login UI), `postgres`, `proxy` (Traefik)
- **Optional:** `redis` (cache profile), `otel-collector` (observability profile)
### 2. File Conventions
| File | Purpose | Safe to modify? |
|------|---------|----------------|
| `docker-compose.yml` | Base stack — all modes start from this | Yes, with care |
| `docker-compose.mode-*.yml` | TLS mode overlays (one per mode) | Yes |
| `docker-compose.prodlike.yml` | Init/setup/start split overlay | Yes |
| `docker-compose.test.yml` | CI test overlay (local images, no direct ports) | Yes |
| `.env.example` | User-facing config template | Yes — update on version bumps |
| `.env.test` | CI-only test config | Internal only |
| `otel-collector-config.yaml` | OTEL Collector pipeline config | Yes |
| `traefik-local-tls.yml` | Traefik dynamic config for local TLS certs | Yes |
| `project.json` | NX project definition | Auto-managed by NX |
| `README.md` | Developer/contributor reference | Yes |
### 3. Key Invariants
- **Four TLS modes must remain independently composable.** Never merge mode-specific config into the base `docker-compose.yml`. Each `docker-compose.mode-*.yml` overlay must work when composed with the base file alone.
- **gRPC routing uses `Content-Type: application/grpc*` header matching**, not path prefixes. Do NOT introduce `/grpc` path routing.
- **`ZITADEL_EXTERNALDOMAIN`, `ZITADEL_EXTERNALPORT`, `ZITADEL_EXTERNALSECURE` must be consistent** with the actual public endpoint. Mismatches cause "Instance not found" errors — the single most common deployment issue.
- **Profiles (`cache`, `observability`) are opt-in** and must not affect the default stack behavior.
- **The `/api` path alias coexists with canonical ZITADEL paths.** Do NOT remove canonical root-level path routing (e.g., `/.well-known/`, `/oauth/v2/`). The `/api` prefix is a convenience alias.
- **Image versions are pinned in `.env.example`** via `ZITADEL_VERSION` and infrastructure image variables. When bumping versions, update `.env.example`.
- **CI tests must go through Traefik.** The test overlay must NOT expose direct container ports. All smoke test traffic flows through the proxy to validate routing end-to-end.
### 4. Rejected Alternatives
These designs were considered and explicitly rejected — do not re-propose them:
- **Single container** merging API + Login — not aligned with v4 architecture
- **`/grpc` path-prefix routing** — tool/client incompatibility risk
- **Strict `/api`-only rewrite model** — breaks canonical OIDC/SAML protocol paths
- **`network_mode: service:`** for Login — fragile, port conflicts, incompatible with Traefik routing
- **Merged TLS configurations** — each mode must be independently composable without side effects
### 5. Common Commands
| Task | Command |
|------|---------|
| Start (local dev) | `cp .env.example .env && docker compose up -d --wait` |
| Start (Let's Encrypt) | `docker compose --env-file .env -f docker-compose.yml -f docker-compose.mode-letsencrypt.yml up -d --wait` |
| Start (production-like) | `docker compose --env-file .env -f docker-compose.yml -f docker-compose.prodlike.yml up -d --wait` |
| Validate all configs | `docker compose --env-file .env.example -f docker-compose.yml -f <overlay> config > /dev/null` for each overlay |
| Run CI smoke test | `pnpm nx run @zitadel/compose:test-full` |
| Smoke check | `curl -sS http://localhost:8888/.well-known/openid-configuration` |
### 6. Terminology
Follow the root `AGENTS.md` glossary. Key rules:
- **Instance** = a logical ZITADEL tenant/partition, NEVER "example"
- **System** = the entire ZITADEL installation/deployment
- See the [Technical Glossary](../../AGENTS.md) for all user-facing text in any language
+91
View File
@@ -0,0 +1,91 @@
# ZITADEL Docker Compose — Developer Reference
> **User-facing documentation:** [zitadel.com/docs/self-hosting/deploy/compose](https://zitadel.com/docs/self-hosting/deploy/compose)
>
> This README is the contributor/developer reference — architecture decisions, file conventions, and routing logic.
## Architecture
```
┌─────────────────────────┐
Browser ──────►│ Traefik (proxy) │
│ Port 80 / 443 │
└───┬──────────┬──────────┘
│ │
┌──────────▼──┐ ┌───▼──────────┐
│ zitadel-api │ │ zitadel-login │
│ Go :8080 │ │ Next.js :3000 │
└──────┬───────┘ └──────────────┘
│
┌──────▼───────┐
│ PostgreSQL │
└──────────────┘
```
Optional services via profiles: `redis` (`cache`), `otel-collector` (`observability`).
## File Conventions
| File | Role | Notes |
|------|------|-------|
| `docker-compose.yml` | **Base stack** — all modes start from this | Must work standalone with `.env.example` |
| `docker-compose.mode-letsencrypt.yml` | TLS overlay: ACME HTTP challenge | Declares its own `letsencrypt` volume |
| `docker-compose.mode-external-tls.yml` | TLS overlay: upstream LB terminates TLS | Enables forwarded headers |
| `docker-compose.mode-local-tls.yml` | TLS overlay: self-signed certs | Mounts `./certs/` and `traefik-local-tls.yml` |
| `docker-compose.prodlike.yml` | Init/setup/start split | Uses YAML anchors for shared DB env |
| `docker-compose.test.yml` | CI smoke test overlay | Overrides images to `:local` tags |
| `.env.example` | User-facing config template | Copy to `.env` before first run |
| `.env.test` | CI-only config | Used by NX targets: `test-run`, `test-e2e`, `test-full`, `stop` |
| `otel-collector-config.yaml` | OTEL Collector pipeline config | Logs traces to stdout; configure `OTEL_BACKEND_ENDPOINT` to forward to a backend |
| `traefik-local-tls.yml` | Traefik dynamic config for local certs | Referenced by local-tls overlay |
| `project.json` | NX project definition | Targets: `test-config`, `test-run`, `test-e2e`, `test`, `test-full`, `stop` |
| `AGENTS.md` | AI agent instructions for this directory | |
## Routing Rules
Traefik routes all traffic for `${ZITADEL_DOMAIN}` via Docker labels:
| Priority | Rule | Target | Middleware |
|----------|------|--------|------------|
| 400 | `Path(/)` | `zitadel-login` | `replacepath=/ui/v2/login/` |
| 250 | `PathPrefix(/ui/v2/login)` | `zitadel-login` | — |
| 200 | `PathPrefix(/api)` | `zitadel-api` | `stripprefix=/api` |
| 100 | Everything else (OIDC, SAML, gRPC, gRPC-web, API v2 REST, ...) | `zitadel-api` (h2c) | — |
No dedicated gRPC router is needed: Traefik's h2c backend scheme forwards gRPC and gRPC-web transparently. API v2 is served as REST/JSON via the gRPC-gateway at `/v2/...` paths.
Both `web` (HTTP) and `websecure` (HTTPS) entrypoints have identical router sets.
### Why this routing model
- `/api` alias exists for DX — tools can use `https://auth.example.com/api/...`
- Canonical paths (e.g., `/.well-known/openid-configuration`, `/oauth/v2/...`) must remain at root for OIDC/SAML protocol compliance
- gRPC, gRPC-web, and REST all share the catch-all router — no separate gRPC router is needed because the `h2c` backend scheme makes Traefik forward all protocols transparently over HTTP/2
## External Settings Invariant
`ZITADEL_EXTERNALDOMAIN`, `ZITADEL_EXTERNALPORT`, and `ZITADEL_EXTERNALSECURE` **must match the public URL** that users see. If they don't, ZITADEL returns "Instance not found" errors. This is the single most common deployment issue.
## Testing
Local NX targets for testing the compose stack:
| Target | What it does | Requires Docker? |
|--------|-------------|------------------|
| `test-config` | Validates all overlay combinations parse with `docker compose config` | No (just the CLI) |
| `test-run` | Builds local images (`@zitadel/api:pack` + `@zitadel/login:pack`), starts the stack with `docker compose up --wait` | Yes |
| `test-e2e` | Runs the full Playwright suite (`wiring.spec.ts` + `smoke.spec.ts`) against `localhost:8888` through Traefik: per-service wiring checks (login, console, OIDC, SAML, API v1 REST, gRPC h2c, gRPC-web, API v2 REST HTTP/1.1 + HTTP/2) and the browser login flow | Yes (stack must be running) |
| `test` | Lightweight — delegates to `test-config` only. Safe for `nx affected` | No |
| `test-full` | Full pipeline: `test-config` → `test-run` → Playwright wiring + browser tests → teardown | Yes |
| `stop` | Tears down the `zitadel-compose-test` stack and removes volumes | Yes |
## Rejected Alternatives
| Alternative | Why rejected |
|-------------|-------------|
| Strict `/api`-only rewrite | Breaks canonical protocol paths (OIDC, SAML) |
| `/grpc` path-prefix routing | gRPC clients/tools don't use path prefixes |
| Dedicated gRPC router (`HeaderRegexp(Content-Type, ^application/grpc.*)`) | Redundant: h2c backend handles gRPC, gRPC-web, and Connect-RPC natively; the catch-all covers all three |
| Single container (API + Login) | Not aligned with v4 architecture; Login is a separate Next.js process |
| Merged TLS configs | Each TLS mode must remain independently composable |
| `network_mode: service:` for Login | Fragile, port conflicts, doesn't work with Traefik routing |
View File
@@ -0,0 +1,30 @@
services:
proxy:
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=zitadel
- --entrypoints.web.address=:80
- --entrypoints.web.forwardedHeaders.trustedIPs=${TRAEFIK_TRUSTED_IPS}
- --entrypoints.websecure.address=:443
- --api.dashboard=${TRAEFIK_DASHBOARD_ENABLED}
- --api.insecure=false
- --ping=true
- --ping.entrypoint=web
- --log.level=${TRAEFIK_LOG_LEVEL}
- --accesslog=${TRAEFIK_ACCESSLOG_ENABLED}
ports: !override
- 80:80
zitadel-api:
environment:
ZITADEL_EXTERNALPORT: 443
ZITADEL_EXTERNALSECURE: true
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: https://${ZITADEL_DOMAIN}/ui/v2/login/
ZITADEL_OIDC_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?samlRequest=
zitadel-login:
environment:
CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_DOMAIN},X-Forwarded-Proto:https
@@ -0,0 +1,42 @@
services:
proxy:
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=zitadel
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.websecure.http.tls=true
- --entrypoints.websecure.http.tls.certresolver=le
- --certificatesresolvers.le.acme.email=${LETSENCRYPT_EMAIL}
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
- --api.dashboard=${TRAEFIK_DASHBOARD_ENABLED}
- --api.insecure=false
- --ping=true
- --ping.entrypoint=web
- --log.level=${TRAEFIK_LOG_LEVEL}
- --accesslog=${TRAEFIK_ACCESSLOG_ENABLED}
ports: !override
- 80:80
- 443:443
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt:rw
zitadel-api:
environment:
ZITADEL_EXTERNALPORT: 443
ZITADEL_EXTERNALSECURE: true
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: https://${ZITADEL_DOMAIN}/ui/v2/login/
ZITADEL_OIDC_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?samlRequest=
zitadel-login:
environment:
CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_DOMAIN},X-Forwarded-Proto:https
volumes:
letsencrypt:
@@ -0,0 +1,38 @@
services:
proxy:
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=zitadel
- --providers.file.filename=/etc/traefik/dynamic/local-tls.yml
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --entrypoints.websecure.address=:443
- --entrypoints.websecure.http.tls=true
- --api.dashboard=${TRAEFIK_DASHBOARD_ENABLED}
- --api.insecure=false
- --ping=true
- --ping.entrypoint=web
- --log.level=${TRAEFIK_LOG_LEVEL}
- --accesslog=${TRAEFIK_ACCESSLOG_ENABLED}
ports: !override
- 80:80
- 443:443
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./certs:/certs:ro
- ./traefik-local-tls.yml:/etc/traefik/dynamic/local-tls.yml:ro
zitadel-api:
environment:
ZITADEL_EXTERNALPORT: 443
ZITADEL_EXTERNALSECURE: true
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: https://${ZITADEL_DOMAIN}/ui/v2/login/
ZITADEL_OIDC_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?samlRequest=
zitadel-login:
environment:
CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_DOMAIN},X-Forwarded-Proto:https
@@ -0,0 +1,64 @@
x-zitadel-db-env: &zitadel-db-env
ZITADEL_DATABASE_POSTGRES_HOST: postgres
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: ${POSTGRES_DB}
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_ADMIN_USER}
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: ${POSTGRES_ZITADEL_USER}
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${POSTGRES_ZITADEL_PASSWORD}
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
services:
zitadel-init:
image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION}
restart: "no"
command: init
environment:
<<: *zitadel-db-env
depends_on:
postgres:
condition: service_healthy
networks:
- zitadel
zitadel-setup:
image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION}
restart: "no"
command: setup --masterkey "${ZITADEL_MASTERKEY}"
environment:
<<: *zitadel-db-env
ZITADEL_EXTERNALDOMAIN: ${ZITADEL_DOMAIN}
ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNALPORT}
ZITADEL_EXTERNALSECURE: ${ZITADEL_EXTERNALSECURE}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false
ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel/bootstrap/login-client.pat
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Automatically Initialized IAM_LOGIN_CLIENT
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: ${LOGIN_CLIENT_PAT_EXPIRATION}
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/
ZITADEL_OIDC_DEFAULTLOGINURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/login?samlRequest=
volumes:
- zitadel-bootstrap:/zitadel/bootstrap:rw
depends_on:
postgres:
condition: service_healthy
zitadel-init:
condition: service_completed_successfully
networks:
- zitadel
zitadel-api:
command: start --masterkey "${ZITADEL_MASTERKEY}"
depends_on:
postgres:
condition: service_healthy
zitadel-init:
condition: service_completed_successfully
zitadel-setup:
condition: service_completed_successfully
+26
View File
@@ -0,0 +1,26 @@
# Test overlay: replaces published images with locally-built ones.
# Used by the NX smoke-test targets (@zitadel/compose:test-run, :test-full, :stop).
#
# All test traffic flows through the Traefik proxy (port 8888)
# so the routing rules are validated end-to-end.
#
# Usage:
# docker compose --env-file .env.test -f docker-compose.yml -f docker-compose.test.yml \
# -p zitadel-compose-test up --force-recreate --renew-anon-volumes --wait
services:
zitadel-api:
image: zitadel/zitadel:local
environment:
# Set a known password for the initial admin so Playwright smoke tests
# can authenticate without a setup API call.
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${SMOKE_TEST_ADMIN_PASSWORD}
# Create an IAM_OWNER machine user and write its PAT to the bootstrap volume.
# The PAT can be consumed by external acceptance tests to call the management API.
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: ZITADEL Admin SA
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2519-04-01T08:45:00.000000Z"
ZITADEL_FIRSTINSTANCE_PATPATH: /zitadel/bootstrap/admin.pat
zitadel-login:
image: zitadel/zitadel-login:local
+246
View File
@@ -0,0 +1,246 @@
name: zitadel
services:
proxy:
image: ${TRAEFIK_IMAGE}
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=zitadel
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --api.dashboard=${TRAEFIK_DASHBOARD_ENABLED}
- --api.insecure=false
- --ping=true
- --ping.entrypoint=web
- --log.level=${TRAEFIK_LOG_LEVEL}
- --accesslog=${TRAEFIK_ACCESSLOG_ENABLED}
ports:
- ${PROXY_HTTP_PUBLISHED_PORT}:80
networks:
- zitadel
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
zitadel-api:
condition: service_healthy
zitadel-login:
condition: service_healthy
zitadel-api:
image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION}
restart: unless-stopped
user: "0"
command: start-from-init --masterkey "${ZITADEL_MASTERKEY}"
environment:
ZITADEL_PORT: 8080
ZITADEL_EXTERNALDOMAIN: ${ZITADEL_DOMAIN}
ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNALPORT}
ZITADEL_EXTERNALSECURE: ${ZITADEL_EXTERNALSECURE}
ZITADEL_TLS_ENABLED: false
ZITADEL_DATABASE_POSTGRES_HOST: postgres
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: ${POSTGRES_DB}
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_ADMIN_USER}
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: ${POSTGRES_ZITADEL_USER}
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${POSTGRES_ZITADEL_PASSWORD}
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false
ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel/bootstrap/login-client.pat
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Automatically Initialized IAM_LOGIN_CLIENT
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: ${LOGIN_CLIENT_PAT_EXPIRATION}
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/
ZITADEL_OIDC_DEFAULTLOGINURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/login?samlRequest=
ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED: ${ZITADEL_ACCESS_LOG_STDOUT_ENABLED}
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE: ${ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE}
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_ENDPOINT: ${ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_ENDPOINT}
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_INSECURE: ${ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_INSECURE}
ZITADEL_INSTRUMENTATION_SERVICENAME: ${ZITADEL_INSTRUMENTATION_SERVICENAME}
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED: ${ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED}
ZITADEL_CACHES_CONNECTORS_REDIS_ADDR: ${ZITADEL_CACHES_CONNECTORS_REDIS_ADDR}
ZITADEL_CACHES_CONNECTORS_REDIS_USERNAME: ${ZITADEL_CACHES_CONNECTORS_REDIS_USERNAME}
ZITADEL_CACHES_CONNECTORS_REDIS_PASSWORD: ${ZITADEL_CACHES_CONNECTORS_REDIS_PASSWORD}
ZITADEL_CACHES_INSTANCE_CONNECTOR: ${ZITADEL_CACHES_INSTANCE_CONNECTOR}
ZITADEL_CACHES_MILESTONES_CONNECTOR: ${ZITADEL_CACHES_MILESTONES_CONNECTOR}
ZITADEL_CACHES_ORGANIZATION_CONNECTOR: ${ZITADEL_CACHES_ORGANIZATION_CONNECTOR}
healthcheck:
test:
- CMD
- /app/zitadel
- ready
interval: 10s
timeout: 30s
retries: 12
start_period: 20s
volumes:
- zitadel-bootstrap:/zitadel/bootstrap:rw
networks:
- zitadel
depends_on:
postgres:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.docker.network=zitadel
- traefik.http.services.zitadel-api.loadbalancer.server.port=8080
- traefik.http.services.zitadel-api.loadbalancer.server.scheme=h2c
- traefik.http.middlewares.zitadel-strip-api.stripprefix.prefixes=/api
- traefik.http.middlewares.zitadel-strip-api.stripprefix.forceSlash=false
# Note: no dedicated gRPC router needed. All gRPC and Connect-RPC traffic
# is handled by the catch-all router below since the backend already uses h2c.
- traefik.http.routers.zitadel-api-alias-web.rule=Host(`${ZITADEL_DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.zitadel-api-alias-web.entrypoints=web
- traefik.http.routers.zitadel-api-alias-web.middlewares=zitadel-strip-api
- traefik.http.routers.zitadel-api-alias-web.service=zitadel-api
- traefik.http.routers.zitadel-api-alias-web.priority=200
- traefik.http.routers.zitadel-api-alias-websecure.rule=Host(`${ZITADEL_DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.zitadel-api-alias-websecure.entrypoints=websecure
- traefik.http.routers.zitadel-api-alias-websecure.tls=true
- traefik.http.routers.zitadel-api-alias-websecure.middlewares=zitadel-strip-api
- traefik.http.routers.zitadel-api-alias-websecure.service=zitadel-api
- traefik.http.routers.zitadel-api-alias-websecure.priority=200
- traefik.http.routers.zitadel-canonical-web.rule=Host(`${ZITADEL_DOMAIN}`) && !PathPrefix(`/ui/v2/login`) && !PathPrefix(`/api`) && !Path(`/`)
- traefik.http.routers.zitadel-canonical-web.entrypoints=web
- traefik.http.routers.zitadel-canonical-web.service=zitadel-api
- traefik.http.routers.zitadel-canonical-web.priority=100
- traefik.http.routers.zitadel-canonical-websecure.rule=Host(`${ZITADEL_DOMAIN}`) && !PathPrefix(`/ui/v2/login`) && !PathPrefix(`/api`) && !Path(`/`)
- traefik.http.routers.zitadel-canonical-websecure.entrypoints=websecure
- traefik.http.routers.zitadel-canonical-websecure.tls=true
- traefik.http.routers.zitadel-canonical-websecure.service=zitadel-api
- traefik.http.routers.zitadel-canonical-websecure.priority=100
zitadel-login:
image: ghcr.io/zitadel/zitadel-login:${ZITADEL_VERSION}
restart: unless-stopped
user: "0"
environment:
ZITADEL_API_URL: http://zitadel-api:8080
NEXT_PUBLIC_BASE_PATH: /ui/v2/login
ZITADEL_SERVICE_USER_TOKEN_FILE: /zitadel/bootstrap/login-client.pat
CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_DOMAIN},X-Forwarded-Proto:${ZITADEL_PUBLIC_SCHEME}
# Future-ready placeholders for upcoming Login OTEL support.
OTEL_SERVICE_NAME: ${LOGIN_OTEL_SERVICE_NAME}
OTEL_EXPORTER_OTLP_ENDPOINT: ${LOGIN_OTEL_EXPORTER_OTLP_ENDPOINT}
OTEL_EXPORTER_OTLP_PROTOCOL: ${LOGIN_OTEL_EXPORTER_OTLP_PROTOCOL}
healthcheck:
test:
- CMD
- /bin/sh
- -c
- node /app/healthcheck.js http://localhost:3000/ui/v2/login/healthy
interval: 10s
timeout: 30s
retries: 12
start_period: 20s
volumes:
- zitadel-bootstrap:/zitadel/bootstrap:ro
networks:
- zitadel
depends_on:
zitadel-api:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.docker.network=zitadel
- traefik.http.services.zitadel-login.loadbalancer.server.port=3000
- traefik.http.middlewares.zitadel-root-rewrite.replacepath.path=/ui/v2/login/
- traefik.http.routers.zitadel-root-web.rule=Host(`${ZITADEL_DOMAIN}`) && Path(`/`)
- traefik.http.routers.zitadel-root-web.entrypoints=web
- traefik.http.routers.zitadel-root-web.middlewares=zitadel-root-rewrite
- traefik.http.routers.zitadel-root-web.service=zitadel-login
- traefik.http.routers.zitadel-root-web.priority=400
- traefik.http.routers.zitadel-root-websecure.rule=Host(`${ZITADEL_DOMAIN}`) && Path(`/`)
- traefik.http.routers.zitadel-root-websecure.entrypoints=websecure
- traefik.http.routers.zitadel-root-websecure.tls=true
- traefik.http.routers.zitadel-root-websecure.middlewares=zitadel-root-rewrite
- traefik.http.routers.zitadel-root-websecure.service=zitadel-login
- traefik.http.routers.zitadel-root-websecure.priority=400
- traefik.http.routers.zitadel-login-web.rule=Host(`${ZITADEL_DOMAIN}`) && PathPrefix(`/ui/v2/login`)
- traefik.http.routers.zitadel-login-web.entrypoints=web
- traefik.http.routers.zitadel-login-web.service=zitadel-login
- traefik.http.routers.zitadel-login-web.priority=250
- traefik.http.routers.zitadel-login-websecure.rule=Host(`${ZITADEL_DOMAIN}`) && PathPrefix(`/ui/v2/login`)
- traefik.http.routers.zitadel-login-websecure.entrypoints=websecure
- traefik.http.routers.zitadel-login-websecure.tls=true
- traefik.http.routers.zitadel-login-websecure.service=zitadel-login
- traefik.http.routers.zitadel-login-websecure.priority=250
postgres:
image: ${POSTGRES_IMAGE}
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
POSTGRES_USER: ${POSTGRES_ADMIN_USER}
POSTGRES_DB: ${POSTGRES_DB}
healthcheck:
test:
- CMD-SHELL
- pg_isready -d ${POSTGRES_DB} -U ${POSTGRES_ADMIN_USER}
interval: 10s
timeout: 30s
retries: 10
start_period: 20s
volumes:
- postgres-data:/var/lib/postgresql/data:rw
networks:
- zitadel
redis:
image: ${REDIS_IMAGE}
restart: unless-stopped
profiles:
- cache
command:
- --save
- ""
- --appendonly
- "no"
networks:
- zitadel
otel-collector:
image: ${OTEL_COLLECTOR_IMAGE}
restart: unless-stopped
profiles:
- observability
command:
- --config=/etc/otelcol/config.yaml
volumes:
- ./otel-collector-config.yaml:/etc/otelcol/config.yaml:ro
networks:
- zitadel
networks:
zitadel:
name: zitadel
volumes:
postgres-data:
zitadel-bootstrap:
+28
View File
@@ -0,0 +1,28 @@
receivers:
otlp:
protocols:
grpc:
http:
processors:
batch:
exporters:
# Traces are logged to stdout in a human-readable format by default.
# To forward to your own OTLP-compatible backend (Grafana Tempo, Jaeger, OpenObserve, etc.),
# uncomment the otlp exporter below and set OTEL_BACKEND_ENDPOINT in .env.
debug:
verbosity: basic
# otlp:
# endpoint: ${env:OTEL_BACKEND_ENDPOINT}
# tls:
# insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [debug]
# To also forward to a backend, add otlp to the list:
# exporters: [debug, otlp]
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@zitadel/compose",
"private": true,
"devDependencies": {
"@playwright/test": "^1.58.0",
"dotenv": "^16.0.0"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig, devices } from "@playwright/test";
import * as dotenv from "dotenv";
import * as path from "path";
// Load .env.test so credentials and port are available when running on the host.
// On CI the same file is passed to the shell environment before invoking NX.
dotenv.config({ path: path.resolve(__dirname, ".env.test") });
export default defineConfig({
testDir: "./tests",
// Fail fast: one retry on flakiness, full trace on first retry for debugging.
retries: 1,
reporter: [["html", { open: "never" }], ["line"]],
use: {
// All navigation goes through Traefik on the published port.
baseURL:
process.env.PLAYWRIGHT_BASE_URL ??
`http://localhost:${process.env.PROXY_HTTP_PUBLISHED_PORT ?? "8888"}`,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+94
View File
@@ -0,0 +1,94 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"name": "@zitadel/compose",
"targets": {
"test-config": {
"description": "Validates that all compose file combinations parse correctly (no Docker daemon needed).",
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"parallel": false,
"commands": [
"docker compose --env-file .env.example -f docker-compose.yml config --quiet",
"docker compose --env-file .env.example -f docker-compose.yml -f docker-compose.mode-letsencrypt.yml config --quiet",
"docker compose --env-file .env.example -f docker-compose.yml -f docker-compose.mode-external-tls.yml config --quiet",
"docker compose --env-file .env.example -f docker-compose.yml -f docker-compose.mode-local-tls.yml config --quiet",
"docker compose --env-file .env.example -f docker-compose.yml -f docker-compose.prodlike.yml config --quiet"
]
},
"inputs": [
"{projectRoot}/docker-compose.yml",
"{projectRoot}/docker-compose.mode-*.yml",
"{projectRoot}/docker-compose.prodlike.yml",
"{projectRoot}/.env.example"
]
},
"test-run": {
"description": "Starts the full compose stack using locally-built images. Waits until all containers are healthy before exiting.",
"dependsOn": [
"@zitadel/api:pack",
"@zitadel/login:pack"
],
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"parallel": false,
"commands": [
"docker compose --env-file .env.test -f docker-compose.yml -f docker-compose.test.yml -p zitadel-compose-test down --volumes --remove-orphans",
"docker compose --env-file .env.test -f docker-compose.yml -f docker-compose.test.yml -p zitadel-compose-test up --force-recreate --wait"
]
}
},
"test-e2e": {
"description": "Runs the Playwright test suite (wiring.spec.ts + smoke.spec.ts) against the running compose stack via localhost:8888 through Traefik. Covers login, console, OIDC, SAML, API v1 REST, gRPC h2c, gRPC-web, API v2 REST (gRPC-gateway JSON) HTTP/1.1 and HTTP/2. Run 'pnpm exec playwright install chromium' once before using this target.",
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "pnpm exec playwright test --config playwright.config.ts"
},
"inputs": [
"{projectRoot}/playwright.config.ts",
"{projectRoot}/tests/**",
"{projectRoot}/.env.test",
"{projectRoot}/package.json"
]
},
"test": {
"description": "Lightweight test target safe for nx affected. Validates compose config syntax only (no Docker daemon needed).",
"dependsOn": [
"test-config"
]
},
"test-full": {
"description": "Builds local images, starts the compose stack, runs Playwright wiring tests (login, console, OIDC, SAML, API v1 REST, gRPC h2c, gRPC-web, API v2 REST HTTP/1.1 and HTTP/2) and the browser login smoke test through Traefik, then tears down.",
"dependsOn": [
"test-config",
"test-run"
],
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"parallel": false,
"commands": [
"pnpm exec playwright install chromium",
"r=0; nx run @zitadel/compose:test-e2e || r=$?; nx run @zitadel/compose:stop; exit $r"
]
},
"inputs": [
"{projectRoot}/docker-compose.yml",
"{projectRoot}/docker-compose.test.yml",
"{projectRoot}/.env.test",
"{projectRoot}/playwright.config.ts",
"{projectRoot}/tests/**"
]
},
"stop": {
"description": "Tears down the compose smoke-test stack and removes all volumes.",
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "docker compose --env-file .env.test -f docker-compose.yml -f docker-compose.test.yml -p zitadel-compose-test down --volumes"
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Compose stack wiring smoke tests
*
* PURPOSE: verify that all compose services are correctly wired together.
* These are INFRASTRUCTURE tests, not login-feature tests.
* They answer: "does the compose stack work end-to-end?"
*
* Test path: browser → Traefik (port 8888) → zitadel-login → zitadel-api → postgres
* A single successful login proves every link in that chain is functioning.
*
* For feature-level login tests (MFA, OIDC flows, IdP, etc.) see:
* apps/login/acceptance/ – run via @zitadel/login:test-acceptance
*
* Credentials are read from SMOKE_TEST_ADMIN_USERNAME / SMOKE_TEST_ADMIN_PASSWORD
* (set in .env.test). The fallback values below match the defaults in .env.test and
* docker-compose.test.yml — they are intentional so the test works out-of-the-box
* without any extra configuration. If you change the seeded admin password, update
* .env.test accordingly.
* The initial admin password is seeded via ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD
* in docker-compose.test.yml.
*/
import { expect, test } from "@playwright/test";
const username =
process.env.SMOKE_TEST_ADMIN_USERNAME ?? "[email protected]";
const password = process.env.SMOKE_TEST_ADMIN_PASSWORD ?? "Password1!";
test.describe("compose stack wiring", () => {
test("admin can complete username/password login (proves Traefik → Login → API → DB chain)", async ({
page,
}) => {
// Step 1 – enter login name.
// Use a long timeout for the first navigation: even though the compose stack
// is healthy, ZITADEL's LoginName page makes server-side gRPC calls to the
// API on first render and can be slow when the instance is still warming up.
await page.goto("/ui/v2/login/loginname");
// The login page renders two inputs with the same test ID (one with autofocus,
// one without — likely an accessibility duplicate). Use .first() to avoid a
// strict-mode violation and target the active, focused input.
const usernameInput = page.getByTestId("username-text-input").first();
await usernameInput.waitFor({ state: "visible", timeout: 120_000 });
await usernameInput.fill(username);
// Wait for the button to be enabled before clicking — the page may still be
// hydrating when the input becomes visible.
await page.getByTestId("submit-button").first().waitFor({ state: "visible" });
await page.getByTestId("submit-button").first().click();
// Step 2 – enter password.
// Wait for the password page to fully render before typing; clicking submit
// above triggers a navigation and the input must be present and interactive.
const passwordInput = page.getByTestId("password-text-input").first();
await passwordInput.waitFor({ state: "visible", timeout: 30_000 });
await passwordInput.fill(password);
await page.getByTestId("submit-button").first().waitFor({ state: "visible" });
await page.getByTestId("submit-button").first().click();
// Step 3 – assert signed-in page (ZITADEL redirects here after a successful
// direct login that has no original auth-request to return to).
await expect(page).toHaveURL(/signedin/, { timeout: 30_000 });
});
});
+558
View File
@@ -0,0 +1,558 @@
/**
* Compose stack wiring tests — per-service reachability through Traefik
*
* PURPOSE: verify that every service and protocol is correctly routed through
* the Traefik reverse proxy. These are INFRASTRUCTURE tests, not feature tests.
* They answer: "is this service alive and reachable through the proxy?"
*
* Services covered:
* - Login UI (zitadel-login, Next.js, /ui/v2/login)
* - Console (zitadel-api, Angular SPA, /ui/console)
* - OIDC (zitadel-api, /.well-known/openid-configuration + /oauth/v2/keys)
* - SAML (zitadel-api, /saml/v2/metadata)
* - Root redirect (Traefik replacepath middleware → /ui/v2/login/)
*
* API protocol × transport matrix (all via Traefik catch-all, priority 100):
* V1 (AdminService/Healthz — unauthenticated, expects success):
* - REST json HTTP/1.1 /admin/v1/healthz
* - REST json h2c /admin/v1/healthz
* - gRPC-web proto HTTP/1.1 /zitadel.admin.v1.AdminService/Healthz
* - gRPC-web json HTTP/1.1 /zitadel.admin.v1.AdminService/Healthz
* - gRPC proto h2c /zitadel.admin.v1.AdminService/Healthz
* - gRPC json h2c /zitadel.admin.v1.AdminService/Healthz
*
* V2 (SessionService/ListSessions — requires auth, expects 401):
* Three access styles, each × HTTP/1.1 and h2c:
*
* 1. REST-style path application/json
* POST /v2/sessions (gRPC-gateway REST mapping)
*
* 2. gRPC-style path application/json (Connect unary JSON)
* POST /zitadel.session.v2.SessionService/ListSessions
* Handled by the Connect handler; application/json = Connect unary.
* Note: application/connect+json is streaming-only (Connect spec).
*
* 3. gRPC-style path application/proto (Connect unary proto)
* POST /zitadel.session.v2.SessionService/ListSessions
* Handled by the Connect handler; application/proto = Connect unary.
* Note: application/connect+proto is streaming-only (Connect spec).
*
* 4. gRPC-style path application/grpc+proto / application/grpc+json (gRPC native, h2c only)
* POST /zitadel.session.v2.SessionService/ListSessions
*
* For end-to-end browser login flow (proves Traefik → Login → API → DB chain):
* see smoke.spec.ts
*
* For feature-level login tests (MFA, OIDC flows, IdPs, etc.):
* see apps/login/acceptance/ — run via @zitadel/login:test-acceptance
*/
import { expect, test, type APIRequestContext, type TestInfo } from "@playwright/test";
import http2 from "node:http2";
import { Buffer } from "node:buffer";
// ---------------------------------------------------------------------------
// HTTP/2 (h2c) helpers
// ---------------------------------------------------------------------------
type H2Response = {
status: number;
headers: Record<string, string>;
body: Buffer;
trailers: Record<string, string>;
};
function h2Request(
baseUrl: string,
method: string,
path: string,
reqHeaders: Record<string, string>,
body?: Buffer | string,
): Promise<H2Response> {
return new Promise((resolve, reject) => {
const client = http2.connect(baseUrl);
const timeout = setTimeout(() => {
client.destroy();
reject(new Error(`h2c ${method} ${path} timed out`));
}, 30_000);
client.once("error", (err) => {
clearTimeout(timeout);
reject(err);
});
const req = client.request({
":method": method,
":path": path,
":scheme": "http",
...reqHeaders,
});
if (body !== undefined) req.write(body);
req.end();
let status = 0;
const chunks: Buffer[] = [];
const trailers: Record<string, string> = {};
const responseHeaders: Record<string, string> = {};
req.on("response", (hdrs) => {
status = Number(hdrs[":status"]);
for (const [k, v] of Object.entries(hdrs)) {
if (!k.startsWith(":")) responseHeaders[k] = String(v);
}
});
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("trailers", (thdrs) => {
for (const [k, v] of Object.entries(thdrs)) {
trailers[k] = String(v);
}
});
req.on("end", () => {
clearTimeout(timeout);
client.close();
resolve({
status,
headers: responseHeaders,
body: Buffer.concat(chunks),
trailers,
});
});
req.on("error", (err) => {
clearTimeout(timeout);
client.close();
reject(err);
});
});
}
function h2Post(
baseUrl: string,
path: string,
reqHeaders: Record<string, string>,
body?: Buffer | string,
): Promise<H2Response> {
return h2Request(baseUrl, "POST", path, reqHeaders, body);
}
function h2Get(
baseUrl: string,
path: string,
reqHeaders: Record<string, string> = {},
): Promise<H2Response> {
return h2Request(baseUrl, "GET", path, reqHeaders);
}
// ---------------------------------------------------------------------------
// gRPC framing helpers (RFC: https://grpc.github.io/grpc/core/md_doc_PROTOCOL-HTTP2.html)
// Frame: [compressed (1 byte)] [message length (4 bytes BE)] [message bytes]
// ---------------------------------------------------------------------------
/** Wrap proto bytes in a gRPC length-prefixed frame. */
function grpcFrame(protoBytes: Buffer = Buffer.alloc(0)): Buffer {
const frame = Buffer.allocUnsafe(5 + protoBytes.length);
frame[0] = 0; // compressed-flag = false
frame.writeUInt32BE(protoBytes.length, 1);
protoBytes.copy(frame, 5);
return frame;
}
/** Parse the first gRPC response frame and return the proto body bytes. */
function parseGrpcFrame(buf: Buffer): Buffer {
if (buf.length < 5) throw new Error(`gRPC response frame too short (${buf.length} bytes)`);
const len = buf.readUInt32BE(1);
if (buf.length < 5 + len) throw new Error(`gRPC response frame truncated: expected ${5 + len} bytes, got ${buf.length}`);
return buf.subarray(5, 5 + len);
}
/** Parse gRPC-web inline trailers from the response body.
* After the data frame(s), gRPC-web appends a trailer frame with flag 0x80. */
function parseGrpcWebTrailers(buf: Buffer): Record<string, string> {
// Skip past data frames to find the trailer frame (flag byte 0x80)
let offset = 0;
while (offset < buf.length) {
if (offset + 5 > buf.length) {
throw new Error(`Truncated gRPC-web frame at offset ${offset}: need 5 header bytes but only ${buf.length - offset} remain`);
}
const flag = buf[offset];
const len = buf.readUInt32BE(offset + 1);
if (flag === 0x80) {
// Trailer frame: ASCII key-value pairs separated by \r\n
const text = buf.subarray(offset + 5, offset + 5 + len).toString("utf8");
const trailers: Record<string, string> = {};
for (const line of text.split("\r\n")) {
const colon = line.indexOf(":");
if (colon > 0) {
trailers[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
}
}
return trailers;
}
offset += 5 + len;
}
return {};
}
// ---------------------------------------------------------------------------
// Soft-fail wrapper — allows new/experimental permutations to warn on failure
// without breaking CI. Tests marked knownWorking: false log a warning instead
// of hard-failing.
// ---------------------------------------------------------------------------
function softTest(
label: string,
knownWorking: boolean,
testFn: (args: { request: APIRequestContext; testInfo: TestInfo }) => Promise<void>,
) {
if (knownWorking) {
test(label, async ({ request }, testInfo) => {
await testFn({ request, testInfo });
});
} else {
test(label, async ({ request }, testInfo) => {
try {
await testFn({ request, testInfo });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
testInfo.annotations.push({
type: "fixme",
description: `[soft-fail] ${msg}`,
});
// eslint-disable-next-line no-console
console.warn(`⚠ SOFT-FAIL: ${label}\n ${msg}`);
}
});
}
}
// ---------------------------------------------------------------------------
// Protocol × transport permutation types
// ---------------------------------------------------------------------------
type Transport = "http1" | "h2c";
type Protocol = "rest" | "grpc-web" | "grpc" | "connectrpc";
type Encoding = "proto" | "json";
type ApiPermutation = {
protocol: Protocol;
encoding: Encoding;
transport: Transport;
/** Whether this permutation is already known to work (hard-fail on regression). */
knownWorking: boolean;
};
// ---------------------------------------------------------------------------
// V1 API permutations — AdminService/Healthz (unauthenticated, expects 200/OK)
// ---------------------------------------------------------------------------
const V1_GRPC_PATH = "/zitadel.admin.v1.AdminService/Healthz";
const V1_REST_PATH = "/admin/v1/healthz";
const v1Permutations: ApiPermutation[] = [
// REST
{ protocol: "rest", encoding: "json", transport: "http1", knownWorking: true },
{ protocol: "rest", encoding: "json", transport: "h2c", knownWorking: true },
// gRPC-web (HTTP/1.1 only — grpc-web is designed for browser HTTP/1.1)
{ protocol: "grpc-web", encoding: "proto", transport: "http1", knownWorking: true },
{ protocol: "grpc-web", encoding: "json", transport: "http1", knownWorking: true },
// gRPC native (h2c only — requires HTTP/2)
{ protocol: "grpc", encoding: "proto", transport: "h2c", knownWorking: true },
{ protocol: "grpc", encoding: "json", transport: "h2c", knownWorking: true },
];
// ---------------------------------------------------------------------------
// V2 API permutations — SessionService/ListSessions (requires auth → 401)
// ---------------------------------------------------------------------------
const V2_GRPC_PATH = "/zitadel.session.v2.SessionService/ListSessions";
const V2_REST_PATH = "/v2/sessions";
const v2Permutations: ApiPermutation[] = [
// REST (gRPC-gateway)
{ protocol: "rest", encoding: "json", transport: "http1", knownWorking: true },
{ protocol: "rest", encoding: "json", transport: "h2c", knownWorking: true },
// ConnectRPC (v2 only — v1 does not register Connect handlers)
{ protocol: "connectrpc", encoding: "proto", transport: "http1", knownWorking: true },
{ protocol: "connectrpc", encoding: "json", transport: "http1", knownWorking: true },
{ protocol: "connectrpc", encoding: "proto", transport: "h2c", knownWorking: true },
{ protocol: "connectrpc", encoding: "json", transport: "h2c", knownWorking: true },
// gRPC native (h2c only)
{ protocol: "grpc", encoding: "proto", transport: "h2c", knownWorking: true },
{ protocol: "grpc", encoding: "json", transport: "h2c", knownWorking: true },
];
// ---------------------------------------------------------------------------
// Tests — service-level reachability (non-API)
// ---------------------------------------------------------------------------
// The base URL comes from playwright.config.ts (PROXY_HTTP_PUBLISHED_PORT, default 8888).
// We resolve it here so h2Post()/h2Get() can connect to the same host.
const BASE =
process.env.PLAYWRIGHT_BASE_URL ??
`http://localhost:${process.env.PROXY_HTTP_PUBLISHED_PORT ?? "8888"}`;
test.describe("traefik routing", () => {
test("GET / → redirects to /ui/v2/login/", async ({ request }) => {
// Playwright follows redirects by default; verify the final destination.
// Note: Next.js normalises /ui/v2/login/ → /ui/v2/login (strips trailing slash).
const resp = await request.get("/");
expect(resp.url()).toContain("/ui/v2/login");
expect(resp.status()).toBe(200);
});
});
test.describe("login ui", () => {
test("GET /ui/v2/login/healthy → 200", async ({ request }) => {
const resp = await request.get("/ui/v2/login/healthy");
expect(resp.status()).toBe(200);
});
test("GET /ui/v2/login/ → 200 HTML", async ({ request }) => {
const resp = await request.get("/ui/v2/login/");
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain("<html");
});
});
test.describe("console", () => {
test("GET /ui/console/ → 200 HTML (Angular SPA shell)", async ({ request }) => {
const resp = await request.get("/ui/console/");
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain("<html");
});
});
test.describe("oidc", () => {
test("GET /.well-known/openid-configuration → valid discovery document", async ({ request }) => {
const resp = await request.get("/.well-known/openid-configuration");
expect(resp.status()).toBe(200);
const doc = await resp.json();
expect(doc).toHaveProperty("issuer");
expect(doc).toHaveProperty("authorization_endpoint");
expect(doc).toHaveProperty("token_endpoint");
expect(doc).toHaveProperty("jwks_uri");
});
test("GET /oauth/v2/keys → JWKS with keys array", async ({ request }) => {
const resp = await request.get("/oauth/v2/keys");
expect(resp.status()).toBe(200);
const body = await resp.json();
expect(body).toHaveProperty("keys");
expect(Array.isArray(body.keys)).toBe(true);
});
});
test.describe("saml", () => {
test("GET /saml/v2/metadata → EntityDescriptor XML", async ({ request }) => {
const resp = await request.get("/saml/v2/metadata");
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain("EntityDescriptor");
});
});
test.describe("api v1 rest extras", () => {
// Additional REST tests beyond the matrix: all three v1 services + /api alias.
for (const svc of ["management", "admin", "auth"]) {
test(`GET /${svc}/v1/healthz → 200`, async ({ request }) => {
const resp = await request.get(`/${svc}/v1/healthz`);
expect(resp.status()).toBe(200);
});
}
test("GET /api/admin/v1/healthz via /api alias → 200 (tests Traefik strip-prefix middleware)", async ({ request }) => {
const resp = await request.get("/api/admin/v1/healthz");
expect(resp.status()).toBe(200);
});
});
// ---------------------------------------------------------------------------
// API V1 protocol × transport matrix
//
// Endpoint: AdminService/Healthz (unauthenticated)
// Expected: success (REST → 200, gRPC → grpc-status 0, gRPC-web → grpc-status 0)
// ---------------------------------------------------------------------------
test.describe("api v1 protocol matrix", () => {
for (const p of v1Permutations) {
const label = `v1 ${p.protocol} ${p.encoding} ${p.transport}`;
softTest(label, p.knownWorking, async ({ request }) => {
switch (p.protocol) {
// ----- REST (gRPC-gateway) -----
case "rest": {
if (p.transport === "http1") {
const resp = await request.get(V1_REST_PATH);
expect(resp.status()).toBe(200);
} else {
const resp = await h2Get(BASE, V1_REST_PATH);
expect(resp.status).toBe(200);
}
break;
}
// ----- gRPC-web (HTTP/1.1) -----
case "grpc-web": {
const contentType =
p.encoding === "proto"
? "application/grpc-web+proto"
: "application/grpc-web+json";
// For json encoding, Healthz request is empty → JSON body is '{}'
const reqBody =
p.encoding === "proto"
? grpcFrame()
: grpcFrame(Buffer.from("{}"));
const resp = await request.post(V1_GRPC_PATH, {
headers: {
"content-type": contentType,
"x-grpc-web": "1",
},
data: reqBody,
});
expect(resp.status()).toBe(200);
// For proto encoding: parse inline trailers and assert grpc-status 0.
// For json encoding: the Go gRPC server has no JSON codec registered and
// returns no well-formed gRPC-web trailer frame. HTTP 200 alone proves
// the request was routed to the backend by Traefik.
if (p.encoding === "proto") {
const buf = Buffer.from(await resp.body());
const trailers = parseGrpcWebTrailers(buf);
expect(trailers["grpc-status"]).toBe("0");
}
break;
}
// ----- gRPC native (h2c) -----
case "grpc": {
const contentType =
p.encoding === "proto"
? "application/grpc+proto"
: "application/grpc+json";
const reqBody =
p.encoding === "proto"
? grpcFrame()
: grpcFrame(Buffer.from("{}"));
const resp = await h2Post(BASE, V1_GRPC_PATH, {
"content-type": contentType,
te: "trailers",
}, reqBody);
expect(resp.status).toBe(200);
// For proto encoding: assert OK (grpc-status 0).
// For json encoding: the Go gRPC server has no JSON codec registered,
// so it returns INTERNAL (13). We only assert the status is defined,
// which proves the request was routed to the backend.
if (p.encoding === "proto") {
expect(resp.trailers["grpc-status"]).toBe("0");
} else {
expect(resp.trailers["grpc-status"]).toBeDefined();
}
break;
}
default:
throw new Error(`unexpected protocol: ${p.protocol}`);
}
});
}
});
// ---------------------------------------------------------------------------
// API V2 protocol × transport matrix
//
// Endpoint: SessionService/ListSessions (requires auth)
// Expected: 401 / UNAUTHENTICATED
// - REST (gRPC-gateway): HTTP 401, body.code === 16
// - ConnectRPC unary: HTTP 401, body.code === "unauthenticated"
// - gRPC native: HTTP 200, grpc-status trailer === "16"
// ---------------------------------------------------------------------------
test.describe("api v2 protocol matrix", () => {
for (const p of v2Permutations) {
const label = `v2 ${p.protocol} ${p.encoding} ${p.transport}`;
softTest(label, p.knownWorking, async ({ request }) => {
switch (p.protocol) {
// ----- REST (gRPC-gateway) -----
case "rest": {
if (p.transport === "http1") {
const resp = await request.post(V2_REST_PATH, {
headers: { "content-type": "application/json" },
data: "{}",
});
expect(resp.status()).toBe(401);
const body = await resp.json();
expect(body.code).toBe(16); // UNAUTHENTICATED
} else {
const resp = await h2Post(BASE, V2_REST_PATH, {
"content-type": "application/json",
}, "{}");
expect(resp.status).toBe(401);
const body = JSON.parse(resp.body.toString("utf8"));
expect(body.code).toBe(16);
}
break;
}
// ----- Connect protocol unary (v2 only — v1 does not register Connect handlers) -----
// Per https://connectrpc.com/docs/multi-protocol/:
// application/json → Connect unary JSON (gRPC-style path)
// application/proto → Connect unary proto (gRPC-style path)
// application/connect+json and application/connect+proto are STREAMING-only.
// Connect unary uses no gRPC length-prefix framing; body is plain JSON or proto bytes.
// Error response format: { "code": "unauthenticated", ... }
case "connectrpc": {
const contentType =
p.encoding === "proto"
? "application/proto"
: "application/json";
// For connect+json, send a JSON body; for connect+proto, send empty bytes
const reqBody = p.encoding === "json" ? "{}" : Buffer.alloc(0);
if (p.transport === "http1") {
const resp = await request.post(V2_GRPC_PATH, {
headers: { "content-type": contentType },
data: reqBody,
});
// ConnectRPC returns the HTTP status directly
expect(resp.status()).toBe(401);
const body = await resp.json();
expect(body.code).toBe("unauthenticated");
} else {
const resp = await h2Post(BASE, V2_GRPC_PATH, {
"content-type": contentType,
}, reqBody);
expect(resp.status).toBe(401);
const body = JSON.parse(resp.body.toString("utf8"));
expect(body.code).toBe("unauthenticated");
}
break;
}
// ----- gRPC native (h2c) -----
case "grpc": {
const contentType =
p.encoding === "proto"
? "application/grpc+proto"
: "application/grpc+json";
const reqBody =
p.encoding === "proto"
? grpcFrame()
: grpcFrame(Buffer.from("{}"));
const resp = await h2Post(BASE, V2_GRPC_PATH, {
"content-type": contentType,
te: "trailers",
}, reqBody);
// gRPC always returns HTTP 200; error is in grpc-status trailer
expect(resp.status).toBe(200);
expect(resp.trailers["grpc-status"]).toBe("16"); // UNAUTHENTICATED
break;
}
default:
throw new Error(`unexpected protocol: ${p.protocol}`);
}
});
}
});
+6
View File
@@ -0,0 +1,6 @@
tls:
stores:
default:
defaultCertificate:
certFile: /certs/local.crt
keyFile: /certs/local.key