diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f2f8cd1e2..6e663080d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,6 +14,7 @@ You are working in the ZITADEL monorepo. This codebase uses specific conventions - `proto/AGENTS.md`: API schema and generation guidance. - `packages/AGENTS.md`: Shared client/proto package workflows. - `tests/functional-ui/AGENTS.md`: Cypress functional UI test workflows. +- `deploy/compose/AGENTS.md`: Docker Compose deployment invariants, file conventions, and rejected alternatives. ## Behavior - Before Go-related work, inspect `go.mod` for required Go version/toolchain. @@ -21,3 +22,4 @@ You are working in the ZITADEL monorepo. This codebase uses specific conventions - For backend changes, note we are in transition: relational data is becoming the system of record, while event writes are still required for history/audit. - Respect terminology defined in the `Technical Glossary` section of `AGENTS.md` when generating UI text or documentation. - Distinguish between `apps/login` (Next.js) and `console` (Angular) when suggesting frontend code. +- For deployment-related work, consult the directory-specific agents files (e.g., `deploy/compose/AGENTS.md`) for invariants, file conventions, and rejected alternatives. diff --git a/.github/semantic.yml b/.github/semantic.yml index ded8ffe0a..4da25301a 100644 --- a/.github/semantic.yml +++ b/.github/semantic.yml @@ -50,6 +50,8 @@ scopes: # API layer - grpc - proto + # Deployment + - deploy # Cross-cutting - build - ci diff --git a/AGENTS.md b/AGENTS.md index 61223d109..3ffcf55bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,22 @@ If a translation is requested for a language not listed above, follow these prio 3. **Priority 3 (Tenant):** If 'Instance' is ambiguous, use the local word for 'Tenant' (e.g., 租户 in Chinese). 4. **Strict Ban:** NEVER use words that mean "an illustration", "a case", "a sample", or "an example." +### 5. Deployment Targets + +ZITADEL supports multiple deployment methods. Each has its own directory and conventions: + +| Target | Location | Status | Notes | +|--------|----------|--------|-------| +| Docker Compose | `deploy/compose/` | Supported | Single-node, graduated from quickstart to semi-production. See `deploy/compose/AGENTS.md` for directory-specific rules. | +| Kubernetes (Helm) | External ([zitadel-charts](https://github.com/zitadel/zitadel-charts)) | Supported | Official Helm chart for production workloads. Docs at `apps/docs/content/self-hosting/deploy/kubernetes/`. | +| apt/rpm packages | Planned | Not yet available | Future packaging target. | + +When generating deployment-related content: +- Docker Compose is the recommended path for **getting started** and **homelab/single-node** deployments +- Kubernetes is the recommended path for **production** workloads +- Always reference the correct deployment method for the user's context +- The same `ZITADEL_*` environment variable model applies across all deployment methods + ## Command Rules Run commands from the repository root. @@ -85,10 +101,11 @@ Run commands from the repository root. - Known exception: `@zitadel/console` has no configured `test` target. ## Verified Common Targets -- `@zitadel/api`: `prod`, `build`, `generate`, `generate-install`, `lint`, `test`, `test-unit`, `test-integration` -- `@zitadel/login`: `dev`, `build`, `lint`, `test`, `test-unit`, `test-integration` +- `@zitadel/api`: `prod`, `build`, `build-linux`, `pack`, `generate`, `generate-install`, `lint`, `test`, `test-unit`, `test-integration` +- `@zitadel/login`: `dev`, `build`, `pack`, `lint`, `test`, `test-unit`, `test-integration` - `@zitadel/docs`: `dev`, `build`, `generate`, `install-proto-plugins`, `check-links`, `check-types`, `test`, `lint` - `@zitadel/console`: `dev`, `build`, `generate`, `install-proto-plugins`, `lint` +- `@zitadel/compose`: `test-config`, `test-run`, `test-e2e`, `test`, `test-full`, `stop` ## Proto Plugin Binaries All proto plugins are installed to `.artifacts/bin///` and Nx-cached. `generate` targets wire up the correct install dependency and prepend `.artifacts/bin/` to `$PATH` — no manual install step is needed. diff --git a/apps/api/AGENTS.md b/apps/api/AGENTS.md index 48657498a..6b7973afe 100644 --- a/apps/api/AGENTS.md +++ b/apps/api/AGENTS.md @@ -17,6 +17,8 @@ The **API App** (`apps/api`) is the Nx application target for building and runni - **Test (all)**: `pnpm nx run @zitadel/api:test` - **Test (unit)**: `pnpm nx run @zitadel/api:test-unit` - **Test (integration)**: `pnpm nx run @zitadel/api:test-integration` +- **Build Linux**: `pnpm nx run @zitadel/api:build-linux` — cross-compiles a Linux binary for Docker packaging +- **Pack (Docker)**: `pnpm nx run @zitadel/api:pack` — builds a local Docker image `zitadel/zitadel:local`. Requires Docker daemon. ## Generation Notes - `@zitadel/api:generate` can update generated, tracked files (stubs/assets/statik). Run it intentionally. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 92964b83f..cf62939a9 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -2,11 +2,14 @@ # This stage produces a runnable image that can be used for debugging. FROM debian:latest AS builder ARG TARGETPLATFORM +# BINARY allows callers to pass a differently-named local build artifact. +# Defaults to 'zitadel' for release builds; set to 'zitadel.local' for local dev builds. +ARG BINARY=zitadel RUN apt-get update && apt-get install ca-certificates -y COPY apps/api/entrypoint.sh /app/entrypoint.sh -COPY ./.artifacts/bin/${TARGETPLATFORM}/zitadel /app/zitadel +COPY ./.artifacts/bin/${TARGETPLATFORM}/${BINARY} /app/zitadel RUN useradd -s "" --home / zitadel && \ chown zitadel /app/zitadel && \ diff --git a/apps/api/project.json b/apps/api/project.json index fce4c63f8..c4c3d2221 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -83,6 +83,34 @@ "{workspaceRoot}/.artifacts/bin/*/*/zitadel.local" ] }, + "build-linux": { + "description": "Compiles the Go-based API backend into a Linux executable binary for Docker packaging.", + "dependsOn": [ + "generate", + "build-console" + ], + "command": "bash -c 'CGO_ENABLED=0 GOOS=linux go build -o .artifacts/bin/linux/$(go env GOARCH)/zitadel.local -ldflags=\"-s -w\"'", + "inputs": [ + { + "runtime": "go env GOARCH" + }, + "runtime", + { + "dependentTasksOutputFiles": "internal/api/ui/console/static/**/*" + } + ], + "outputs": [ + "{workspaceRoot}/.artifacts/bin/linux/*/zitadel.local" + ] + }, + "pack": { + "description": "Builds a local Docker image for the ZITADEL API (zitadel/zitadel:local). Requires Docker daemon.", + "dependsOn": [ + "build-linux" + ], + "command": "bash -c 'docker build --build-arg TARGETPLATFORM=linux/$(go env GOARCH) --build-arg BINARY=zitadel.local -f apps/api/Dockerfile -t zitadel/zitadel:local .'", + "cache": false + }, "generate": { "description": "Generates the code needed to start a full-featured API: gRPC and OpenAPI stubs, static files for the embedded login v1, asset routes and documentation.", "dependsOn": [ diff --git a/apps/docs/content/self-hosting/deploy/compose.mdx b/apps/docs/content/self-hosting/deploy/compose.mdx index b0f08d62e..4f091286e 100644 --- a/apps/docs/content/self-hosting/deploy/compose.mdx +++ b/apps/docs/content/self-hosting/deploy/compose.mdx @@ -1,90 +1,201 @@ --- -title: Set up Zitadel with Docker Compose -description: "Deploy a ZITADEL instance locally using Docker Compose for rapid testing and development." +title: Set up ZITADEL with Docker Compose sidebar_label: Docker Compose +description: Deploy ZITADEL with Docker Compose — from a 2-minute localhost quickstart to a hardened homelab setup with TLS, caching, and observability. --- import NoteInstanceNotFound from './troubleshooting/_note_instance_not_found.mdx'; -import DockerCompose from './docker-compose.yaml'; -import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock'; - -import Disclaimer from './_disclaimer.mdx' import DefaultUser from './_defaultuser.mdx' import Next from './_next.mdx' -This guide is the entrypoint for running the Zitadel platform locally for the first time. -It is for demonstration and development purposes only and does not set up a production-ready and security hardened instance of Zitadel. -Once Zitadel up is and running, learn more about a production setup in the [What's Next section](#next-steps). +This guide takes you from zero to a running ZITADEL instance in minutes and then shows you how to harden it for a homelab or semi-production deployment. ## Prerequisites -The setup is likely to work with other software versions too, but it is tested against the following environment: +- Docker Engine 24+ with the Compose plugin (`docker compose`) +- A machine with at least 2 GB RAM -- Ubuntu 24.04.1 -- Docker 28.3.2 -- Docker Compose v2.38.2 -- curl 8.5.0 +See [Requirements](/self-hosting/manage/requirements) for supported database, cache, and proxy versions. -## Docker compose +## Stage 1 — Quickstart (2 minutes) -The following commands set up services for a PostgreSQL database, a Zitadel API and a Zitadel login: +Download the two required files, copy the example config, and start: -1. Download the `docker-compose.yaml` file from the Zitadel repository: - ```shell - curl -L https://raw.githubusercontent.com/zitadel/zitadel/main/apps/docs/content/self-hosting/deploy/docker-compose.yaml -o docker-compose.yaml - ``` -2. Make sure the containers use the latest image versions. - ```shell - docker compose pull - ``` -3. Run the PostgreSQL database, the Zitadel API, and the Zitadel login. - ```shell - docker compose up --detach --wait - ``` -4. Verify the containers are running and healthy. - ```shell - docker compose ps - ``` +```shell +mkdir zitadel-compose && cd zitadel-compose + +# Download the compose file and example environment +curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.yml && +curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/.env.example + +# Create your environment file and start +cp .env.example .env +docker compose up -d --wait +``` + +That's it. Visit [http://localhost:8080](http://localhost:8080) to open the login screen. -## What's next + +The base stack runs: **Traefik** ([reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy)) → **ZITADEL API** (Go) + **ZITADEL Login** (Next.js) → **PostgreSQL**. +All routing, including [gRPC over HTTP/2](/self-hosting/manage/http2), is handled automatically by Traefik — no extra configuration needed. + -Before proceeding, review the downloaded `docker-compose.yaml` file: -The comments give context to the used variables and show examples for commonly used configuration variants. +## Stage 2 — Homelab / Semi-Production -
- docker-compose.yaml - -
+### Use your own domain -Here are some natural steps forward to go from the current setup to production: +Edit `.env` and set your real domain (see [Custom Domains](/self-hosting/manage/custom-domain) for details): -- **Use a different master key**: Set up an instance from scratch and use a different master key. For example, generate one by running `tr -dc A-Za-z0-9 - Read this if the environment uses the login v1 -

- The login v2 is the next generation of the Zitadel login. - Its code and deployment are easily customizable. - Unlike the login v1, it runs in its own process. - The login v2 is enabled by default in new installations. - But if an existing `docker-compose.yaml` file is updated by following this guide, - an additional `zitadel-login` service is now running which is actually not doing anything, yet. - The following steps give guidance on how to enable the login v2 in an environment. -

-
    -
  1. Read the login v2 related comments in the docker-compose.yaml.
  2. -
  3. Manually create a login client personal access token (PAT) for the now running v2 login.
  4. -
  5. Move the PAT to `./login-client.pat`
  6. -
  7. Restart the login process: `docker compose restart login`
  8. -
  9. Enable the Login UI for all users
  10. -
- +Pick a TLS mode and download the matching overlay file: + +| Mode | Overlay file | When to use | +|------|-------------|-------------| +| Let's Encrypt | `docker-compose.mode-letsencrypt.yml` | Public domain, automatic certs | +| External TLS | `docker-compose.mode-external-tls.yml` | Behind a load balancer, CDN, or WAF that terminates TLS | +| Local TLS | `docker-compose.mode-local-tls.yml` | Self-signed certs for LAN-only access | + +Download the overlay you need, then start with both files: + +```shell +# Download the overlay (replace with your chosen mode) +curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.mode-letsencrypt.yml + +# Start with the overlay +docker compose --env-file .env \ + -f docker-compose.yml \ + -f docker-compose.mode-letsencrypt.yml \ + up -d --wait +``` + +Set `LETSENCRYPT_EMAIL` in `.env` to receive certificate expiry notifications. + +### Harden secrets + + +The masterkey [encrypts sensitive data at rest](/concepts/architecture/secrets). Once ZITADEL has been initialized with a masterkey, it **cannot be changed** without losing access to encrypted data. Generate it **before first start** and store it safely. + + +```shell +# Generate a secure masterkey (must be exactly 32 characters) +ZITADEL_MASTERKEY=$(tr -dc A-Za-z0-9 > .env + +# Set strong database passwords +echo "POSTGRES_ADMIN_PASSWORD=$(tr -dc A-Za-z0-9 > .env +echo "POSTGRES_ZITADEL_PASSWORD=$(tr -dc A-Za-z0-9 > .env +``` + +### External URL settings + +These three settings **must match your public endpoint exactly**: + +| Setting | Meaning | Example | +|---------|---------|---------| +| `ZITADEL_DOMAIN` | The public domain users type in | `auth.example.com` | +| `ZITADEL_EXTERNALPORT` | The port visible to users | `443` for HTTPS | +| `ZITADEL_EXTERNALSECURE` | Whether the public URL uses HTTPS | `true` for any TLS mode | + +If these don't match reality, ZITADEL returns **"Instance not found"** errors. This is the most common deployment issue — see [TLS Modes](/self-hosting/manage/tls_modes) for details. + + + +### Enable caching with Redis + +See [Cache Configuration](/self-hosting/manage/cache) for all available options. Add these to `.env`: + +```dotenv +ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED=true +ZITADEL_CACHES_INSTANCE_CONNECTOR=redis +ZITADEL_CACHES_MILESTONES_CONNECTOR=redis +ZITADEL_CACHES_ORGANIZATION_CONNECTOR=redis +``` + +Then start with the `cache` profile: + +```shell +docker compose --env-file .env -f docker-compose.yml --profile cache up -d --wait +``` + +## Stage 3 — Beyond Compose + +### Production-like init/setup/start split + +For controlled upgrades, separate [database initialization from the running API](/self-hosting/manage/updating_scaling): + +```shell +curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.prodlike.yml && + +docker compose --env-file .env \ + -f docker-compose.yml \ + -f docker-compose.prodlike.yml \ + up -d --wait +``` + +This creates three ZITADEL containers: +1. `zitadel-init` — runs database migrations (one-shot) +2. `zitadel-setup` — configures the instance (one-shot) +3. `zitadel-api` — starts the API server (long-running) + +### Enable observability + +Set in `.env`: + +```dotenv +ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE=grpc +``` + +Download the collector configuration and start with the `observability` profile: + +```shell +curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/otel-collector-config.yaml && + +docker compose --env-file .env -f docker-compose.yml --profile observability up -d --wait +``` + +Traces are logged to the collector's stdout by default (`docker compose logs otel-collector`). +To forward traces to your own backend (Grafana Tempo, Jaeger, OpenObserve, etc.), set `OTEL_BACKEND_ENDPOINT` in `.env` and uncomment the `otlp` exporter in `otel-collector-config.yaml`. +See [Metrics](/self-hosting/manage/metrics/overview) for Prometheus scraping and available metric types. + +### Scale API replicas + + +Scaling requires the **prodlike overlay** so that migrations run once in `zitadel-init` instead of on every replica. + + +```shell +docker compose --env-file .env \ + -f docker-compose.yml \ + -f docker-compose.prodlike.yml \ + up -d --scale zitadel-api=3 +``` + +### Updating ZITADEL + +Edit `ZITADEL_VERSION` in `.env`, then: + +```shell +docker compose --env-file .env -f docker-compose.yml pull +docker compose --env-file .env -f docker-compose.yml up -d --wait +``` + + +`ZITADEL_FIRSTINSTANCE_*` and `ZITADEL_DEFAULTINSTANCE_*` environment variables are only applied during the **initial setup**. +To change settings on an existing installation, use the Admin Console or Admin API. + + +### Moving to Kubernetes + +Docker Compose is ideal for getting started and homelab deployments. +For production workloads, review the [Production Checklist](/self-hosting/manage/productionchecklist) and deploy with the official [Helm chart for Kubernetes](/self-hosting/deploy/kubernetes). + +The compose pack and the Helm chart share the same application configuration model (`ZITADEL_*` environment variables), so migration is straightforward. + + diff --git a/apps/docs/content/self-hosting/deploy/docker-compose.yaml b/apps/docs/content/self-hosting/deploy/docker-compose.yaml deleted file mode 100644 index 3eb9df424..000000000 --- a/apps/docs/content/self-hosting/deploy/docker-compose.yaml +++ /dev/null @@ -1,136 +0,0 @@ -services: - zitadel: - restart: unless-stopped - image: ghcr.io/zitadel/zitadel:latest - command: start-from-init --masterkey "MasterkeyNeedsToHave32Characters" - environment: - # See "What's next" to learn about how to serve Zitadel on a different domain or IP. - ZITADEL_EXTERNALDOMAIN: localhost - - # See "What's next" to learn about how to enable TLS. - ZITADEL_EXTERNALSECURE: false - ZITADEL_TLS_ENABLED: false - - # Database connection settings. - ZITADEL_DATABASE_POSTGRES_HOST: db - ZITADEL_DATABASE_POSTGRES_PORT: 5432 - # The database is created by the init job if it does not exist. - ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel - # The admin user must already exist in the database. - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres - ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres - ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable - # The zitadel user is created by the init job if it does not exist. - ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel - ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel - ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - - # By configuring a login application, the setup job creates a user of type machine with the role IAM_LOGIN_CLIENT. - # It writes a PAT to the path specified in ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH. - # The PAT is passed to the login container via the environment variable ZITADEL_SERVICE_USER_TOKEN_FILE. - ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /current-dir/login-client.pat - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false - ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client - ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Automatically Initialized IAM_LOGIN_CLIENT - ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: '2029-01-01T00:00:00Z' - # Activate the login v2 on an installation from scratch. - # To activate the login v2 on an existing installation, read the "What's next" section. - ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true # To use the login v1, set this to false. - ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:3000/ui/v2/login/ - # Configure the redirection paths to the login v2. - ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?authRequest= - ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:3000/ui/v2/login/logout?post_logout_redirect= - ZITADEL_SAML_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?samlRequest= - - # By configuring a machine, the setup job creates a user of type machine with the role IAM_OWNER. - # It writes a personal access token (PAT) to the path specified in ZITADEL_FIRSTINSTANCE_PATPATH. - # The PAT can be used to provision resources with [Terraform](/guides/manage/terraform-provider), for example. - # ZITADEL_FIRSTINSTANCE_PATPATH: /current-dir/admin.pat - # ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: admin - # ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Automatically Initialized IAM_OWNER - # ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: '2029-01-01T00:00:00Z' - - # To change the initial human admin users username and password, uncomment the following lines. - # The first login name is formatted like this: @. - # With the following incommented configuration, this would be root@my-organization.localhost - # Visit http://localhost:8080/ui/console to check if the login name works. - # If you can't log in, check the available login names: - # echo "select * from projections.login_names3;" | psql -h localhost -U postgres -d zitadel - # The postgres users password is postgres. - # ZITADEL_FIRSTINSTANCE_ORG_NAME: My Organization - # ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: root - # ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: RootPassword1! - - # Enable debug logs - # ZITADEL_LOG_LEVEL: debug - # Write Access Logs to stdout. - # ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED: true - - healthcheck: - test: - - CMD - - /app/zitadel - - ready - interval: 10s - timeout: 60s - retries: 5 - start_period: 10s - user: "0" - volumes: - - .:/current-dir:delegated - ports: - - 8080:8080 - - 3000:3000 - networks: - - zitadel - depends_on: - db: - condition: service_healthy - - login: - restart: unless-stopped - image: ghcr.io/zitadel/zitadel-login:latest - # If you can't use the network_mode service:zitadel, you can pass the environment variables ZITADEL_API_URL=http://zitadel:8080 and CUSTOM_REQUEST_HEADERS=Host:localhost instead. - environment: - - ZITADEL_API_URL=http://localhost:8080 - - NEXT_PUBLIC_BASE_PATH=/ui/v2/login - - ZITADEL_SERVICE_USER_TOKEN_FILE=/current-dir/login-client.pat - network_mode: service:zitadel - user: "0" - volumes: - - .:/current-dir:ro - depends_on: - zitadel: - condition: service_healthy - restart: false - - db: - restart: unless-stopped - image: postgres:17 - environment: - PGUSER: postgres - POSTGRES_PASSWORD: postgres - healthcheck: - test: - - CMD-SHELL - - pg_isready - - -d - - zitadel - - -U - - postgres - interval: 10s - timeout: 30s - retries: 5 - start_period: 20s - networks: - - zitadel - ports: - - 5432:5432 - volumes: - - 'data:/var/lib/postgresql/data:rw' - -networks: - zitadel: - -volumes: - data: diff --git a/apps/docs/content/self-hosting/deploy/troubleshooting/troubleshooting.mdx b/apps/docs/content/self-hosting/deploy/troubleshooting/troubleshooting.mdx index cecf388c2..e9c6a302e 100644 --- a/apps/docs/content/self-hosting/deploy/troubleshooting/troubleshooting.mdx +++ b/apps/docs/content/self-hosting/deploy/troubleshooting/troubleshooting.mdx @@ -14,3 +14,38 @@ import UpstreamHeader from "../../../self-hosting/deploy/troubleshooting/_upstre ## upstream sent too big header while reading response header from upstream + +## Container restarts after upgrading to a new version + +When you upgrade ZITADEL to a newer version, the setup phase runs database migrations automatically. + +**Which command to use for upgrades:** + +- `start-from-init` (used in the quickstart) runs **init → setup → start** in sequence. The init phase is only needed once ever (for a fresh database), but it is safe to re-run because it skips resources that already exist. Use `start-from-init` only for the quickstart or when you're sure the database needs initialising from scratch. +- **For upgrades on existing installations**, prefer `start-from-setup` (runs setup → start) or run `zitadel setup` followed by `zitadel start` as separate steps. This avoids running the init phase unnecessarily and is the recommended production approach. See [Update and Scale ZITADEL](/self-hosting/manage/updating_scaling) for details. + +If the container exits with code 1 and the logs show a database migration error, check the following: + +- Make sure the PostgreSQL version meets the [requirements](/self-hosting/manage/database). PostgreSQL 18 support requires ZITADEL v4.11.0 or later. +- Log messages like `role "zitadel" already exists` or `database "zitadel" already exists` during `start-from-init` re-runs are **expected and harmless** — the init phase skips those steps. +- If the container still fails after a version upgrade, check whether the version you are upgrading to lists any known migration issues in the [release notes](https://github.com/zitadel/zitadel/releases). + +## Instance settings (FIRSTINSTANCE / DEFAULTINSTANCE) not taking effect after restart + +Environment variables prefixed with `ZITADEL_FIRSTINSTANCE_` and `ZITADEL_DEFAULTINSTANCE_` are only applied during the **initial creation** of a ZITADEL instance. +Changing these variables and restarting the container will not update an already-existing instance. + +To change instance-level settings on a running installation: +- Use the [Admin Console](https://your-domain/ui/console/instance) to update settings interactively. +- Use the Admin API to automate changes. + +**Login V2**: Starting with ZITADEL v4, the Login V2 UI is enabled by default for new installations. +If you are running an existing installation and want to enable it, follow the [Login Client guide](/self-hosting/manage/login-client). + +## ZITADEL service becomes unhealthy without visible error messages + +If the `zitadel` container shows as `unhealthy` but the log output does not reveal an obvious error: + +1. Increase log verbosity by setting `ZITADEL_LOG_LEVEL: debug` and `ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED: true`. +2. Check that the healthcheck command can reach ZITADEL. The built-in `zitadel ready` command uses HTTP when `ZITADEL_TLS_ENABLED=false` and HTTPS otherwise. Ensure that this variable matches your TLS settings. +3. If ZITADEL is running behind a reverse proxy, make sure the proxy forwards traffic correctly before the first healthcheck succeeds. See [Run and configure a reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy). diff --git a/apps/docs/content/self-hosting/manage/configure/docker-compose.yaml b/apps/docs/content/self-hosting/manage/configure/docker-compose.yaml index 3fd0e5471..968a52211 100644 --- a/apps/docs/content/self-hosting/manage/configure/docker-compose.yaml +++ b/apps/docs/content/self-hosting/manage/configure/docker-compose.yaml @@ -21,14 +21,15 @@ services: environment: - POSTGRES_USER=root - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=zitadel networks: - 'zitadel' healthcheck: - test: ["CMD-SHELL", "pg_isready", "-d", "db_prod"] + test: ["CMD-SHELL", "pg_isready -d zitadel -U root"] interval: 10s - timeout: 60s + timeout: 30s retries: 5 - start_period: 10s + start_period: 20s volumes: - 'data:/var/lib/postgresql/data:rw' diff --git a/apps/docs/content/self-hosting/manage/requirements.mdx b/apps/docs/content/self-hosting/manage/requirements.mdx new file mode 100644 index 000000000..98c3cf4cd --- /dev/null +++ b/apps/docs/content/self-hosting/manage/requirements.mdx @@ -0,0 +1,36 @@ +--- +title: ZITADEL Requirements +sidebar_label: Requirements +--- + +This page lists the infrastructure versions that ZITADEL is tested against and officially supports. + +## Database + +ZITADEL requires a PostgreSQL database. Versions **14 to 18** are supported. + +See the [Database](/self-hosting/manage/database) page for full configuration options, how to pre-create users and databases, and how to rotate credentials. + +## Cache (optional) + +ZITADEL supports Redis as an optional cache to speed up lookups of frequently needed objects. +Redis must run in **standalone mode** (Cluster and Sentinel are not yet supported). + +See the [Caches](/self-hosting/manage/cache) page for configuration options. + +## Container runtime + +- **Docker Compose**: v2.x (Compose V1 `docker-compose` CLI is not supported) +- **Kubernetes**: see the [Kubernetes deployment guide](/self-hosting/deploy/kubernetes) + +## Reverse proxy + +Any reverse proxy that supports HTTP/2 upstream connections (h2c or h2) works with ZITADEL. +The following proxies are tested and have dedicated configuration guides: + +- **Traefik**: v3.x +- **NGINX**: v1.x stable releases +- **Caddy**: v2.x +- **Apache httpd**: 2.4.x + +See the [Reverse Proxy Configuration](/self-hosting/manage/reverseproxy/reverse_proxy) page for details and Docker Compose examples. diff --git a/apps/docs/content/self-hosting/manage/reverseproxy/caddy/index.mdx b/apps/docs/content/self-hosting/manage/reverseproxy/caddy/index.mdx index a0ec90087..551965496 100644 --- a/apps/docs/content/self-hosting/manage/reverseproxy/caddy/index.mdx +++ b/apps/docs/content/self-hosting/manage/reverseproxy/caddy/index.mdx @@ -28,6 +28,21 @@ You can either setup your environment for TLS mod +## Known issues + +### TE: trailers header causes requests to hang + +When a browser sends a `TE: trailers` HTTP header (common with HTTP/2), Caddy may forward it upstream where it conflicts with ZITADEL's gRPC-gateway handling. +Requests that result in an error response will hang indefinitely instead of returning the error to the client. + +**Workaround**: strip the header in Caddy before it reaches ZITADEL: + +``` +reverse_proxy h2c://zitadel:8080 { + header_up -TE +} +``` + ## More Information diff --git a/apps/docs/content/self-hosting/manage/reverseproxy/reverse_proxy.mdx b/apps/docs/content/self-hosting/manage/reverseproxy/reverse_proxy.mdx index aa9e8944e..daf1d1a24 100644 --- a/apps/docs/content/self-hosting/manage/reverseproxy/reverse_proxy.mdx +++ b/apps/docs/content/self-hosting/manage/reverseproxy/reverse_proxy.mdx @@ -3,6 +3,36 @@ title: Reverse Proxy Configuration description: "Setup and configure reverse proxies like Traefik, NGINX, and Caddy to manage external traffic for self-hosted ZITADEL." --- +ZITADEL exposes a single HTTP/2 port (default `8080`) that serves both gRPC and HTTP APIs, as well as the management console. +Running a reverse proxy in front of ZITADEL is the recommended approach for production deployments because it: + +- Terminates TLS and provides HTTPS to clients +- Exposes standard ports (`80`/`443`) while keeping ZITADEL on an internal port +- Handles certificate renewal (e.g. via Let's Encrypt) +- Enables path-based routing between the ZITADEL API and the Login UI + +## HTTP/2 and h2c requirements + +ZITADEL relies on HTTP/2 for all gRPC communication. +When the reverse proxy terminates TLS, it must forward traffic to ZITADEL using **h2c** (unencrypted HTTP/2), not HTTP/1.1. +Not all reverse proxies support h2c by default — check the proxy-specific guides below for configuration examples. + +For more details, see [HTTP/2 Support in ZITADEL](/self-hosting/manage/http2). + +## TLS modes + +ZITADEL supports three TLS modes that affect how the reverse proxy should be configured: + +| Mode | Description | Typical use | +|------|-------------|-------------| +| `disabled` | ZITADEL listens on plain HTTP, reverse proxy handles nothing special | Local development, h2c passthrough | +| `external` | ZITADEL listens on plain HTTP; reverse proxy terminates TLS | Most production setups | +| `enabled` | ZITADEL itself terminates TLS (end-to-end encryption) | Environments requiring mutual TLS | + +For more details, see [TLS Modes](/self-hosting/manage/tls_modes). + +## Proxy guides + Check out one of the following guides to configure your favorite reverse proxy: - [Traefik](/self-hosting/manage/reverseproxy/traefik) @@ -12,3 +42,14 @@ Check out one of the following guides to configure your favorite reverse proxy: - [Cloudflare](/self-hosting/manage/reverseproxy/cloudflare) - [Cloudflare Tunnel](/self-hosting/manage/reverseproxy/cloudflare_tunnel) - [Fronting Zitadel Cloud](/self-hosting/manage/reverseproxy/zitadel_cloud) + +## Login UI routing + +When running both the ZITADEL API and the Login V2 UI (`zitadel-login`) behind a reverse proxy, the proxy must route traffic to the correct upstream: + +- `/ui/v2/login` → Login UI container (port `3000` by default) +- All other paths → ZITADEL API container (port `8080` by default) + +The proxy-specific guides include Docker Compose examples that demonstrate this split routing. + +For tested proxy versions, see [ZITADEL Requirements](/self-hosting/manage/requirements#reverse-proxy). diff --git a/apps/docs/lib/sidebar-data.ts b/apps/docs/lib/sidebar-data.ts index 5efc40217..51e886e45 100644 --- a/apps/docs/lib/sidebar-data.ts +++ b/apps/docs/lib/sidebar-data.ts @@ -773,6 +773,7 @@ export const guidesSidebar: readonly SidebarItem[] = [ label: "Production & Operations", collapsed: false, items: [ + "self-hosting/manage/requirements", "self-hosting/manage/production", "self-hosting/manage/productionchecklist", "self-hosting/manage/usage_control", diff --git a/apps/login/AGENTS.md b/apps/login/AGENTS.md index f8bd36862..6ca1a5c6c 100644 --- a/apps/login/AGENTS.md +++ b/apps/login/AGENTS.md @@ -22,3 +22,4 @@ The **Login App** (`apps/login`) provides the user interface for authentication - **Test (all)**: `pnpm nx run @zitadel/login:test` - **Test (unit)**: `pnpm nx run @zitadel/login:test-unit` - **Test (integration)**: `pnpm nx run @zitadel/login:test-integration` +- **Pack (Docker)**: `pnpm nx run @zitadel/login:pack` — builds a local Docker image `zitadel/zitadel-login:local`. Requires Docker daemon. diff --git a/apps/login/project.json b/apps/login/project.json index 92228b827..cca178b50 100644 --- a/apps/login/project.json +++ b/apps/login/project.json @@ -40,6 +40,22 @@ "{projectRoot}/.next/standalone" ] }, + "pack": { + "description": "Builds a local Docker image for the ZITADEL Login UI (zitadel/zitadel-login:local). Requires Docker daemon.", + "dependsOn": [ + "build" + ], + "command": "docker build -f apps/login/Dockerfile -t zitadel/zitadel-login:local apps/login", + "cache": false, + "inputs": [ + "default", + "{workspaceRoot}/pnpm-lock.yaml", + "{projectRoot}/Dockerfile", + "!{projectRoot}/.env.*", + "!{projectRoot}/integration/**/*", + "!{projectRoot}/acceptance/**/*" + ] + }, "build-vercel": { "description": "Builds the Next.js Login application for Vercel deployment", "cache": true, @@ -152,7 +168,21 @@ "description": "Stops the gRPC mock container used for integration tests.", "command": "nx run @zitadel/login-api-mock:down" }, - "pack": { + "test-acceptance": { + "description": "Runs the Playwright acceptance test suite in apps/login/acceptance/ against a running ZITADEL stack. Requires LOGIN_BASE_URL, ZITADEL_API_URL, and ZITADEL_ADMIN_TOKEN to be set (or apps/login/.env.test.local to exist). Run the setup script at acceptance/setup/setup.sh first to seed config.", + "executor": "nx:run-commands", + "options": { + "cwd": "{projectRoot}", + "command": "pnpm exec playwright test --config acceptance/playwright.config.ts" + }, + "inputs": [ + "{projectRoot}/acceptance/**", + "!{projectRoot}/acceptance/playwright-report/**", + "!{projectRoot}/acceptance/test-results/**", + "{workspaceRoot}/pnpm-lock.yaml" + ] + }, + "pack-archive": { "description": "Packages the standalone Login application build into an archive", "dependsOn": [ "build" diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example new file mode 100644 index 000000000..89f40c3c7 --- /dev/null +++ b/deploy/compose/.env.example @@ -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=ops@example.com + +# ----------------------------- +# 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 diff --git a/deploy/compose/.env.test b/deploy/compose/.env.test new file mode 100644 index 000000000..487492e15 --- /dev/null +++ b/deploy/compose/.env.test @@ -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: +# @. → zitadel-admin@zitadel.localhost +SMOKE_TEST_ADMIN_USERNAME=zitadel-admin@zitadel.localhost +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 diff --git a/deploy/compose/.gitignore b/deploy/compose/.gitignore new file mode 100644 index 000000000..824bd3abe --- /dev/null +++ b/deploy/compose/.gitignore @@ -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 + diff --git a/deploy/compose/AGENTS.md b/deploy/compose/AGENTS.md new file mode 100644 index 000000000..83da508c0 --- /dev/null +++ b/deploy/compose/AGENTS.md @@ -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 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 diff --git a/deploy/compose/README.md b/deploy/compose/README.md new file mode 100644 index 000000000..343ca749f --- /dev/null +++ b/deploy/compose/README.md @@ -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 | diff --git a/deploy/compose/certs/.gitkeep b/deploy/compose/certs/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/deploy/compose/docker-compose.mode-external-tls.yml b/deploy/compose/docker-compose.mode-external-tls.yml new file mode 100644 index 000000000..3f787659d --- /dev/null +++ b/deploy/compose/docker-compose.mode-external-tls.yml @@ -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 diff --git a/deploy/compose/docker-compose.mode-letsencrypt.yml b/deploy/compose/docker-compose.mode-letsencrypt.yml new file mode 100644 index 000000000..358e91a48 --- /dev/null +++ b/deploy/compose/docker-compose.mode-letsencrypt.yml @@ -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: diff --git a/deploy/compose/docker-compose.mode-local-tls.yml b/deploy/compose/docker-compose.mode-local-tls.yml new file mode 100644 index 000000000..32c51dfd3 --- /dev/null +++ b/deploy/compose/docker-compose.mode-local-tls.yml @@ -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 diff --git a/deploy/compose/docker-compose.prodlike.yml b/deploy/compose/docker-compose.prodlike.yml new file mode 100644 index 000000000..b23d30e44 --- /dev/null +++ b/deploy/compose/docker-compose.prodlike.yml @@ -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 diff --git a/deploy/compose/docker-compose.test.yml b/deploy/compose/docker-compose.test.yml new file mode 100644 index 000000000..c4acb8bf2 --- /dev/null +++ b/deploy/compose/docker-compose.test.yml @@ -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 diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml new file mode 100644 index 000000000..0d35b8f25 --- /dev/null +++ b/deploy/compose/docker-compose.yml @@ -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: diff --git a/deploy/compose/otel-collector-config.yaml b/deploy/compose/otel-collector-config.yaml new file mode 100644 index 000000000..5fcff77e5 --- /dev/null +++ b/deploy/compose/otel-collector-config.yaml @@ -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] diff --git a/deploy/compose/package.json b/deploy/compose/package.json new file mode 100644 index 000000000..7d5d17d85 --- /dev/null +++ b/deploy/compose/package.json @@ -0,0 +1,8 @@ +{ + "name": "@zitadel/compose", + "private": true, + "devDependencies": { + "@playwright/test": "^1.58.0", + "dotenv": "^16.0.0" + } +} diff --git a/deploy/compose/playwright.config.ts b/deploy/compose/playwright.config.ts new file mode 100644 index 000000000..bfad73e2a --- /dev/null +++ b/deploy/compose/playwright.config.ts @@ -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"] }, + }, + ], +}); diff --git a/deploy/compose/project.json b/deploy/compose/project.json new file mode 100644 index 000000000..c67e56de2 --- /dev/null +++ b/deploy/compose/project.json @@ -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" + } + } + } +} diff --git a/deploy/compose/tests/smoke.spec.ts b/deploy/compose/tests/smoke.spec.ts new file mode 100644 index 000000000..70735c202 --- /dev/null +++ b/deploy/compose/tests/smoke.spec.ts @@ -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 ?? "zitadel-admin@zitadel.localhost"; +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 }); + }); +}); diff --git a/deploy/compose/tests/wiring.spec.ts b/deploy/compose/tests/wiring.spec.ts new file mode 100644 index 000000000..cbf7475ca --- /dev/null +++ b/deploy/compose/tests/wiring.spec.ts @@ -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; + body: Buffer; + trailers: Record; +}; + +function h2Request( + baseUrl: string, + method: string, + path: string, + reqHeaders: Record, + body?: Buffer | string, +): Promise { + 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 = {}; + const responseHeaders: Record = {}; + + 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, + body?: Buffer | string, +): Promise { + return h2Request(baseUrl, "POST", path, reqHeaders, body); +} + +function h2Get( + baseUrl: string, + path: string, + reqHeaders: Record = {}, +): Promise { + 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 { + // 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 = {}; + 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, +) { + 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(" { + 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(" { + 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}`); + } + }); + } +}); diff --git a/deploy/compose/traefik-local-tls.yml b/deploy/compose/traefik-local-tls.yml new file mode 100644 index 000000000..2dfb690af --- /dev/null +++ b/deploy/compose/traefik-local-tls.yml @@ -0,0 +1,6 @@ +tls: + stores: + default: + defaultCertificate: + certFile: /certs/local.crt + keyFile: /certs/local.key diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 890567bb5..de4d7292c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -631,6 +631,15 @@ importers: specifier: 5.9.2 version: 5.9.2 + deploy/compose: + devDependencies: + '@playwright/test': + specifier: ^1.58.0 + version: 1.58.0 + dotenv: + specifier: ^16.0.0 + version: 16.6.1 + packages/zitadel-client: dependencies: '@bufbuild/protobuf': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 277dba5fb..ba6e22bfc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - tests/* - packages/* - apps/* + - deploy/compose exclude: - benchmark # because of the node 18 or 20 dependency diff --git a/project.json b/project.json index 9920e2cc6..13c5e5817 100644 --- a/project.json +++ b/project.json @@ -22,7 +22,7 @@ "@zitadel/api:pack-linux-*", "@zitadel/api:pack-darwin-*", "@zitadel/api:pack-windows-*", - "@zitadel/login:pack" + "@zitadel/login:pack-archive" ], "executor": "nx:run-commands", "options": {