# 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:// 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 `@` 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: