.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.
272 lines
12 KiB
YAML
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:
|