- Pin seaweedfs (4.47), jaeger (1.76.0), certbot (v5.8.0) and pgadmin (9.18) instead of :latest. - Rotate container logs (json-file, 3 x 10 MB) in every stack. - Cap Jaeger's in-memory storage at 100k traces and add a healthcheck. - Publish dev ports on 127.0.0.1 (DEV_BIND_ADDRESS) so the dev credentials aren't reachable from the LAN. - Fix the prod command in compose.yml's header, the README's note on who writes the admin PAT, and the certbot command's line breaks.
251 lines
10 KiB
YAML
251 lines
10 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 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.
|
|
|
|
# Docker's default json-file driver never rotates, so logs would grow until
|
|
# the disk fills up.
|
|
x-logging: &default-logging
|
|
driver: json-file
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
|
|
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]
|
|
logging: *default-logging
|
|
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]
|
|
logging: *default-logging
|
|
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]
|
|
logging: *default-logging
|
|
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:4.47
|
|
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]
|
|
logging: *default-logging
|
|
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
|
|
# The node is named rabbit@<hostname> and its data lives under that name.
|
|
# Without a fixed hostname every recreated container gets a new one and
|
|
# starts on an empty data directory (users, vhosts, queues all gone).
|
|
hostname: 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]
|
|
logging: *default-logging
|
|
restart: unless-stopped
|
|
|
|
# --- Tracing --------------------------------------------------------------
|
|
# OTLP collector + trace UI, shared by any project's OpenTelemetry exporter.
|
|
# Last Jaeger v1 release (v1 is deprecated; v2 is jaegertracing/jaeger).
|
|
jaeger:
|
|
image: jaegertracing/all-in-one:1.76.0
|
|
container_name: infra-jaeger
|
|
environment:
|
|
COLLECTOR_OTLP_ENABLED: "true"
|
|
# Traces are kept in memory; without a cap they grow until the
|
|
# container runs out of RAM. The oldest are dropped first.
|
|
MEMORY_MAX_TRACES: "100000"
|
|
healthcheck:
|
|
# Admin port: answers 200 once the collector and query service are up.
|
|
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:14269/"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 10
|
|
networks: [infra-net]
|
|
logging: *default-logging
|
|
restart: unless-stopped
|
|
|
|
volumes:
|
|
postgres_data:
|
|
zitadel_bootstrap:
|
|
seaweedfs_data:
|
|
rabbitmq_data:
|