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

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

### What's in the pack

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

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

**Compose files**

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

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

### Build infra

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

### Testing

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

### Documentation

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

### Breaking change

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

---

### Checklist

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

---------

Co-authored-by: Mridang Agarwalla <[email protected]>
This commit is contained in:
Florian Forster
2026-02-24 16:33:12 -08:00
committed by GitHub
co-authored by Mridang Agarwalla
parent 54095b64b1
commit bac224c56d
38 changed files with 1968 additions and 210 deletions
+177 -66
View File
@@ -1,90 +1,201 @@
---
title: Set up Zitadel with Docker Compose
description: "Deploy a ZITADEL instance locally using Docker Compose for rapid testing and development."
title: Set up ZITADEL with Docker Compose
sidebar_label: Docker Compose
description: Deploy ZITADEL with Docker Compose — from a 2-minute localhost quickstart to a hardened homelab setup with TLS, caching, and observability.
---
import NoteInstanceNotFound from './troubleshooting/_note_instance_not_found.mdx';
import DockerCompose from './docker-compose.yaml';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import Disclaimer from './_disclaimer.mdx'
import DefaultUser from './_defaultuser.mdx'
import Next from './_next.mdx'
This guide is the entrypoint for running the Zitadel platform locally for the first time.
It is for demonstration and development purposes only and does not set up a production-ready and security hardened instance of Zitadel.
Once Zitadel up is and running, learn more about a production setup in the [What's Next section](#next-steps).
This guide takes you from zero to a running ZITADEL instance in minutes and then shows you how to harden it for a homelab or semi-production deployment.
## Prerequisites
The setup is likely to work with other software versions too, but it is tested against the following environment:
- Docker Engine 24+ with the Compose plugin (`docker compose`)
- A machine with at least 2 GB RAM
- Ubuntu 24.04.1
- Docker 28.3.2
- Docker Compose v2.38.2
- curl 8.5.0
See [Requirements](/self-hosting/manage/requirements) for supported database, cache, and proxy versions.
## Docker compose
## Stage 1 — Quickstart (2 minutes)
The following commands set up services for a PostgreSQL database, a Zitadel API and a Zitadel login:
Download the two required files, copy the example config, and start:
1. Download the `docker-compose.yaml` file from the Zitadel repository:
```shell
curl -L https://raw.githubusercontent.com/zitadel/zitadel/main/apps/docs/content/self-hosting/deploy/docker-compose.yaml -o docker-compose.yaml
```
2. Make sure the containers use the latest image versions.
```shell
docker compose pull
```
3. Run the PostgreSQL database, the Zitadel API, and the Zitadel login.
```shell
docker compose up --detach --wait
```
4. Verify the containers are running and healthy.
```shell
docker compose ps
```
```shell
mkdir zitadel-compose && cd zitadel-compose
# Download the compose file and example environment
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.yml &&
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/.env.example
# Create your environment file and start
cp .env.example .env
docker compose up -d --wait
```
That's it. Visit [http://localhost:8080](http://localhost:8080) to open the login screen.
<DefaultUser components={props.components} />
## What's next
<Callout type="info">
The base stack runs: **Traefik** ([reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy)) → **ZITADEL API** (Go) + **ZITADEL Login** (Next.js) → **PostgreSQL**.
All routing, including [gRPC over HTTP/2](/self-hosting/manage/http2), is handled automatically by Traefik — no extra configuration needed.
</Callout>
Before proceeding, review the downloaded `docker-compose.yaml` file:
The comments give context to the used variables and show examples for commonly used configuration variants.
## Stage 2 — Homelab / Semi-Production
<details>
<summary>docker-compose.yaml</summary>
<DynamicCodeBlock lang="yaml" code={DockerCompose} />
</details>
### Use your own domain
Here are some natural steps forward to go from the current setup to production:
Edit `.env` and set your real domain (see [Custom Domains](/self-hosting/manage/custom-domain) for details):
- **Use a different master key**: Set up an instance from scratch and use a different master key. For example, generate one by running `tr -dc A-Za-z0-9 </dev/urandom | head -c 32`
- **Use files for secrets instead of environment variables, and don't commit them to Git**: Learn how to [configure the platform](/self-hosting/manage/configure)
- **Run the containers as non-root users**: Remove the `user: "0"` lines from the `docker-compose.yaml` file.
- **Configure a different external domain or IP**: Beware that the login only works with HTTPS on non-localhost domains. [Run and configure a reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy)
- **Serve the API and the UI together on the same port**: [Run and configure a reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy)
- **Encrypt Traffic**: [Run and configure a reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy)
```dotenv
ZITADEL_DOMAIN=auth.example.com
```
For more detailed recommendations, read the [Zitadel Production Setup Guide](/self-hosting/manage/production).
### Enable TLS
<details>
<summary>Read this if the environment uses the login v1</summary>
<p>
The login v2 is the next generation of the Zitadel login.
Its code and deployment are easily customizable.
Unlike the login v1, it runs in its own process.
The login v2 is enabled by default in new installations.
But if an existing `docker-compose.yaml` file is updated by following this guide,
an additional `zitadel-login` service is now running which is actually not doing anything, yet.
The following steps give guidance on how to enable the login v2 in an environment.
</p>
<ol>
<li>Read the login v2 related comments in the docker-compose.yaml.</li>
<li><a href="../../self-hosting/manage/login-client#create-login-client">Manually create a login client personal access token (PAT) for the now running v2 login</a>.</li>
<li>Move the PAT to `./login-client.pat`</li>
<li>Restart the login process: `docker compose restart login`</li>
<li><a href="../../self-hosting/manage/login-client#require-login-v2">Enable the Login UI for all users</a></li>
</ol>
</details>
Pick a TLS mode and download the matching overlay file:
| Mode | Overlay file | When to use |
|------|-------------|-------------|
| Let's Encrypt | `docker-compose.mode-letsencrypt.yml` | Public domain, automatic certs |
| External TLS | `docker-compose.mode-external-tls.yml` | Behind a load balancer, CDN, or WAF that terminates TLS |
| Local TLS | `docker-compose.mode-local-tls.yml` | Self-signed certs for LAN-only access |
Download the overlay you need, then start with both files:
```shell
# Download the overlay (replace with your chosen mode)
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.mode-letsencrypt.yml
# Start with the overlay
docker compose --env-file .env \
-f docker-compose.yml \
-f docker-compose.mode-letsencrypt.yml \
up -d --wait
```
Set `LETSENCRYPT_EMAIL` in `.env` to receive certificate expiry notifications.
### Harden secrets
<Callout type="warn">
The masterkey [encrypts sensitive data at rest](/concepts/architecture/secrets). Once ZITADEL has been initialized with a masterkey, it **cannot be changed** without losing access to encrypted data. Generate it **before first start** and store it safely.
</Callout>
```shell
# Generate a secure masterkey (must be exactly 32 characters)
ZITADEL_MASTERKEY=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)
echo "ZITADEL_MASTERKEY=$ZITADEL_MASTERKEY" >> .env
# Set strong database passwords
echo "POSTGRES_ADMIN_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" >> .env
echo "POSTGRES_ZITADEL_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" >> .env
```
### External URL settings
These three settings **must match your public endpoint exactly**:
| Setting | Meaning | Example |
|---------|---------|---------|
| `ZITADEL_DOMAIN` | The public domain users type in | `auth.example.com` |
| `ZITADEL_EXTERNALPORT` | The port visible to users | `443` for HTTPS |
| `ZITADEL_EXTERNALSECURE` | Whether the public URL uses HTTPS | `true` for any TLS mode |
If these don't match reality, ZITADEL returns **"Instance not found"** errors. This is the most common deployment issue — see [TLS Modes](/self-hosting/manage/tls_modes) for details.
<NoteInstanceNotFound components={props.components} />
### Enable caching with Redis
See [Cache Configuration](/self-hosting/manage/cache) for all available options. Add these to `.env`:
```dotenv
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED=true
ZITADEL_CACHES_INSTANCE_CONNECTOR=redis
ZITADEL_CACHES_MILESTONES_CONNECTOR=redis
ZITADEL_CACHES_ORGANIZATION_CONNECTOR=redis
```
Then start with the `cache` profile:
```shell
docker compose --env-file .env -f docker-compose.yml --profile cache up -d --wait
```
## Stage 3 — Beyond Compose
### Production-like init/setup/start split
For controlled upgrades, separate [database initialization from the running API](/self-hosting/manage/updating_scaling):
```shell
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.prodlike.yml &&
docker compose --env-file .env \
-f docker-compose.yml \
-f docker-compose.prodlike.yml \
up -d --wait
```
This creates three ZITADEL containers:
1. `zitadel-init` — runs database migrations (one-shot)
2. `zitadel-setup` — configures the instance (one-shot)
3. `zitadel-api` — starts the API server (long-running)
### Enable observability
Set in `.env`:
```dotenv
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE=grpc
```
Download the collector configuration and start with the `observability` profile:
```shell
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/otel-collector-config.yaml &&
docker compose --env-file .env -f docker-compose.yml --profile observability up -d --wait
```
Traces are logged to the collector's stdout by default (`docker compose logs otel-collector`).
To forward traces to your own backend (Grafana Tempo, Jaeger, OpenObserve, etc.), set `OTEL_BACKEND_ENDPOINT` in `.env` and uncomment the `otlp` exporter in `otel-collector-config.yaml`.
See [Metrics](/self-hosting/manage/metrics/overview) for Prometheus scraping and available metric types.
### Scale API replicas
<Callout type="info">
Scaling requires the **prodlike overlay** so that migrations run once in `zitadel-init` instead of on every replica.
</Callout>
```shell
docker compose --env-file .env \
-f docker-compose.yml \
-f docker-compose.prodlike.yml \
up -d --scale zitadel-api=3
```
### Updating ZITADEL
Edit `ZITADEL_VERSION` in `.env`, then:
```shell
docker compose --env-file .env -f docker-compose.yml pull
docker compose --env-file .env -f docker-compose.yml up -d --wait
```
<Callout>
`ZITADEL_FIRSTINSTANCE_*` and `ZITADEL_DEFAULTINSTANCE_*` environment variables are only applied during the **initial setup**.
To change settings on an existing installation, use the Admin Console or Admin API.
</Callout>
### Moving to Kubernetes
Docker Compose is ideal for getting started and homelab deployments.
For production workloads, review the [Production Checklist](/self-hosting/manage/productionchecklist) and deploy with the official [Helm chart for Kubernetes](/self-hosting/deploy/kubernetes).
The compose pack and the Helm chart share the same application configuration model (`ZITADEL_*` environment variables), so migration is straightforward.
<Next components={props.components} />
@@ -1,136 +0,0 @@
services:
zitadel:
restart: unless-stopped
image: ghcr.io/zitadel/zitadel:latest
command: start-from-init --masterkey "MasterkeyNeedsToHave32Characters"
environment:
# See "What's next" to learn about how to serve Zitadel on a different domain or IP.
ZITADEL_EXTERNALDOMAIN: localhost
# See "What's next" to learn about how to enable TLS.
ZITADEL_EXTERNALSECURE: false
ZITADEL_TLS_ENABLED: false
# Database connection settings.
ZITADEL_DATABASE_POSTGRES_HOST: db
ZITADEL_DATABASE_POSTGRES_PORT: 5432
# The database is created by the init job if it does not exist.
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
# The admin user must already exist in the database.
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
# The zitadel user is created by the init job if it does not exist.
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
# By configuring a login application, the setup job creates a user of type machine with the role IAM_LOGIN_CLIENT.
# It writes a PAT to the path specified in ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH.
# The PAT is passed to the login container via the environment variable ZITADEL_SERVICE_USER_TOKEN_FILE.
ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /current-dir/login-client.pat
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Automatically Initialized IAM_LOGIN_CLIENT
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: '2029-01-01T00:00:00Z'
# Activate the login v2 on an installation from scratch.
# To activate the login v2 on an existing installation, read the "What's next" section.
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true # To use the login v1, set this to false.
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:3000/ui/v2/login/
# Configure the redirection paths to the login v2.
ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:3000/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?samlRequest=
# By configuring a machine, the setup job creates a user of type machine with the role IAM_OWNER.
# It writes a personal access token (PAT) to the path specified in ZITADEL_FIRSTINSTANCE_PATPATH.
# The PAT can be used to provision resources with [Terraform](/guides/manage/terraform-provider), for example.
# ZITADEL_FIRSTINSTANCE_PATPATH: /current-dir/admin.pat
# ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: admin
# ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Automatically Initialized IAM_OWNER
# ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: '2029-01-01T00:00:00Z'
# To change the initial human admin users username and password, uncomment the following lines.
# The first login name is formatted like this: <username>@<org_name>.<external_domain>
# With the following incommented configuration, this would be [email protected]
# Visit http://localhost:8080/ui/console to check if the login name works.
# If you can't log in, check the available login names:
# echo "select * from projections.login_names3;" | psql -h localhost -U postgres -d zitadel
# The postgres users password is postgres.
# ZITADEL_FIRSTINSTANCE_ORG_NAME: My Organization
# ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: root
# ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: RootPassword1!
# Enable debug logs
# ZITADEL_LOG_LEVEL: debug
# Write Access Logs to stdout.
# ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED: true
healthcheck:
test:
- CMD
- /app/zitadel
- ready
interval: 10s
timeout: 60s
retries: 5
start_period: 10s
user: "0"
volumes:
- .:/current-dir:delegated
ports:
- 8080:8080
- 3000:3000
networks:
- zitadel
depends_on:
db:
condition: service_healthy
login:
restart: unless-stopped
image: ghcr.io/zitadel/zitadel-login:latest
# If you can't use the network_mode service:zitadel, you can pass the environment variables ZITADEL_API_URL=http://zitadel:8080 and CUSTOM_REQUEST_HEADERS=Host:localhost instead.
environment:
- ZITADEL_API_URL=http://localhost:8080
- NEXT_PUBLIC_BASE_PATH=/ui/v2/login
- ZITADEL_SERVICE_USER_TOKEN_FILE=/current-dir/login-client.pat
network_mode: service:zitadel
user: "0"
volumes:
- .:/current-dir:ro
depends_on:
zitadel:
condition: service_healthy
restart: false
db:
restart: unless-stopped
image: postgres:17
environment:
PGUSER: postgres
POSTGRES_PASSWORD: postgres
healthcheck:
test:
- CMD-SHELL
- pg_isready
- -d
- zitadel
- -U
- postgres
interval: 10s
timeout: 30s
retries: 5
start_period: 20s
networks:
- zitadel
ports:
- 5432:5432
volumes:
- 'data:/var/lib/postgresql/data:rw'
networks:
zitadel:
volumes:
data:
@@ -14,3 +14,38 @@ import UpstreamHeader from "../../../self-hosting/deploy/troubleshooting/_upstre
## upstream sent too big header while reading response header from upstream
<UpstreamHeader components={props.components} />
## Container restarts after upgrading to a new version
When you upgrade ZITADEL to a newer version, the setup phase runs database migrations automatically.
**Which command to use for upgrades:**
- `start-from-init` (used in the quickstart) runs **init → setup → start** in sequence. The init phase is only needed once ever (for a fresh database), but it is safe to re-run because it skips resources that already exist. Use `start-from-init` only for the quickstart or when you're sure the database needs initialising from scratch.
- **For upgrades on existing installations**, prefer `start-from-setup` (runs setup → start) or run `zitadel setup` followed by `zitadel start` as separate steps. This avoids running the init phase unnecessarily and is the recommended production approach. See [Update and Scale ZITADEL](/self-hosting/manage/updating_scaling) for details.
If the container exits with code 1 and the logs show a database migration error, check the following:
- Make sure the PostgreSQL version meets the [requirements](/self-hosting/manage/database). PostgreSQL 18 support requires ZITADEL v4.11.0 or later.
- Log messages like `role "zitadel" already exists` or `database "zitadel" already exists` during `start-from-init` re-runs are **expected and harmless** — the init phase skips those steps.
- If the container still fails after a version upgrade, check whether the version you are upgrading to lists any known migration issues in the [release notes](https://github.com/zitadel/zitadel/releases).
## Instance settings (FIRSTINSTANCE / DEFAULTINSTANCE) not taking effect after restart
Environment variables prefixed with `ZITADEL_FIRSTINSTANCE_` and `ZITADEL_DEFAULTINSTANCE_` are only applied during the **initial creation** of a ZITADEL instance.
Changing these variables and restarting the container will not update an already-existing instance.
To change instance-level settings on a running installation:
- Use the [Admin Console](https://your-domain/ui/console/instance) to update settings interactively.
- Use the Admin API to automate changes.
**Login V2**: Starting with ZITADEL v4, the Login V2 UI is enabled by default for new installations.
If you are running an existing installation and want to enable it, follow the [Login Client guide](/self-hosting/manage/login-client).
## ZITADEL service becomes unhealthy without visible error messages
If the `zitadel` container shows as `unhealthy` but the log output does not reveal an obvious error:
1. Increase log verbosity by setting `ZITADEL_LOG_LEVEL: debug` and `ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED: true`.
2. Check that the healthcheck command can reach ZITADEL. The built-in `zitadel ready` command uses HTTP when `ZITADEL_TLS_ENABLED=false` and HTTPS otherwise. Ensure that this variable matches your TLS settings.
3. If ZITADEL is running behind a reverse proxy, make sure the proxy forwards traffic correctly before the first healthcheck succeeds. See [Run and configure a reverse proxy](/self-hosting/manage/reverseproxy/reverse_proxy).
@@ -21,14 +21,15 @@ services:
environment:
- POSTGRES_USER=root
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=zitadel
networks:
- 'zitadel'
healthcheck:
test: ["CMD-SHELL", "pg_isready", "-d", "db_prod"]
test: ["CMD-SHELL", "pg_isready -d zitadel -U root"]
interval: 10s
timeout: 60s
timeout: 30s
retries: 5
start_period: 10s
start_period: 20s
volumes:
- 'data:/var/lib/postgresql/data:rw'
@@ -0,0 +1,36 @@
---
title: ZITADEL Requirements
sidebar_label: Requirements
---
This page lists the infrastructure versions that ZITADEL is tested against and officially supports.
## Database
ZITADEL requires a PostgreSQL database. Versions **14 to 18** are supported.
See the [Database](/self-hosting/manage/database) page for full configuration options, how to pre-create users and databases, and how to rotate credentials.
## Cache (optional)
ZITADEL supports Redis as an optional cache to speed up lookups of frequently needed objects.
Redis must run in **standalone mode** (Cluster and Sentinel are not yet supported).
See the [Caches](/self-hosting/manage/cache) page for configuration options.
## Container runtime
- **Docker Compose**: v2.x (Compose V1 `docker-compose` CLI is not supported)
- **Kubernetes**: see the [Kubernetes deployment guide](/self-hosting/deploy/kubernetes)
## Reverse proxy
Any reverse proxy that supports HTTP/2 upstream connections (h2c or h2) works with ZITADEL.
The following proxies are tested and have dedicated configuration guides:
- **Traefik**: v3.x
- **NGINX**: v1.x stable releases
- **Caddy**: v2.x
- **Apache httpd**: 2.4.x
See the [Reverse Proxy Configuration](/self-hosting/manage/reverseproxy/reverse_proxy) page for details and Docker Compose examples.
@@ -28,6 +28,21 @@ You can either setup your environment for <a href={'#tls-mode-external'}>TLS mod
<ProxyGuideTLSMode components={props.components} mode="enabled" configfilename="enabled-tls.Caddyfile" configfilecontent={ConfigEnabled} providername={providername} link={link} lower={lower}></ProxyGuideTLSMode>
## Known issues
### TE: trailers header causes requests to hang
When a browser sends a `TE: trailers` HTTP header (common with HTTP/2), Caddy may forward it upstream where it conflicts with ZITADEL's gRPC-gateway handling.
Requests that result in an error response will hang indefinitely instead of returning the error to the client.
**Workaround**: strip the header in Caddy before it reaches ZITADEL:
```
reverse_proxy h2c://zitadel:8080 {
header_up -TE
}
```
## More Information
<ProxyGuideMore components={props.components} ></ProxyGuideMore>
@@ -3,6 +3,36 @@ title: Reverse Proxy Configuration
description: "Setup and configure reverse proxies like Traefik, NGINX, and Caddy to manage external traffic for self-hosted ZITADEL."
---
ZITADEL exposes a single HTTP/2 port (default `8080`) that serves both gRPC and HTTP APIs, as well as the management console.
Running a reverse proxy in front of ZITADEL is the recommended approach for production deployments because it:
- Terminates TLS and provides HTTPS to clients
- Exposes standard ports (`80`/`443`) while keeping ZITADEL on an internal port
- Handles certificate renewal (e.g. via Let's Encrypt)
- Enables path-based routing between the ZITADEL API and the Login UI
## HTTP/2 and h2c requirements
ZITADEL relies on HTTP/2 for all gRPC communication.
When the reverse proxy terminates TLS, it must forward traffic to ZITADEL using **h2c** (unencrypted HTTP/2), not HTTP/1.1.
Not all reverse proxies support h2c by default — check the proxy-specific guides below for configuration examples.
For more details, see [HTTP/2 Support in ZITADEL](/self-hosting/manage/http2).
## TLS modes
ZITADEL supports three TLS modes that affect how the reverse proxy should be configured:
| Mode | Description | Typical use |
|------|-------------|-------------|
| `disabled` | ZITADEL listens on plain HTTP, reverse proxy handles nothing special | Local development, h2c passthrough |
| `external` | ZITADEL listens on plain HTTP; reverse proxy terminates TLS | Most production setups |
| `enabled` | ZITADEL itself terminates TLS (end-to-end encryption) | Environments requiring mutual TLS |
For more details, see [TLS Modes](/self-hosting/manage/tls_modes).
## Proxy guides
Check out one of the following guides to configure your favorite reverse proxy:
- [Traefik](/self-hosting/manage/reverseproxy/traefik)
@@ -12,3 +42,14 @@ Check out one of the following guides to configure your favorite reverse proxy:
- [Cloudflare](/self-hosting/manage/reverseproxy/cloudflare)
- [Cloudflare Tunnel](/self-hosting/manage/reverseproxy/cloudflare_tunnel)
- [Fronting Zitadel Cloud](/self-hosting/manage/reverseproxy/zitadel_cloud)
## Login UI routing
When running both the ZITADEL API and the Login V2 UI (`zitadel-login`) behind a reverse proxy, the proxy must route traffic to the correct upstream:
- `/ui/v2/login` → Login UI container (port `3000` by default)
- All other paths → ZITADEL API container (port `8080` by default)
The proxy-specific guides include Docker Compose examples that demonstrate this split routing.
For tested proxy versions, see [ZITADEL Requirements](/self-hosting/manage/requirements#reverse-proxy).
+1
View File
@@ -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",