Files
Musify/deploy
Admin cf8ac706b6 Replace deploy scripts with self-contained compose bootstrap jobs
Removes every wrapper script under deploy/ (up.ps1, up.sh, scripts/*,
zitadel/provision.*) and folds their logic into three one-shot services
in compose.yml, following the pattern already used by seaweedfs-init:

  keys-init     generates the RS256 stream-ticket key pair
  migrate       applies the EF Core migrations (backend/Musify.Migrator.Dockerfile)
  zitadel-init  creates the Musify project + OIDC apps, writes the client
                ids back into the active env file

Deploying is now two plain `docker compose` commands, no scripts to run
or chmod:

  docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
  docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build

(swap compose.dev.yml for compose.prod.yml + --env-file deploy/.env.prod
for production). The first command brings up infra and lets the bootstrap
jobs run to completion; the second builds and starts the apps, which by
then read real OIDC values instead of empty ones — compose interpolates
${VAR} once per invocation, so this can't collapse into a single command
without the apps reading their own config file at container start instead
of via compose env (out of scope here).

Also:
- DatabaseDesignTimeFactory now also reads Database__ConnectionString
  from the environment, and DesignSettings.json is optional, so the new
  migrate container works without user-secrets or a config file excluded
  from the Docker build context by .dockerignore.
- nginx's depends_on no longer lists the apps-profile services: it does
  not need them at its own startup (every vhost resolves its upstream at
  request time via the embedded Docker resolver), and requiring them made
  `compose.prod.yml up -d` (without --profile apps) an invalid project.
- env_get/env_set in zitadel-init strip \r: the mounted env file can have
  CRLF line endings on Windows, which otherwise makes an empty-looking
  value non-empty to `[ -z ... ]`. .env.example/.env.prod.example are
  pinned to LF via .gitattributes for the same reason. Also fixed:
  rewriting the bind-mounted env file in place instead of renaming a temp
  file over it (rename onto a bind-mounted inode fails with "Resource
  busy").
- Removed compose.share.yml and zitadel/.output-share/ — a "temporary"
  personal overlay that had leaked a real Zitadel service-account PAT
  into git history (committed in 70968b3). That token should be rotated.
- Removed deploy/.tmp/ from .gitignore: nothing mounts or creates it
  anymore (it never actually did, either — no compose service referenced
  it, only the now-removed up.ps1 did).

Verified end-to-end with Docker: a clean `down -v` + fresh `up -d` +
`--profile apps up -d --build` on the dev stack brings up all four one-shot
jobs successfully and all four apps healthy (api/gateway return 200,
worker starts its consumers with no errors, web-player responds). Also
verified compose.prod.yml starts nginx cleanly with dummy certs and no
apps running, confirming the depends_on fix.
2026-09-09 15:08:34 +02:00
..

Deploy

Everything needed to run Musify, in development and in production, from the same set of compose files — no wrapper scripts, just docker compose.

File Role
compose.yml Every service, including the one-shot bootstrap jobs. No published ports, no domains, no environment-specific values.
compose.dev.yml Development overlay: publishes the ports to the host, adds pgAdmin.
compose.prod.yml Production overlay: adds the nginx edge and the mounted secrets.
.env / .env.prod The values compose interpolates. Not versioned; templates in .env.example / .env.prod.example.

The four applications (api, worker, gateway, web-player) sit behind the apps compose profile, so a plain up starts infrastructure only — the usual development setup, with the apps running from the IDE.

The one-shot bootstrap jobs

Three services in compose.yml run once and exit, so a plain docker compose up -d leaves the stack fully ready with no manual steps:

Service Does
keys-init Generates the RS256 stream-ticket key pair in ./keys, if it is not there already.
migrate Applies the EF Core migrations (built from backend/Musify.Migrator.Dockerfile).
zitadel-init Creates the Musify project and its two OIDC apps in Zitadel (or reuses them), then writes AUTH_CLIENT_ID / WEB_CLIENT_ID / WEB_CLIENT_SECRET into the env file.

api and worker wait for migrate; api and gateway wait for keys-init (depends_on: condition: service_completed_successfully). They do not wait for zitadel-init — compose interpolates ${AUTH_CLIENT_ID} once, when a command starts, so a container that starts in the same up as zitadel-init would still get the empty value it had before provisioning ran. That's why bringing up the apps is a second command: by the time you run it, zitadel-init has already finished and the env file has real values.


Development

docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build

The first command starts the infrastructure and runs the bootstrap jobs (keys, migrations, Zitadel project/apps). The second builds and runs api, worker, gateway and web-player in Docker too — skip it to run the apps from the IDE instead (the usual dev loop):

dotnet run --project backend/Hosts/Musify.Api               # :5111
dotnet run --project backend/Hosts/Musify.StreamingGateway  # :8081
dotnet run --project backend/Hosts/Musify.Worker             # background
npm --prefix web-player run dev                              # :5173

When running the apps from the IDE, Musify.Api reads Authentication:* from user-secrets and web-player reads its own web-player/.env — neither looks at deploy/.env. Copy the three values zitadel-init wrote (AUTH_CLIENT_ID, WEB_CLIENT_ID, WEB_CLIENT_SECRET) into dotnet user-secrets set Authentication:ClientId <value> --project backend/Hosts/Musify.Api and into web-player/.env once; they stay valid until you wipe the Zitadel volume.

Add --profile tools to also start pgAdmin.

Ports

Service Port Used by
PostgreSQL 59000 → 5432 API + Worker (EF Core) and Zitadel
Zitadel 8080 OIDC/OAuth2, console at /ui/console
RabbitMQ 5672 / 15672 MassTransit / management UI
SeaweedFS 8333 / 8888 / 9333 S3 / filer (→ gateway) / master
Jaeger 16686 / 4317 / 4318 traces UI / OTLP gRPC / OTLP HTTP
pgAdmin (--profile tools) 5050 Postgres UI
API, gateway, web player (--profile apps) 5111 / 8081 / 3000

Host requirements: .NET 10 SDK, Node 22, Docker Desktop. (ffmpeg and yt-dlp are only needed on the host if you run the Worker outside Docker — inside Docker its image already has them.)

Why host.docker.internal

PUBLIC_AUTH_URL and S3_PUBLIC_URL point at host.docker.internal, which resolves to the same address from the browser, from apps running on the host and from inside the containers. That keeps the OIDC token issuer and the presigned S3 URLs valid whichever way the apps are running. Zitadel routes by the Host header, so the domain has to be identical everywhere; changing it requires wiping the Postgres/Zitadel volumes and starting over (docker compose -f compose.yml -f compose.dev.yml down -v, then up -d again).

Resetting

docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down          # stop, keep data
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down -v       # stop, wipe volumes

Production

On a server with a domain, from the repository root:

cp deploy/.env.prod.example deploy/.env.prod

Fill it in:

  1. Replace every example.com with your domain and every CHANGE_ME with a random value (openssl rand -hex 24; the Zitadel masterkey needs exactly 32 characters). chmod 600 deploy/.env.prod.
  2. Drop deploy/nginx/certs/origin.pem and origin.key — the origin certificate nginx serves. The stack assumes Cloudflare in Full (strict) mode in front of it, so a Cloudflare Origin Certificate is enough; any certificate valid for the five hostnames works.
  3. Point example.com, www, api, auth, stream and s3 at the server, proxied/TLS-terminated the same way.

Then, same two commands as development, with the prod env file and overlay:

docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build

The first command starts the infrastructure, the nginx edge and the bootstrap jobs; the second builds and starts the applications, already wired to the OIDC client ids the first one produced. Both are idempotent — run them again to deploy a new version (docker compose ... --profile apps up -d --build alone is enough for an update once the stack already exists; it reuses the infra containers and only rebuilds and restarts the apps).

Only nginx publishes ports (80/443); everything else is reachable only on the internal compose network.

Optional secrets

deploy/secrets/ is mounted read-only at /secrets in the Worker. Drop a youtube_cookies.txt there if you want yt-dlp to use a cookie jar — it is referenced from YTDLP_ADDITIONAL_ARGUMENTS in .env.prod, which you can empty if you do not have one.


Layout

deploy/
├─ compose.yml                     # all services, incl. keys-init / migrate / zitadel-init
├─ compose.dev.yml                 # dev overlay: host ports, pgAdmin
├─ compose.prod.yml                # prod overlay: nginx edge, secrets
├─ .env.example / .env.prod.example
├─ keys/                           # RS256 stream-ticket key pair (generated)
├─ secrets/                        # mounted into the Worker (prod, optional)
├─ nginx/
│  ├─ templates/default.conf.template # vhosts, rendered with the domain by envsubst
│  ├─ snippets/proxy.conf             # proxy headers shared by every vhost
│  └─ certs/                          # origin.pem + origin.key (not versioned)
└─ zitadel/
   └─ .output/                    # PAT written by Zitadel on init (not versioned)

The application images are built from backend/Musify.*.Dockerfile (build context backend/, so restore can copy the central props and every csproj) and web-player/Dockerfile.

Notes

  • S3 credentials are rendered into the SeaweedFS identity file from S3_ACCESS_KEY / S3_SECRET_KEY when the container starts, so they cannot drift from the ones the apps are given.
  • Zitadel runs start-from-init, which is idempotent: the same command works on an empty volume and on an existing one.
  • latest Zitadel images require the standalone Login UI v2 container, which this stack does not run. zitadel-init turns that requirement off so the built-in /ui/login is used.
  • AppSettings*.json are PascalCase in Musify.Api and Musify.Worker; on case-sensitive Linux the host looks for appsettings*.json, so their Dockerfiles create lowercase copies at publish time.
  • Re-running zitadel-init or migrate is safe: the former reuses the existing project/apps by name (regenerating the web app's secret only if it is missing from the env file), the latter is a normal dotnet ef database update, a no-op once the schema is current.