# Ping — base stack, shared by development and production. # # This intentionally does NOT run Zitadel or SeaweedFS: Ping reuses the same instances # already running for Musify (see deploy/.env.example — ZITADEL_ADMIN_PAT_DIR / S3_*). # Bring Musify's stack up first, then this one. # # dev docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d # # The `api` and `web` services sit behind the `apps` profile, so a plain `up` starts # Postgres, LiveKit and the one-shot bootstrap jobs (migrations, Zitadel project/apps, # SeaweedFS bucket) and stops there — the usual dev setup, with the apps running from the # IDE. Add `--profile apps up -d --build` as a SECOND command to also build and run them in # Docker: by then zitadel-init has already written the OIDC client ids into the env file. x-app-depends: &app-depends postgres: condition: service_healthy x-backend-env: &backend-env Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" # The public S3 URL, not the internal one: the same address is used to sign the # presigned upload/download URLs the browser has to be able to reach. InfrastructureStorage__Address: ${S3_PUBLIC_URL} InfrastructureStorage__AccessKey: ${S3_ACCESS_KEY} InfrastructureStorage__SecretAccessKey: ${S3_SECRET_KEY} InfrastructureStorage__ForcePathStyle: "true" InfrastructureStorage__UseHttp: ${S3_USE_HTTP} ApplicationStorage__Bucket: ${S3_BUCKET} services: # --- Infrastructure ------------------------------------------------------- postgres: image: postgres:17-alpine container_name: ping-postgres environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 10 restart: unless-stopped # Voice/video SFU. --dev keeps it single-node with in-memory state (fine for local # development), but the key pair is overridden via LIVEKIT_KEYS: the LiveKit .NET server SDK # rejects secrets shorter than 256 bits, and --dev's built-in "devkey/secret" pair is too short. livekit: image: livekit/livekit-server:latest container_name: ping-livekit command: --dev --bind 0.0.0.0 environment: LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}" # Node IP is "automatically determined" by default, which inside a container means the # Docker bridge network's internal address (e.g. 172.x.x.x) — unreachable from the # browser, so ICE/media never connects even though the WebSocket signaling (which goes # through the published port, not this address) succeeds. Every public URL in this dev # stack is already hardcoded to localhost (see PUBLIC_WEB_URL, VITE_LIVEKIT_URL), so the # browser and this container are always on the same machine — advertise that directly. NODE_IP: 127.0.0.1 restart: unless-stopped # One-shot: creates the Ping project and its two OIDC apps in Musify's Zitadel instance # (or reuses them if they already exist), then writes AUTH_CLIENT_ID / WEB_CLIENT_ID back # into the env file mounted at /workspace/.env — so a later `--profile apps up` picks up # real values instead of empty ones. Both apps are public clients (PKCE, no secret): the # Scalar docs UI and the React SPA. zitadel-init: image: alpine:3.20 container_name: ping-zitadel-init volumes: - ${ZITADEL_ADMIN_PAT_DIR}:/zitadel-output:ro - ./.env:/workspace/.env environment: PUBLIC_AUTH_URL: ${PUBLIC_AUTH_URL} PUBLIC_API_URL: ${PUBLIC_API_URL} PUBLIC_WEB_URL: ${PUBLIC_WEB_URL} ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false} entrypoint: ["/bin/sh", "-c"] command: - | set -e apk add --no-cache curl jq >/dev/null ENV_FILE=/workspace/.env if [ ! -f "$$ENV_FILE" ]; then echo "no env file mounted at $$ENV_FILE, skipping" exit 0 fi env_set() { if grep -qE "^$$2=" "$$1"; then awk -v k="$$2" -v v="$$3" '{ sub(/\r$$/, "") } index($$0,k "=")==1{print k "=" v; next} {print}' "$$1" >"$$1.tmp" && cat "$$1.tmp" >"$$1" && rm -f "$$1.tmp" else printf '%s=%s\n' "$$2" "$$3" >>"$$1" fi } if [ ! -f /zitadel-output/admin-sa.pat ]; then echo "admin-sa.pat not found under ZITADEL_ADMIN_PAT_DIR — is Musify's Zitadel running?" >&2 exit 1 fi PAT=$$(tr -d '\r\n' waiting for Zitadel to answer at $$PUBLIC_AUTH_URL" deadline=$$(( $$(date +%s) + 60 )) until curl -fsS "$$PUBLIC_AUTH_URL/.well-known/openid-configuration" >/dev/null 2>&1; do [ "$$(date +%s)" -lt "$$deadline" ] || { echo "Zitadel did not respond at $$PUBLIC_AUTH_URL" >&2; exit 1; } sleep 3 done api_call() { if [ $$# -ge 3 ]; then curl -fsS -X "$$1" "$$MGMT$$2" -H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" -d "$$3" else curl -fsS -X "$$1" "$$MGMT$$2" -H "Authorization: Bearer $$PAT" fi } name_query() { printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$$1"; } echo "==> project" PROJECT_NAME=Ping PROJECT_ID=$$(api_call POST /projects/_search "$$(name_query "$$PROJECT_NAME")" | jq -r --arg n "$$PROJECT_NAME" '.result // [] | map(select(.name==$$n)) | first | .id // empty') if [ -z "$$PROJECT_ID" ]; then PROJECT_ID=$$(api_call POST /projects "$$(printf '{"name":"%s"}' "$$PROJECT_NAME")" | jq -r '.id') echo " created (id $$PROJECT_ID)" else echo " reused (id $$PROJECT_ID)" fi find_app() { api_call POST "/projects/$$PROJECT_ID/apps/_search" "$$(name_query "$$1")" | jq -r --arg n "$$1" '.result // [] | map(select(.name==$$n)) | first | .id // empty'; } echo "==> API app (Scalar)" API_APP_NAME="Ping API (Scalar)" API_APP_ID=$$(find_app "$$API_APP_NAME") if [ -n "$$API_APP_ID" ]; then API_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$API_APP_ID" | jq -r '.app.oidcConfig.clientId') echo " reused (clientId $$API_CLIENT_ID)" else API_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(jq -n --arg name "$$API_APP_NAME" --arg api "$$PUBLIC_API_URL" '{ name: $$name, redirectUris: [($$api + "/scalar/"), ($$api + "/scalar/oauth2-redirect.html")], postLogoutRedirectUris: [($$api + "/scalar/")], responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"], appType: "OIDC_APP_TYPE_USER_AGENT", authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE", version: "OIDC_VERSION_1_0", devMode: ($$ENV.ZITADEL_DEV_MODE == "true"), accessTokenType: "OIDC_TOKEN_TYPE_JWT", accessTokenRoleAssertion: true, idTokenRoleAssertion: true, idTokenUserinfoAssertion: true }')" | jq -r '.clientId') echo " created (clientId $$API_CLIENT_ID)" fi echo "==> web app (React SPA — public client, PKCE)" WEB_APP_NAME="Ping Web" WEB_APP_ID=$$(find_app "$$WEB_APP_NAME") if [ -n "$$WEB_APP_ID" ]; then WEB_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$WEB_APP_ID" | jq -r '.app.oidcConfig.clientId') echo " reused (clientId $$WEB_CLIENT_ID)" else WEB_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(jq -n --arg name "$$WEB_APP_NAME" --arg web "$$PUBLIC_WEB_URL" '{ name: $$name, redirectUris: [($$web + "/auth/callback")], postLogoutRedirectUris: [($$web + "/")], responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], appType: "OIDC_APP_TYPE_USER_AGENT", authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE", version: "OIDC_VERSION_1_0", devMode: ($$ENV.ZITADEL_DEV_MODE == "true"), accessTokenType: "OIDC_TOKEN_TYPE_JWT", accessTokenRoleAssertion: true, idTokenRoleAssertion: true, idTokenUserinfoAssertion: true }')" | jq -r '.clientId') echo " created (clientId $$WEB_CLIENT_ID)" fi env_set "$$ENV_FILE" AUTH_CLIENT_ID "$$API_CLIENT_ID" env_set "$$ENV_FILE" WEB_CLIENT_ID "$$WEB_CLIENT_ID" echo "==> wired into $$ENV_FILE" restart: "no" # One-shot: SeaweedFS does not auto-create buckets. Reuses Musify's SeaweedFS instance # (same S3 endpoint/credentials) but creates Ping's own, separate bucket. seaweedfs-init: image: amazon/aws-cli:latest container_name: ping-seaweedfs-init environment: AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY} AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY} AWS_DEFAULT_REGION: us-east-1 entrypoint: ["/bin/sh", "-c"] command: - | aws --endpoint-url ${S3_PUBLIC_URL} s3 mb s3://${S3_BUCKET} 2>/dev/null || true aws --endpoint-url ${S3_PUBLIC_URL} s3 ls restart: "no" # One-shot: applies the EF Core migrations, then exits. migrate: build: context: .. dockerfile: Ping.Migrator.Dockerfile container_name: ping-migrate depends_on: postgres: condition: service_healthy environment: Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" restart: "no" # --- Applications (profile: apps) ----------------------------------------- api: profiles: [apps] build: context: .. dockerfile: Ping.Api.Dockerfile container_name: ping-api depends_on: <<: *app-depends migrate: condition: service_completed_successfully environment: <<: *backend-env ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT} ASPNETCORE_URLS: http://+:5211 # AUTH_CLIENT_ID is written to the env file by the zitadel-init service. Authentication__MetadataAddress: "${PUBLIC_AUTH_URL}/.well-known/openid-configuration" Authentication__IssuerAddress: ${PUBLIC_AUTH_URL} Authentication__ClientId: ${AUTH_CLIENT_ID} Authentication__AudienceAddress: ${AUTH_CLIENT_ID} Authentication__AuthorizationEndpoint: "${PUBLIC_AUTH_URL}/oauth/v2/authorize?prompt=login" Authentication__TokenEndpoint: "${PUBLIC_AUTH_URL}/oauth/v2/token" Authentication__ScalarRedirectUri: "${PUBLIC_API_URL}/scalar/" Authentication__Scopes__0: openid Authentication__Scopes__1: profile Authentication__Scopes__2: email Authentication__RequireHttpsMetadata: ${AUTH_REQUIRE_HTTPS} LiveKit__ApiKey: ${LIVEKIT_API_KEY} LiveKit__ApiSecret: ${LIVEKIT_API_SECRET} restart: unless-stopped web: profiles: [apps] build: context: ../../frontend dockerfile: Dockerfile args: VITE_API_BASE_URL: ${PUBLIC_API_URL} VITE_AUTH_AUTHORITY: ${PUBLIC_AUTH_URL} VITE_AUTH_CLIENT_ID: ${WEB_CLIENT_ID} VITE_AUTH_REDIRECT_URI: "${PUBLIC_WEB_URL}/auth/callback" VITE_LIVEKIT_URL: ws://localhost:7880 container_name: ping-web depends_on: - api restart: unless-stopped volumes: postgres_data: