Files
SharedServices/compose.yml
T
Admin 6dcbd9589a Serve the Zitadel login from the Login V2 container
Zitadel v4 redirects every sign-in to /ui/v2/login, which is served by a
separate container (zitadel-login) that this stack didn't run, so logging
in on a fresh instance ended in a 404.

- Add zitadel-login, with its own service account (login-client) whose
  PAT Zitadel writes to a volume only that container mounts.
- Configure the Login V2 base URL (ZITADEL_LOGIN_BASE_URL): its own port
  in dev, same hostname as Zitadel behind the edge in prod.
- Pin zitadel and zitadel-login to v4.19.1 and add healthchecks.
- Run Zitadel as root, as Zitadel's own compose does, so it can write the
  PATs into root-owned mounts (the Linux bind mount and the new volume).
- Route /ui/v2/login on the auth vhost to the login container and drop
  the no-op grpc_set_header.
- Document the extra steps for an instance created before this.
2026-09-24 15:05:42 +02:00

223 lines
9.5 KiB
YAML

# Shared infrastructure: identity, storage, database, messaging and tracing
# for every project (Musify, Ping, docker-manager, ...).
#
# This file is the production shape: no published ports, no domains. Every
# service is reachable only on the `infra-net` network. Development reaches
# it through compose.dev.yml, which publishes the ports to the host instead
# of joining a network. See README.md for why.
#
# dev docker compose -f compose.yml -f compose.dev.yml up -d
# prod docker compose --env-file .env.prod -f compose.yml -f compose.prod.yml up -d
#
# A consuming project's own deploy stack does NOT get its own copy of any of
# these services. It only needs this stack to already be up, and its own
# one-shot bootstrap jobs (zitadel-init, seaweedfs-init) to create its own
# OIDC apps / bucket against the shared instances below.
networks:
infra-net:
name: infra-net
services:
# --- Database ---------------------------------------------------------
# One instance, one database per consuming project (see postgres-init/).
# Zitadel provisions its own `zitadel` database itself, from the admin
# credentials below, so it does not need an init script.
postgres:
image: postgres:17-alpine
container_name: infra-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./postgres-init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 10
networks: [infra-net]
restart: unless-stopped
# --- Identity -----------------------------------------------------------
# OIDC provider shared by every project. `start-from-init` runs init +
# setup (both idempotent) and then starts, so the same command works on a
# cold and on a warm volume.
zitadel:
# Pinned together with zitadel-login below: bump both at once.
image: ghcr.io/zitadel/zitadel:v4.19.1
container_name: infra-zitadel
# The image runs as uid 1000, but Docker creates both the bind mount
# (on Linux) and the bootstrap volume as root, so Zitadel couldn't write
# the PATs into them. Same as Zitadel's own compose.
user: "0"
# `disabled` (dev, plain http) makes Zitadel advertise http URLs; behind a
# TLS-terminating proxy it must be `external` or the console calls
# http://<domain> and the CSP blocks it.
command: start-from-init --masterkeyFromEnv --tlsMode ${ZITADEL_TLS_MODE:-disabled}
environment:
ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY}
# The external domain/port form the token issuer and must be identical
# for the browser, every project's apps and the containers (Zitadel
# routes by Host).
ZITADEL_EXTERNALSECURE: ${ZITADEL_EXTERNAL_SECURE}
ZITADEL_EXTERNALDOMAIN: ${ZITADEL_EXTERNAL_DOMAIN}
ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNAL_PORT}
ZITADEL_TLS_ENABLED: "false"
ZITADEL_DATABASE_POSTGRES_HOST: postgres
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DATABASE_PASSWORD}
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_USER}
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_PASSWORD}
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
# First organization and its console admin. Only read when the instance
# is created; changing them later doesn't rename anything that exists.
# A username without "@" gets `@<org domain>` appended; one with "@" is
# kept as is (e.g. admin@example.com).
ZITADEL_FIRSTINSTANCE_ORG_NAME: ${ZITADEL_ORG_NAME:-ZITADEL}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_ADMIN_USERNAME}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_FIRSTNAME: ${ZITADEL_ADMIN_FIRST_NAME:-ZITADEL}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_LASTNAME: ${ZITADEL_ADMIN_LAST_NAME:-Admin}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_DISPLAYNAME: ${ZITADEL_ADMIN_DISPLAY_NAME:-ZITADEL Admin}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false"
# Service account + PAT written on init; each consuming project's own
# zitadel-init one-shot uses it to create its own project/apps.
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin Service Account
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z"
ZITADEL_FIRSTINSTANCE_PATPATH: /output/admin-sa.pat
# Service account the login UI (zitadel-login) calls the API with. Its
# PAT goes to a volume only that container mounts, not to /output.
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: "2099-01-01T00:00:00Z"
ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /bootstrap/login-client.pat
# Login V2: sign-in pages are served by zitadel-login, not by Zitadel
# itself. Like the FIRSTINSTANCE values, these are only read when the
# instance is created (see README for an existing instance).
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "true"
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: ${ZITADEL_LOGIN_BASE_URL}/
ZITADEL_OIDC_DEFAULTLOGINURLV2: ${ZITADEL_LOGIN_BASE_URL}/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: ${ZITADEL_LOGIN_BASE_URL}/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: ${ZITADEL_LOGIN_BASE_URL}/login?samlRequest=
depends_on:
postgres:
condition: service_healthy
volumes:
- ./zitadel/.output:/output
- zitadel_bootstrap:/bootstrap
healthcheck:
test: ["CMD", "/app/zitadel", "ready"]
interval: 10s
timeout: 30s
retries: 12
start_period: 20s
networks: [infra-net]
restart: unless-stopped
# Login V2 UI (Next.js), served under /ui/v2/login. It calls the API
# directly on infra-net, so it has to present the public Host/scheme for
# Zitadel to find the instance.
zitadel-login:
image: ghcr.io/zitadel/zitadel-login:v4.19.1
container_name: infra-zitadel-login
environment:
ZITADEL_API_URL: http://zitadel:8080
NEXT_PUBLIC_BASE_PATH: /ui/v2/login
ZITADEL_SERVICE_USER_TOKEN_FILE: /bootstrap/login-client.pat
CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_EXTERNAL_DOMAIN},X-Forwarded-Proto:${ZITADEL_PUBLIC_SCHEME}
healthcheck:
test: ["CMD", "/bin/sh", "-c", "node /app/healthcheck.mjs http://localhost:3000/ui/v2/login/healthy"]
interval: 10s
timeout: 30s
retries: 12
start_period: 20s
depends_on:
zitadel:
condition: service_healthy
volumes:
- zitadel_bootstrap:/bootstrap:ro
networks: [infra-net]
restart: unless-stopped
# --- Storage --------------------------------------------------------------
# S3-compatible object store shared by every project. All-in-one: master +
# volume + filer + S3 gateway. The S3 identity file is rendered from the
# environment on start so the credentials can never drift from the ones
# handed to consuming projects. No bucket is created here; each
# consuming project's own seaweedfs-init one-shot creates its own.
seaweedfs:
image: chrislusf/seaweedfs:latest
container_name: infra-seaweedfs
entrypoint: ["/bin/sh", "-c"]
command:
- |
cat > /tmp/s3.conf <<'JSON'
{
"identities": [
{
"name": "infra",
"credentials": [{ "accessKey": "${S3_ACCESS_KEY}", "secretKey": "${S3_SECRET_KEY}" }],
"actions": ["Admin", "Read", "Write"]
}
]
}
JSON
exec weed server \
-ip.bind=0.0.0.0 \
-dir=/data \
-master.volumeSizeLimitMB=1024 \
-volume.max=0 \
-filer \
-s3 \
-s3.config=/tmp/s3.conf
volumes:
- seaweedfs_data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"]
interval: 10s
timeout: 5s
retries: 10
networks: [infra-net]
restart: unless-stopped
# --- Messaging --------------------------------------------------------
# Broker shared by every project. Each project's own consumers/exchanges
# are project-specific; only the broker itself lives here.
rabbitmq:
image: rabbitmq:4-management-alpine
container_name: infra-rabbitmq
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 10
networks: [infra-net]
restart: unless-stopped
# --- Tracing --------------------------------------------------------------
# OTLP collector + trace UI, shared by any project's OpenTelemetry exporter.
jaeger:
image: jaegertracing/all-in-one:latest
container_name: infra-jaeger
environment:
COLLECTOR_OTLP_ENABLED: "true"
networks: [infra-net]
restart: unless-stopped
volumes:
postgres_data:
zitadel_bootstrap:
seaweedfs_data:
rabbitmq_data: