docs: rewrite the deploy documentation and fix the stale references

The root README was a design handoff for a prototype that is not in this repo,
web-player/README.md was the sv template, and deploy/README.md only covered
development. Replace all three and document the production deploy: its
prerequisites, what up.sh does and why nginx sits in front.

The docs under docs/ still used the pre-monorepo project paths, Ardalis.Result
instead of ErrorOr and the player.html test console that no longer exists.
This commit is contained in:
2026-07-20 15:14:11 +02:00
parent fa12fb795d
commit 6778a69098
10 changed files with 269 additions and 331 deletions
+120 -154
View File
@@ -1,192 +1,158 @@
# Development deploy
# Deploy
Everything the backend needs for development, in one place. A single
`docker-compose.yml` brings up **PostgreSQL, Zitadel, RabbitMQ, SeaweedFS and
Jaeger**; the .NET apps run from the IDE (or, optionally, in Docker too).
Everything needed to run Musify, in development and in production, from the
same set of compose files.
## Quick start
| 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
```powershell
# From the repo root (Musify/)
./deploy/up.ps1
./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 full bootstrap:
`up.ps1` does the whole bootstrap, with no manual steps afterwards:
1. Creates `deploy/.env` from `.env.example` if missing.
2. Starts the stack (`docker compose up -d`) and waits for Postgres to be healthy.
3. Creates the `webapi-storage` bucket in SeaweedFS (`seaweedfs-init` service).
4. Generates the RS256 stream-ticket keys in `D:\weed` if missing.
5. Creates the Worker temp directory (`D:\tempsFilesDev`).
6. Applies the EF Core migrations to `musify_db`.
7. **Provisions Zitadel automatically** (project + OIDC app with JWT token) and
wires the ClientId into `Musify.Api` user-secrets and `.env`.
1. Creates `deploy/.env` from `.env.example` if it is missing.
2. Generates the RS256 stream-ticket keys in `deploy/keys/` and the Worker
scratch directory `deploy/.tmp/`.
3. Starts the stack and waits for Postgres.
4. Applies the EF Core migrations.
5. Provisions Zitadel: project, API OIDC app and web player OIDC app, then
writes the client ids into `deploy/.env`, `Musify.Api` user-secrets and
`web-player/.env`.
6. Prints a summary with every URL and credential.
When it finishes it prints a summary with URLs and credentials. **No manual steps.**
Other switches: `-Tools` (pgAdmin), `-SkipMigrations`, `-Down` (stop, keep
data), `-Destroy` (stop and wipe the volumes).
### Without the script (infra only)
### Ports
```powershell
docker compose -f deploy/docker-compose.yml up -d
```
The compose file still creates the bucket on its own; run migrations with
`dotnet ef database update` (see [../docs/development.md](../docs/development.md)).
## Services and ports
| Service | Port(s) | Used for |
| Service | Port | Used by |
|---|---|---|
| PostgreSQL | `59000` → 5432 | API + Worker (EF Core) and the Zitadel database |
| Zitadel | `8080` | OIDC/OAuth2 (console at `/ui/console`) |
| PostgreSQL | `59000` → 5432 | API + Worker (EF Core) and Zitadel |
| Zitadel | `8080` | OIDC/OAuth2, console at `/ui/console` |
| RabbitMQ | `5672` / `15672` | MassTransit / management UI |
| SeaweedFS S3 | `8333` | S3 (presigned PUT/GET) |
| SeaweedFS filer | `8888` | serves files → StreamingGateway |
| SeaweedFS master | `9333` | admin/UI |
| Jaeger | `16686` / `4317` / `4318` | OTLP traces (UI / gRPC / HTTP) |
| 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` | |
Ports are published to the host, so apps run locally connect without changing
`AppSettings.Development.json` (Postgres, RabbitMQ, S3, OTLP).
## Authentication (Zitadel) — automatic
There are **no manual steps**. `up.ps1` runs `zitadel/provision.ps1`, which
idempotently:
1. Uses a **service account** with a **PAT** that Zitadel writes on init
(`zitadel/.output/admin-sa.pat`).
2. Creates (or reuses) the **project** `Musify` and an **OIDC app** (PKCE,
`devMode`, **access token = JWT** — required for `JwtBearer` to validate).
3. Captures the generated **ClientId** and writes it to:
- **`Musify.Api` user-secrets** (for running the apps locally), and
- `deploy/.env` as `AUTH_CLIENT_ID` (for the full-docker path).
The values stay stable while the Postgres volume persists. After `up.ps1 -Destroy`
the next `up.ps1` re-provisions (new ClientId, re-wired).
- Console: `http://host.docker.internal:8080/ui/console`
- Admin login: `admin@zitadel.host.docker.internal` / `Password1!` (see `.env`).
- Test login: open Scalar (`http://localhost:5111/scalar/v1`) and authorize.
### Why `host.docker.internal`
`ZITADEL_EXTERNAL_DOMAIN=host.docker.internal` forms the token *issuer* and
resolves the **same** from the browser, local apps and containers, so token
validation works both ways (local and full-docker). Zitadel routes by the `Host`
header, so the domain must be identical everywhere. If you only run the apps
locally you can use `localhost`. Changing the domain requires a re-init:
`up.ps1 -Destroy; up.ps1`.
## Running the apps
### Recommended: from the IDE (best for debugging)
### Running the apps from the IDE
```powershell
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**, **ffmpeg** on PATH (Worker audio transcode).
Host requirements: .NET 10 SDK, Node 22, ffmpeg and yt-dlp on PATH (the Worker
transcodes and downloads audio), Docker Desktop.
### Optional: everything in Docker
### Why `host.docker.internal`
```powershell
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.apps.yml up -d --build
`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:
```sh
./deploy/up.sh
```
Builds and runs `api`, `worker` and `gateway` as containers (the Worker already
ships ffmpeg). The stream-ticket keys are mounted from `D:\weed` (configurable via
`STREAM_KEYS_DIR` in `.env`). Auth works in this mode too (the API downloads the
Zitadel JWKS via `host.docker.internal`).
It refuses to start until the two things it cannot generate are in place:
> Note: on Linux the apps look for `appsettings*.json` in lowercase. The `Api`/
> `Worker` projects use `AppSettings*.json` (PascalCase), so their Dockerfiles
> create lowercase copies at publish time. On Windows it did not matter
> (case-insensitive).
1. **`deploy/.env.prod`** — copied from `.env.prod.example` on the first run.
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` it.
2. **`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.
## Useful commands
DNS: point `example.com`, `www`, `api`, `auth`, `stream` and `s3` at the server.
```powershell
./deploy/up.ps1 -SkipMigrations # bring up without touching the DB
./deploy/up.ps1 -Down # stop and remove containers (keep data)
./deploy/up.ps1 -Destroy # stop and remove containers + volumes (wipe data)
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.
docker compose -f deploy/docker-compose.yml logs -f zitadel
docker compose -f deploy/docker-compose.yml ps
```
Only nginx publishes ports (80/443); everything else is reachable only on the
internal compose network.
## Project layout — what each file does
### 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/
├─ docker-compose.yml # infrastructure stack (single source of truth)
├─ docker-compose.apps.yml # optional overlay: the 3 .NET apps in Docker
├─ .env # local config (secrets/ports); not versioned
├─ .env.example # config template (versioned)
├─ up.ps1 # orchestrator: up / down / destroy
├─ README.md # this file
├─ 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 # shared helpers (Read-DotEnv, Write-Step)
│ ├─ stream-keys.ps1 # generates the RS256 stream-ticket key pair
│ └─ migrate.ps1 # applies EF Core migrations
├─ seaweedfs/
│ └─ s3.conf # SeaweedFS S3 identities/credentials
│ ├─ common.ps1 / lib.sh # shared helpers
│ ├─ stream-keys.ps1 / .sh # RS256 key pair generation
│ └─ migrate.ps1 / .sh # EF Core migrations
└─ zitadel/
├─ provision.ps1 # creates project + OIDC app, wires the ClientId
└─ .output/ # PAT + init secrets written by Zitadel (not versioned)
backend/
├─ Musify.Api.Dockerfile # API image
├─ Musify.Worker.Dockerfile # Worker image (includes ffmpeg)
├─ Musify.StreamingGateway.Dockerfile # Gateway image
└─ .dockerignore
├─ provision.ps1 / .sh # project + OIDC apps, wires the client ids
└─ .output/ # PAT written by Zitadel on init (not versioned)
```
### Compose
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`.
- **`docker-compose.yml`** — the infra stack (`postgres`, `zitadel`, `rabbitmq`,
`seaweedfs`, the one-shot `seaweedfs-init` bucket creator, and `jaeger`). This is
the single source of truth for services, ports and volumes. Values come from `.env`.
- **`docker-compose.apps.yml`** — an optional overlay that adds the `api`, `worker`
and `gateway` services. It merges with the infra file (same `musify-dev` project
and network) and shares the backend env via YAML anchors. Used to run the whole
stack in Docker; for active development prefer running the apps from the IDE.
## Notes
### Config
- **`.env`** — the actual values used by compose (Postgres/RabbitMQ/Zitadel/S3
credentials, ports, host paths, `AUTH_CLIENT_ID`). Ignored by git (`*.env`).
- **`.env.example`** — the versioned template. Copy it to `.env` (or let `up.ps1`
do it) and adjust.
### Scripts (PowerShell)
- **`up.ps1`** — the orchestrator. Parses `.env`, starts the stack, waits for
Postgres, then calls the sub-scripts in order (keys → migrations → provisioning)
and prints the summary. Also handles `-Down` / `-Destroy` / `-SkipMigrations`.
- **`scripts/common.ps1`** — shared helpers dot-sourced by the others: `Read-DotEnv`
(parse `.env` into a hashtable) and `Write-Step` (step logging).
- **`scripts/stream-keys.ps1`** — generates the RS256 key pair that the API uses to
sign stream tickets and the Gateway uses to verify them. No-op if the keys exist.
- **`scripts/migrate.ps1`** — sets the connection string in user-secrets and runs
`dotnet ef database update` (startup project = `Musify.Infrastructure`).
- **`zitadel/provision.ps1`** — reads the service-account PAT, creates/reuses the
Zitadel project and OIDC app (JWT token), and wires the ClientId into
`Musify.Api` user-secrets and `.env`. Idempotent; can be run standalone.
### Service configs
- **`seaweedfs/s3.conf`** — the S3 identities (access/secret keys) SeaweedFS loads.
Must match `InfrastructureStorage:*` in the app settings and `S3_*` in `.env`.
- **`zitadel/.output/`** — created at runtime; holds the PAT Zitadel writes on init
(`admin-sa.pat`). Not versioned; wiped by `up.ps1 -Destroy`.
### Dockerfiles (in `backend/`)
- **`Musify.Api.Dockerfile`** / **`Musify.Worker.Dockerfile`** /
**`Musify.StreamingGateway.Dockerfile`** — multi-stage builds for each host. The
Worker image also installs ffmpeg. Built by the apps overlay; build context is
`backend/` so restore can copy the central props and all csproj files.
- **`.dockerignore`** — keeps `bin/`, `obj/`, IDE folders and `DesignSettings.json`
out of the build context.
- **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. The provisioning scripts turn 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.