Deploy
Everything needed to run Musify, in development and in production, from the same set of compose files.
| File | Role |
|---|---|
compose.yml |
Every service. 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. |
up.ps1 |
Development orchestrator (Windows). |
up.sh |
Production orchestrator (the server). |
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.
Development
./deploy/up.ps1 # infrastructure only, apps from the IDE
./deploy/up.ps1 -Apps # build and run the apps in Docker too
up.ps1 does the whole bootstrap, with no manual steps afterwards:
- Creates
deploy/.envfrom.env.exampleif it is missing. - Generates the RS256 stream-ticket keys in
deploy/keys/and the Worker scratch directorydeploy/.tmp/. - Starts the stack and waits for Postgres.
- Applies the EF Core migrations.
- Provisions Zitadel: project, API OIDC app and web player OIDC app, then
writes the client ids into
deploy/.env,Musify.Apiuser-secrets andweb-player/.env. - Prints a summary with every URL and credential.
Other switches: -Tools (pgAdmin), -SkipMigrations, -Down (stop, keep
data), -Destroy (stop and wipe the volumes).
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 (-Tools) |
5050 |
Postgres UI |
API, gateway, web player (-Apps) |
5111 / 8081 / 3000 |
Running the apps from the IDE
dotnet run --project backend/Musify.Api # :5111
dotnet run --project backend/Musify.StreamingGateway # :8081
dotnet run --project backend/Musify.Worker # background
npm --prefix web-player run dev # :5173
Host requirements: .NET 10 SDK, Node 22, ffmpeg and yt-dlp on PATH (the Worker transcodes and downloads audio), Docker Desktop.
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 a re-init (./up.ps1 -Destroy; ./up.ps1).
Production
One command on the server, from the repository root:
./deploy/up.sh
It refuses to start until the two things it cannot generate are in place:
deploy/.env.prod— copied from.env.prod.exampleon the first run. Replace everyexample.comwith your domain and everyCHANGE_MEwith a random value (openssl rand -hex 24; the Zitadel masterkey needs exactly 32 characters).chmod 600it.deploy/nginx/certs/origin.pemandorigin.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.
DNS: point example.com, www, api, auth, stream and s3 at the server.
Then up.sh generates the stream keys, starts the infrastructure and the edge,
provisions Zitadel (it needs curl and jq on the server), applies the
migrations and finally builds and starts the applications. It is idempotent —
run it again to deploy a new version, or ./deploy/up.sh --no-build to restart
without rebuilding.
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, environment-independent
├─ compose.dev.yml # dev overlay: host ports, pgAdmin
├─ compose.prod.yml # prod overlay: nginx edge, secrets
├─ .env.example / .env.prod.example
├─ up.ps1 # dev orchestrator
├─ up.sh # prod orchestrator
├─ 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)
├─ scripts/
│ ├─ common.ps1 / lib.sh # shared helpers
│ ├─ stream-keys.ps1 / .sh # RS256 key pair generation
│ └─ migrate.ps1 / .sh # EF Core migrations
└─ zitadel/
├─ provision.ps1 / .sh # project + OIDC apps, wires the client ids
└─ .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_KEYwhen 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. latestZitadel images require the standalone Login UI v2 container, which this stack does not run. The provisioning scripts turn that requirement off so the built-in/ui/loginis used.AppSettings*.jsonare PascalCase inMusify.ApiandMusify.Worker; on case-sensitive Linux the host looks forappsettings*.json, so their Dockerfiles create lowercase copies at publish time.