Files
Musify/deploy/compose.yml
Admin f99f74f4a1 Use Musify's own credentials on the shared services
Infrastructure now gives each project its own Postgres role, RabbitMQ
user and vhost, S3 identity limited to its bucket and a Zitadel service
account that owns only its project, instead of handing out the admin
accounts.

- Postgres, RabbitMQ and S3 settings default to Musify's own (musify).
- MassTransit connects to the `musify` vhost (RABBITMQ_VHOST, taken from
  the address path); RABBITMQ_DEFAULT_USER/PASS become RABBITMQ_USER and
  RABBITMQ_PASSWORD.
- zitadel-init mounts ZITADEL_PAT_DIR (Infrastructure's
  zitadel/.output/musify) and uses that PAT. It no longer creates the
  Zitadel project, which that account can't do: Infrastructure does.
2026-09-24 17:05:23 +02:00

410 lines
19 KiB
YAML

# Musify: base stack, shared by development and production.
#
# Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are NOT part of this
# file. They're shared infrastructure that lives in ../../../Infrastructure
# and must already be running (see Infrastructure/README.md). This file only
# has what's specific to Musify: its own database, its own OIDC apps and
# storage bucket (created against the shared instances below), and its own
# apps.
#
# dev docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
# prod docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
#
# The four application services sit behind the `apps` profile, so a plain
# `up` runs only the one-shot bootstrap jobs (stream-ticket keys, database
# migrations, Zitadel project/apps, the storage bucket) and stops there. That's
# 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, so the apps start with real values. See deploy/README.md.
# Backend settings that are identical for the API and the Worker. ASP.NET Core
# maps `__` to `:`, so these override the matching appsettings.json sections.
# POSTGRES_HOST / RABBITMQ_HOST / JAEGER_HOST differ between environments
# (host.docker.internal in dev, the container name over infra-net in prod,
# see .env.example / .env.prod.example), same as PUBLIC_AUTH_URL/S3_PUBLIC_URL
# already do.
x-backend-env: &backend-env
Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
# Musify's own vhost and user on the shared broker (the path is the vhost).
MassTransit__Address: "rabbitmq://${RABBITMQ_HOST}:5672/${RABBITMQ_VHOST}"
MassTransit__Username: ${RABBITMQ_USER}
MassTransit__Password: ${RABBITMQ_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}
OpenTelemetry__OtlpEndpoint: "http://${JAEGER_HOST}:4317"
services:
# --- Shared-infrastructure bootstrap ---------------------------------------
# Waits for Infrastructure's Postgres to accept connections before `migrate`
# runs. This replaces a `depends_on: postgres: condition: service_healthy`:
# that only works within the same compose file, and Postgres now lives in a
# separate one (../../../Infrastructure).
wait-for-postgres:
image: postgres:17-alpine
container_name: musify-wait-for-postgres
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
deadline=$$(( $$(date +%s) + 60 ))
until pg_isready -h ${POSTGRES_HOST} -p 5432 -U ${POSTGRES_USER} >/dev/null 2>&1; do
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "postgres did not respond at ${POSTGRES_HOST}:5432. Is Infrastructure running?" >&2; exit 1; }
sleep 2
done
restart: "no"
# One-shot: SeaweedFS does not auto-create buckets. `aws s3 ls` doubles as
# the readiness probe, since seaweedfs-init no longer has an in-file
# `depends_on: seaweedfs: condition: service_healthy` to wait for.
seaweedfs-init:
image: amazon/aws-cli:latest
container_name: musify-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:
- |
set -e
deadline=$$(( $$(date +%s) + 60 ))
until aws --endpoint-url ${S3_PUBLIC_URL} s3 ls >/dev/null 2>&1; do
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "seaweedfs did not respond at ${S3_PUBLIC_URL}. Is Infrastructure running?" >&2; exit 1; }
sleep 2
done
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: generates the RS256 key pair used to sign (API) and verify
# (Gateway) stream tickets, if it is not already in ./keys. No host tools
# required, since openssl runs inside the container.
keys-init:
image: alpine:3.20
container_name: musify-keys-init
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
if [ -f /keys/stream_private.pem ] && [ -f /keys/stream_public.pem ]; then
echo "stream-ticket keys already present"
else
apk add --no-cache openssl >/dev/null
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /keys/stream_private.pem
openssl rsa -in /keys/stream_private.pem -pubout -out /keys/stream_public.pem
chmod 644 /keys/stream_public.pem
chmod 600 /keys/stream_private.pem
echo "generated stream-ticket keys"
fi
volumes:
- ./keys:/keys
restart: "no"
# One-shot: applies the EF Core migrations, then exits. See
# backend/Musify.Migrator.Dockerfile.
migrate:
build:
context: ../backend
dockerfile: Musify.Migrator.Dockerfile
container_name: musify-migrate
depends_on:
wait-for-postgres:
condition: service_completed_successfully
environment:
Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
restart: "no"
# One-shot: creates the Musify project and its two OIDC apps in the shared
# Zitadel (or reuses them if they already exist), then writes
# AUTH_CLIENT_ID, WEB_CLIENT_ID and WEB_CLIENT_SECRET back into the env file
# mounted at /workspace/.env (see compose.dev.yml / compose.prod.yml), so a
# later `--profile apps up` picks up real values instead of empty ones.
zitadel-init:
image: alpine:3.20
container_name: musify-zitadel-init
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}
volumes:
- ${ZITADEL_PAT_DIR}:/output:ro
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
# The env file may have CRLF line endings (edited on Windows), so
# strip \r on read and drop it on write, otherwise a "blank" value
# is actually a lone \r, and `[ -z ... ]` lies.
env_get() { grep -E "^$$2=" "$$1" 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '\r'; }
env_set() {
# $$1 is a bind-mounted file: rewrite its content in place rather
# than renaming a temp file over it (rename onto a bind-mounted
# inode fails with "Resource busy").
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
}
# Musify's own service account, owner of the Musify project only
# (created by Infrastructure's `provision`, see its README).
echo "==> waiting for Musify's Zitadel PAT"
deadline=$$(( $$(date +%s) + 180 ))
while [ ! -s /output/zitadel.pat ]; do
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "no zitadel.pat in ZITADEL_PAT_DIR. Is Infrastructure up, and does ZITADEL_PAT_DIR point at its zitadel/.output/musify? Check 'docker logs infra-provision'." >&2; exit 1; }
sleep 3
done
PAT=$$(tr -d '\r\n' </output/zitadel.pat)
MGMT="$$PUBLIC_AUTH_URL/management/v1"
echo "==> waiting for Zitadel to answer at $$PUBLIC_AUTH_URL"
deadline=$$(( $$(date +%s) + 120 ))
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=Musify
# Created by Infrastructure's `provision`: this account can't create
# projects, only manage this one.
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')
[ -n "$$PROJECT_ID" ] || { echo "Zitadel project $$PROJECT_NAME not found. Is musify in Infrastructure's x-projects?" >&2; exit 1; }
echo " found (id $$PROJECT_ID)"
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"
API_APP_NAME="Musify 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"
WEB_APP_NAME="Musify Web"
WEB_APP_ID=$$(find_app "$$WEB_APP_NAME")
WEB_CLIENT_SECRET=$$(env_get "$$ENV_FILE" WEB_CLIENT_SECRET)
if [ -n "$$WEB_APP_ID" ]; then
WEB_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$WEB_APP_ID" | jq -r '.app.oidcConfig.clientId')
if [ -z "$$WEB_CLIENT_SECRET" ]; then
WEB_CLIENT_SECRET=$$(api_call POST "/projects/$$PROJECT_ID/apps/$$WEB_APP_ID/oidc_config/_generate_client_secret" '{}' | jq -r '.clientSecret')
echo " reused (clientId $$WEB_CLIENT_ID, secret regenerated)"
else
echo " reused (clientId $$WEB_CLIENT_ID)"
fi
else
CREATED=$$(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_WEB",
authMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC",
version: "OIDC_VERSION_1_0",
devMode: ($$ENV.ZITADEL_DEV_MODE == "true"),
accessTokenType: "OIDC_TOKEN_TYPE_JWT",
accessTokenRoleAssertion: true,
idTokenRoleAssertion: true,
idTokenUserinfoAssertion: true
}')")
WEB_CLIENT_ID=$$(echo "$$CREATED" | jq -r '.clientId')
WEB_CLIENT_SECRET=$$(echo "$$CREATED" | jq -r '.clientSecret')
echo " created (clientId $$WEB_CLIENT_ID)"
fi
echo "==> native app (KMP client: desktop loopback + mobile custom scheme)"
# Loopback (desktop) and a reverse-domain custom scheme (Android/iOS,
# RFC 8252 §7.1) are both valid for a NATIVE app without devMode, so
# dev and prod share the same config. Unlike the other apps, an
# existing one is also updated, so a change here reaches instances
# created before it.
NATIVE_APP_NAME="Musify Native"
NATIVE_CONFIG='{
"redirectUris": ["http://127.0.0.1:17453/callback", "cat.ikerdemo.musify:/callback"],
"postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "cat.ikerdemo.musify:/logout"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
"appType": "OIDC_APP_TYPE_NATIVE",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
"devMode": false,
"accessTokenType": "OIDC_TOKEN_TYPE_JWT",
"accessTokenRoleAssertion": true,
"idTokenRoleAssertion": true,
"idTokenUserinfoAssertion": true
}'
NATIVE_APP_ID=$$(find_app "$$NATIVE_APP_NAME")
if [ -n "$$NATIVE_APP_ID" ]; then
NATIVE_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID" | jq -r '.app.oidcConfig.clientId')
# Zitadel answers 400 "No changes" when the config already matches.
RESULT=$$(curl -sS -X PUT "$$MGMT/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID/oidc_config" \
-H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" -d "$$NATIVE_CONFIG")
case "$$RESULT" in
*'"details"'*'"changeDate"'*) echo " reused (clientId $$NATIVE_CLIENT_ID, config updated)" ;;
*COMMAND-1m88i*) echo " reused (clientId $$NATIVE_CLIENT_ID)" ;;
*) echo "could not update the native app config: $$RESULT" >&2; exit 1 ;;
esac
else
NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(echo "$$NATIVE_CONFIG" | jq --arg name "$$NATIVE_APP_NAME" '. + {name: $$name, version: "OIDC_VERSION_1_0"}')" | jq -r '.clientId')
echo " created (clientId $$NATIVE_CLIENT_ID)"
fi
env_set "$$ENV_FILE" AUTH_CLIENT_ID "$$API_CLIENT_ID"
env_set "$$ENV_FILE" WEB_CLIENT_ID "$$WEB_CLIENT_ID"
env_set "$$ENV_FILE" WEB_CLIENT_SECRET "$$WEB_CLIENT_SECRET"
env_set "$$ENV_FILE" NATIVE_CLIENT_ID "$$NATIVE_CLIENT_ID"
echo "==> wired into $$ENV_FILE"
restart: "no"
# --- Applications (profile: apps) -----------------------------------------
api:
profiles: [apps]
build:
context: ../backend
dockerfile: Musify.Api.Dockerfile
container_name: musify-api
depends_on:
migrate:
condition: service_completed_successfully
keys-init:
condition: service_completed_successfully
environment:
<<: *backend-env
ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
ASPNETCORE_URLS: http://+:5111
StreamGateway__PublicBaseUrl: ${PUBLIC_STREAM_URL}
StreamTicket__PrivateKeyPath: /keys/stream_private.pem
Workers__Routes__TemporaryFilesDirectory: /tmp/musify
# 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}
volumes:
- ./keys:/keys:ro
restart: unless-stopped
worker:
profiles: [apps]
build:
context: ../backend
dockerfile: Musify.Worker.Dockerfile
container_name: musify-worker
depends_on:
migrate:
condition: service_completed_successfully
environment:
<<: *backend-env
DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
AudioTranscoder__Routes__WorkingDirectory: /tmp/musify
Workers__Routes__TemporaryFilesDirectory: /tmp/musify
restart: unless-stopped
# YARP in front of the SeaweedFS filer: validates the RS256 stream ticket and
# streams the bytes, so audio never goes through the API.
gateway:
profiles: [apps]
build:
context: ../backend
dockerfile: Musify.StreamingGateway.Dockerfile
container_name: musify-gateway
depends_on:
keys-init:
condition: service_completed_successfully
environment:
ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
ASPNETCORE_URLS: http://+:8081
StreamTicket__PublicKeyPath: /keys/stream_public.pem
ReverseProxy__Clusters__seaweed-filer__Destinations__filer__Address: "http://${SEAWEEDFS_HOST}:8888/"
ReverseProxy__Routes__media__Transforms__0__PathPattern: "/buckets/${S3_BUCKET}/{**rest}"
Cors__AllowedOrigins__0: ${PUBLIC_WEB_URL}
volumes:
- ./keys:/keys:ro
restart: unless-stopped
web-player:
profiles: [apps]
build:
context: ../web-player
dockerfile: Dockerfile
container_name: musify-web-player
depends_on:
- api
environment:
NODE_ENV: production
PORT: 3000
# adapter-node rejects request bodies over 512K by default; audio
# uploads are posted to /upload. Keep in line with the edge's
# client_max_body_size.
BODY_SIZE_LIMIT: 1G
ORIGIN: ${PUBLIC_WEB_URL}
ZITADEL_ISSUER: ${PUBLIC_AUTH_URL}
ZITADEL_CLIENT_ID: ${WEB_CLIENT_ID}
ZITADEL_CLIENT_SECRET: ${WEB_CLIENT_SECRET}
AUTH_REDIRECT_URI: "${PUBLIC_WEB_URL}/auth/callback"
AUTH_POST_LOGOUT_URI: "${PUBLIC_WEB_URL}/"
AUTH_ALLOW_INSECURE_HTTP: ${AUTH_ALLOW_INSECURE_HTTP}
SESSION_SECRET: ${SESSION_SECRET}
# The container name, not the `api` service name: in prod every project
# shares infra-net, where a generic name can resolve to another
# project's container.
API_BASE_URL: http://musify-api:5111
# Presigned PUTs are signed for the public S3 host; the container sends
# them straight to SeaweedFS instead of going back out through the edge.
S3_UPLOAD_ENDPOINT: ${S3_INTERNAL_URL}
restart: unless-stopped