Files
Ping/backend/deploy/compose.yml
T
Admin 0623875dfa Add Ping backend
.NET 10 solution for Ping, a Discord style chat app: Domain, Application
and Infrastructure projects plus the Api host. SignalR for chat and
presence, LiveKit for call tokens, EF Core on Postgres, S3 compatible
storage for attachments, and OIDC auth shared with the Musify Zitadel
instance. Includes the Compose stack for local development.
2026-09-21 19:32:55 +02:00

272 lines
12 KiB
YAML

# 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' </zitadel-output/admin-sa.pat)
MGMT="$$PUBLIC_AUTH_URL/management/v1"
echo "==> 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: