# 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' 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