diff --git a/.gitignore b/.gitignore
index fefe9b59e..e2e5954ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,9 @@ save/logs/*
save/conf/*
!save/conf/README.md
+save/db/*
+!save/db/README.md
+
save/sync-cdn/*
!save/conf/README.md
@@ -75,4 +78,15 @@ save/waveform-cache-smoke/
# Generated API docs (built from docs/openapi.yaml via `npm run docs:api`;
# also built+deployed by .github/workflows/publish-api-docs.yml)
-docs/api.html
\ No newline at end of file
+docs/api.html
+
+# Runtime artifacts from docs/docker-compose/ recipes (bind-mount targets,
+# user secrets, deployment-local state). The compose files + Dockerfiles +
+# READMEs are committed; everything users generate when running the
+# recipes is not.
+docs/docker-compose/*/config/
+docs/docker-compose/*/mstream-config/
+docs/docker-compose/*/music/
+docs/docker-compose/*/downloads/
+docs/docker-compose/*/transmission-config/
+docs/docker-compose/*/.env
\ No newline at end of file
diff --git a/README.md b/README.md
index ffff4d500..1514fe791 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,17 @@ The demo site does not require you to sign in. mStream is publicly accessible by
* [Install From Source](docs/install.md)
* [AWS Cloud using Terraform](https://gitlab.com/SiliconTao-Systems/nova)
+### Docker Compose recipes
+
+Drop-in `compose.yml` recipes for common deployments — see the [cookbook README](docs/docker-compose/) for the full index and per-recipe notes.
+
+* [**default/**](docs/docker-compose/default/) — streaming-only, single linuxserver/mstream container
+* [**with-mpd/**](docs/docker-compose/with-mpd/) — adds server-side audio playback via a sidecar MPD container
+* [**with-transmission/**](docs/docker-compose/with-transmission/) — mStream + Transmission, with completed downloads served back into the library automatically (Transmission recommended over qBittorrent / Deluge)
+* [**ssl-nginx/**](docs/docker-compose/ssl-nginx/) — mStream behind nginx with a Let's Encrypt wildcard cert (acme.sh + Cloudflare DNS-01)
+* [**dev/**](docs/docker-compose/dev/) — runs mStream from this repo's source with `node --watch` for hot reload
+* [**dev-everything/**](docs/docker-compose/dev-everything/) — `dev/` with DLNA, Subsonic, rust-server-audio, and ALSA passthrough preconfigured
+
## Mobile Apps
[
](https://apps.apple.com/us/app/mstream-player/id1605378892) [
](https://play.google.com/store/apps/details?id=com.nieratechinc.mstreamplayer&hl=en_US)
diff --git a/docs/docker-compose/README.md b/docs/docker-compose/README.md
new file mode 100644
index 000000000..9a1d7d50c
--- /dev/null
+++ b/docs/docker-compose/README.md
@@ -0,0 +1,16 @@
+# Docker Compose Cookbook
+
+Drop-in `compose.yml` recipes for running mStream under Docker Compose. Most recipes are self-contained directories you can `cp -r` next to your music library and bring up with `docker compose up -d`. The `dev/` recipe is the exception — it stays in the repo.
+
+## Recipes
+
+- **[default/](default/)** — Streaming-only deployment. Single linuxserver/mstream container, no server-side audio.
+- **[with-mpd/](with-mpd/)** — Streaming plus server-side audio playback via a sidecar MPD container. Requires a Linux host with a real audio device; Docker Desktop on Windows/macOS cannot pass `/dev/snd` through. See [with-mpd/README.md](with-mpd/README.md).
+- **[with-transmission/](with-transmission/)** — mStream + a Transmission daemon, with completed downloads served back into the library automatically. mStream supports three torrent clients (Transmission, qBittorrent, Deluge) — see [with-transmission/README.md](with-transmission/README.md) for why we recommend Transmission.
+- **[ssl-nginx/](ssl-nginx/)** — mStream behind an nginx reverse proxy with a Let's Encrypt wildcard cert, issued and auto-renewed via `acme.sh` + Cloudflare DNS-01. Requires a Cloudflare-managed domain.
+- **[dev/](dev/)** — Runs mStream from this repo's source with `node --watch` for hot reload. Used in-place; see [dev/README.md](dev/README.md).
+- **[dev-everything/](dev-everything/)** — Like `dev/` but with DLNA, Subsonic, the rust-server-audio binary, and ALSA passthrough all preconfigured. Linux host only; see [dev-everything/README.md](dev-everything/README.md).
+
+For the deployable recipes, edit `PUID`/`PGID`, `TZ`, and the music/config volume paths in each `compose.yml` to match your host before bringing it up. `PUID`/`PGID` should match the owner of the mounted directories (`id -u` / `id -g` on the host).
+
+The recipes track `:latest` for the linuxserver images (their conventional tag — they publish updates frequently). For a reproducible production deploy, pin to a specific image digest or version tag and bump it deliberately.
diff --git a/docs/docker-compose/default/compose.yml b/docs/docker-compose/default/compose.yml
new file mode 100644
index 000000000..6cecf7d76
--- /dev/null
+++ b/docs/docker-compose/default/compose.yml
@@ -0,0 +1,14 @@
+services:
+ mstream:
+ image: lscr.io/linuxserver/mstream:latest
+ container_name: mstream
+ environment:
+ - PUID=1000
+ - PGID=1000
+ - TZ=Etc/UTC
+ volumes:
+ - ./config:/config
+ - ./music:/music:ro
+ ports:
+ - "3000:3000"
+ restart: unless-stopped
diff --git a/docs/docker-compose/dev-everything/Dockerfile b/docs/docker-compose/dev-everything/Dockerfile
new file mode 100644
index 000000000..520bb7426
--- /dev/null
+++ b/docs/docker-compose/dev-everything/Dockerfile
@@ -0,0 +1,11 @@
+FROM node:22-bookworm-slim
+
+# libasound2 — runtime for the rust-server-audio binary (links cpal → ALSA)
+# alsa-utils — `aplay -l` etc. for debugging device passthrough from inside the container
+# mpv — CLI-fallback player exercised by src/api/cli-audio/mpv.js
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ libasound2 \
+ alsa-utils \
+ mpv \
+ && rm -rf /var/lib/apt/lists/*
diff --git a/docs/docker-compose/dev-everything/README.md b/docs/docker-compose/dev-everything/README.md
new file mode 100644
index 000000000..537feda51
--- /dev/null
+++ b/docs/docker-compose/dev-everything/README.md
@@ -0,0 +1,39 @@
+# Dev-everything recipe
+
+Like [`dev/`](../dev/) but with every server-side audio feature pre-enabled: DLNA, Subsonic, the bundled rust-server-audio binary, and ALSA passthrough. For exercising the full feature surface against live source.
+
+## What's enabled
+
+- **DLNA** — `same-port` mode on port 3000 with directory-style browse layout. SSDP discovery requires multicast, hence `network_mode: host` below.
+- **Subsonic API** — `same-port` mode. Reachable at `http://:3000/rest/...` or via any Subsonic client.
+- **Rust server-audio binary** — `autoBootServerAudio: true`. mStream spawns `bin/rust-server-audio/rust-server-audio-linux-x64` at startup; the binary's HTTP control plane listens on 3333 internally.
+- **ALSA passthrough** — `/dev/snd` mapped, `audio` group added, `libasound2` baked into the image. The rust binary plays through the host's default ALSA device.
+- **mpv installed** — `src/api/cli-audio/mpv.js` can take over if the rust binary fails to spawn.
+
+## Constraints
+
+- **Linux host only.** `devices: /dev/snd` doesn't exist on Docker Desktop for Windows/macOS, and `network_mode: host` behaves very differently outside Linux.
+- **Public-mode auth.** `users: {}` → no login required. Don't expose this recipe past localhost or a trusted LAN.
+
+## Usage
+
+From the repo root:
+
+ mkdir -p docs/docker-compose/dev-everything/music
+ docker compose -f docs/docker-compose/dev-everything/compose.yml up
+
+Drop test tracks into `docs/docker-compose/dev-everything/music/` and trigger a scan from the mStream UI.
+
+## Config persistence
+
+`mstream.json` is bind-mounted RW. On first boot mStream adds two derived fields in place:
+
+- `secret` — base64-encoded JWT signing key.
+- `dlna.uuid` — stable renderer identifier across restarts.
+
+To reset state, `docker compose down`, then either `git checkout mstream.json` or delete and restore from this repo.
+
+## Gotchas inherited from `dev/`
+
+- Anonymous `node_modules` volume persists across up/down — after `package.json` changes, `docker compose down -v` to force a clean reinstall.
+- `node --watch` reloads on any source change under `/app`, which is the entire bind-mounted repo.
diff --git a/docs/docker-compose/dev-everything/compose.yml b/docs/docker-compose/dev-everything/compose.yml
new file mode 100644
index 000000000..a52b6df50
--- /dev/null
+++ b/docs/docker-compose/dev-everything/compose.yml
@@ -0,0 +1,34 @@
+services:
+ mstream:
+ build: .
+ container_name: mstream-dev-everything
+ working_dir: /app
+ # Host networking so DLNA's SSDP multicast actually reaches the LAN.
+ # With bridge networking, mStream's TCP endpoints (mStream API, DLNA
+ # SOAP, Subsonic REST) all still work — but DLNA renderers won't
+ # auto-discover the server. Linux-only feature.
+ network_mode: host
+ devices:
+ - /dev/snd:/dev/snd
+ group_add:
+ - audio
+ volumes:
+ # Repo source. Paths are relative to this file —
+ # ../../.. resolves to the repo root.
+ - ../../..:/app
+ # Anonymous volume on node_modules so the container's `npm install`
+ # wins over whatever the host has.
+ - /app/node_modules
+ # Bind-mounted config. RW because mStream adds `secret` and
+ # `dlna.uuid` to it on first boot.
+ - ./mstream.json:/etc/mstream/mstream.json
+ # Sandbox music dir alongside this compose file. Drop tracks in
+ # ./music on the host; rescan from the mStream UI.
+ - ./music:/music:ro
+ environment:
+ - NODE_ENV=development
+ # See dev/compose.yml for the rationale on npm ci + skip-if-installed
+ # — short version: `npm install` modifies the bind-mounted lockfile,
+ # `npm ci` doesn't.
+ command: sh -c "[ -f /app/node_modules/.package-lock.json ] || npm ci --no-audit --no-fund; exec node --watch cli-boot-wrapper.js -j /etc/mstream/mstream.json"
+ restart: unless-stopped
diff --git a/docs/docker-compose/dev-everything/mstream.json b/docs/docker-compose/dev-everything/mstream.json
new file mode 100644
index 000000000..9e5912b49
--- /dev/null
+++ b/docs/docker-compose/dev-everything/mstream.json
@@ -0,0 +1,21 @@
+{
+ "port": 3000,
+ "address": "::",
+ "folders": {
+ "music": {
+ "root": "/music",
+ "type": "music"
+ }
+ },
+ "users": {},
+ "dlna": {
+ "mode": "same-port",
+ "name": "mStream Music (dev)",
+ "browse": "dirs"
+ },
+ "subsonic": {
+ "mode": "same-port"
+ },
+ "autoBootServerAudio": true,
+ "rustPlayerPort": 3333
+}
diff --git a/docs/docker-compose/dev/README.md b/docs/docker-compose/dev/README.md
new file mode 100644
index 000000000..5fd505e65
--- /dev/null
+++ b/docs/docker-compose/dev/README.md
@@ -0,0 +1,25 @@
+# Dev recipe
+
+Runs mStream from this repo's source with Node's built-in `--watch` for hot reload. Unlike the other recipes, this one is **tied to its position inside the repo** — `compose.yml` uses `../../..` to reach the repo root, so don't copy this directory elsewhere.
+
+## Usage
+
+From anywhere in the repo:
+
+ docker compose -f docs/docker-compose/dev/compose.yml up
+
+Edit any file under `src/` and `node --watch` restarts the process within a second.
+
+## Config
+
+mStream reads `save/conf/default.json` at the repo root by default (see `cli-boot-wrapper.js`). That file is inside the bind-mounted source tree, so edits are picked up without rebuilding the container. To point at a different file:
+
+ docker compose -f docs/docker-compose/dev/compose.yml run --rm \
+ mstream node --watch cli-boot-wrapper.js -j /app/path/to/your.json
+
+## Gotchas
+
+- **No audio support baked in.** No `libasound2`, no `/dev/snd` passthrough. For dev work that needs server-side audio, either layer `apt-get install libasound2 mpv` into a child Dockerfile, or run the `with-mpd/` recipe in parallel and point this dev container at its MPD socket via `MSTREAM_MPD_HOST`.
+- **No `/music` mount.** Mount your library by adding a volume to `compose.yml` and pointing `save/conf/default.json` at the in-container path.
+- **Bumping deps requires resetting `node_modules`.** The anonymous volume persists across `docker compose up`/`down`; after a `package.json` change run `docker compose -f docs/docker-compose/dev/compose.yml down -v` so the next boot reinstalls cleanly.
+- **`node --watch` over a bind mount from Windows/macOS** can occasionally miss events (the host's filesystem-change events have to traverse the Docker Desktop VM). If a save doesn't trigger a restart, touching the file again usually does.
diff --git a/docs/docker-compose/dev/compose.yml b/docs/docker-compose/dev/compose.yml
new file mode 100644
index 000000000..d82086110
--- /dev/null
+++ b/docs/docker-compose/dev/compose.yml
@@ -0,0 +1,27 @@
+services:
+ mstream:
+ image: node:22-bookworm-slim
+ container_name: mstream-dev
+ working_dir: /app
+ volumes:
+ # Bind-mount the repo. Paths relative to this file —
+ # ../../.. resolves to the repo root.
+ - ../../..:/app
+ # Anonymous volume on node_modules so the container's `npm install`
+ # wins over whatever the host has (esp. wrong-platform native bins
+ # when developing from Windows or macOS).
+ - /app/node_modules
+ environment:
+ - NODE_ENV=development
+ # First boot: clean `npm ci` install into the anonymous node_modules
+ # volume, then run mStream with Node's built-in --watch. Subsequent
+ # boots skip the install entirely (the .package-lock.json marker in
+ # node_modules signals it's already populated). Using `npm ci` not
+ # `npm install` so the bind-mounted package-lock.json on the host
+ # never gets touched — `npm install` will silently rewrite peer/
+ # optional metadata when there's drift, polluting the host tree.
+ # `exec` so node replaces sh (cleaner signal handling on shutdown).
+ command: sh -c "[ -f /app/node_modules/.package-lock.json ] || npm ci --no-audit --no-fund; exec node --watch cli-boot-wrapper.js"
+ ports:
+ - "3000:3000"
+ restart: unless-stopped
diff --git a/docs/docker-compose/ssl-nginx/.env.example b/docs/docker-compose/ssl-nginx/.env.example
new file mode 100644
index 000000000..115b6a434
--- /dev/null
+++ b/docs/docker-compose/ssl-nginx/.env.example
@@ -0,0 +1,55 @@
+# Copy this file to `.env` and fill in your values before `docker compose up -d`.
+
+# Cloudflare API token with Zone:Read + Zone.DNS:Edit on the zone for $DOMAIN.
+# Create at https://dash.cloudflare.com/profile/api-tokens
+CF_Token=
+
+# Base domain you control via Cloudflare DNS.
+# The issued cert covers both ${DOMAIN} and *.${DOMAIN}.
+DOMAIN=example.com
+
+# Full hostname mStream will be served at. Must be under ${DOMAIN}.
+MSTREAM_HOSTNAME=music.example.com
+
+# Email for acme.sh registration with Let's Encrypt (expiry notifications, etc.).
+# Baked into the image at build time — change requires `docker compose build`.
+ACME_EMAIL=you@example.com
+
+# ── Renewal-notification hook (optional) ────────────────────────────────
+# Leave ACME_NOTIFY_HOOK blank to disable. Set it to a provider name and
+# fill in the matching credentials block below. acme.sh persists the
+# config into /root/.acme.sh/account.conf (in the acme-state volume) on
+# first boot, so renewals fired by cron use it automatically. To rotate:
+# edit this file, then `docker compose restart nginx`.
+#
+# Notification level: 0=errors only, 1=skipped, 2=everything (default 2
+# here so the success trail confirms the cron is alive — use 0 to quiet).
+ACME_NOTIFY_HOOK=
+ACME_NOTIFY_LEVEL=2
+
+# Provider credentials — uncomment the block matching your hook.
+
+# ntfy.sh — ACME_NOTIFY_HOOK=ntfy
+#NTFY_URL=https://ntfy.sh/your-private-topic
+#NTFY_USER=
+#NTFY_PASSWORD=
+
+# Telegram bot — ACME_NOTIFY_HOOK=telegram
+#TELEGRAM_BOT_APITOKEN=
+#TELEGRAM_BOT_CHATID=
+
+# Discord webhook — ACME_NOTIFY_HOOK=discord
+#DISCORD_WEBHOOK_URL=
+
+# Generic SMTP — ACME_NOTIFY_HOOK=smtp
+#SMTP_FROM=
+#SMTP_TO=
+#SMTP_HOST=
+#SMTP_PORT=587
+#SMTP_USERNAME=
+#SMTP_PASSWORD=
+#SMTP_SECURE=tls
+
+# For other providers (mailgun, sendgrid, slack, pushover, gotify, opsgenie,
+# etc.) see https://github.com/acmesh-official/acme.sh/wiki/notify and the
+# scripts under https://github.com/acmesh-official/acme.sh/tree/master/notify
diff --git a/docs/docker-compose/ssl-nginx/README.md b/docs/docker-compose/ssl-nginx/README.md
new file mode 100644
index 000000000..4f9517cc6
--- /dev/null
+++ b/docs/docker-compose/ssl-nginx/README.md
@@ -0,0 +1,44 @@
+# SSL nginx recipe
+
+mStream behind an nginx reverse proxy with a Let's Encrypt wildcard cert, issued and auto-renewed by [acme.sh](https://github.com/acmesh-official/acme.sh) using the Cloudflare DNS-01 challenge.
+
+## What you get
+
+- **mStream container** — `lscr.io/linuxserver/mstream`, no published port. Only reachable inside the compose network.
+- **nginx container** — built from `nginx/Dockerfile`. Terminates TLS on 443, redirects 80 → 443, and proxies `${MSTREAM_HOSTNAME}` to `mstream:3000` over the internal network.
+- **Wildcard cert** for `${DOMAIN}` and `*.${DOMAIN}`, issued via Cloudflare DNS challenge — no port-80 round-trip needed during issuance, so this works even if the host isn't directly reachable from the public internet at issuance time.
+- **Auto-renewal** via the cron job acme.sh installs in the container (fires daily; rotates within 30 days of expiry; reloads nginx in place).
+
+## Prerequisites
+
+- A domain managed by Cloudflare DNS.
+- A Cloudflare API token with **Zone:Read** + **Zone.DNS:Edit** on the zone, created at .
+- Ports **80** and **443** reachable on the host.
+
+## Setup
+
+ cp -r docs/docker-compose/ssl-nginx ~/mstream-ssl
+ cd ~/mstream-ssl
+ cp .env.example .env
+ # edit .env: CF_Token, DOMAIN, MSTREAM_HOSTNAME, ACME_EMAIL
+ mkdir music mstream-config
+ docker compose up -d --build
+
+First boot: nginx container runs `acme.sh --issue` and pulls a fresh cert. Watch with `docker compose logs -f nginx` — should land in 30–60 seconds (DNS propagation).
+
+## Failure modes
+
+- **Bad Cloudflare token / wrong zone.** Entrypoint sees the issuance failure and *parks the container* (`exec sleep infinity`) rather than restart-looping. Without this, `restart: always` would hammer the Let's Encrypt and Cloudflare APIs until you got rate-limited (LE caps validation failures at 5/account/hour; CF locks the account aggressively). Fix `.env`, then `docker compose restart nginx`.
+- **Cert renewal fails silently.** acme.sh's cron writes to its own log inside the container; `docker exec mstream-nginx cat /root/.acme.sh/acme.sh.log` to inspect. The previous cert keeps serving traffic until it actually expires.
+- **mStream not reachable.** Check `docker compose ps`; if mstream is up but nginx returns 502, the issue is usually the music dir bind-mount (mStream errors out at boot if `./music` doesn't exist as a real directory).
+
+## Customization knobs
+
+- **`ACME_EMAIL` change** requires `docker compose build` — it's baked into the image at acme.sh install time.
+- **Different cert paths.** acme.sh writes to `/etc/ssl/certs/fullchain.pem` and `/etc/ssl/private/privkey.pem` inside the container (the `cert-out` volume). Both nginx and acme.sh agree on these paths — change one, change both.
+- **Adding more proxied services.** Add another `server { listen 443 ssl; ... }` block in `nginx/default.conf.template`. The wildcard cert already covers any subdomain under `${DOMAIN}`, so no cert re-issuance is needed.
+- **HTTP-01 challenge instead of DNS-01.** Useful if you don't want a Cloudflare API token. Means exposing port 80 to the public internet for the challenge handshake; swap `--dns dns_cf` for `--standalone` (or `--webroot /usr/share/nginx/html` if you want acme.sh to share the running nginx). Out of scope for this recipe.
+
+## Notes for the curious
+
+The `ssl_certificate` paths point at `/etc/ssl/certs/fullchain.pem` and `/etc/ssl/private/privkey.pem` — both inside the `cert-out` named volume. The volume is the source of truth: blow away the volume (`docker compose down -v`) to force re-issuance on next boot. Useful for testing the issuance path; expensive against the Let's Encrypt staging cap if you do it repeatedly.
diff --git a/docs/docker-compose/ssl-nginx/compose.yml b/docs/docker-compose/ssl-nginx/compose.yml
new file mode 100644
index 000000000..01ca2bd47
--- /dev/null
+++ b/docs/docker-compose/ssl-nginx/compose.yml
@@ -0,0 +1,35 @@
+services:
+ nginx:
+ build:
+ context: ./nginx
+ args:
+ ACME_EMAIL: ${ACME_EMAIL}
+ container_name: mstream-nginx
+ restart: always
+ env_file: .env
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - acme-state:/root/.acme.sh # acme.sh account + renewal cron + cert source-of-truth
+ - cert-out:/etc/ssl # what nginx reads (fullchain.pem, privkey.pem)
+ depends_on:
+ - mstream
+
+ mstream:
+ image: lscr.io/linuxserver/mstream:latest
+ container_name: mstream
+ environment:
+ - PUID=1000
+ - PGID=1000
+ - TZ=Etc/UTC
+ volumes:
+ - ./mstream-config:/config
+ - ./music:/music:ro
+ # No host-port publish — only nginx is exposed. nginx reaches mstream
+ # via the default compose network using the service name `mstream`.
+ restart: unless-stopped
+
+volumes:
+ acme-state:
+ cert-out:
diff --git a/docs/docker-compose/ssl-nginx/nginx/Dockerfile b/docs/docker-compose/ssl-nginx/nginx/Dockerfile
new file mode 100644
index 000000000..f6fd1bb91
--- /dev/null
+++ b/docs/docker-compose/ssl-nginx/nginx/Dockerfile
@@ -0,0 +1,16 @@
+FROM nginx:stable
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl ca-certificates cron openssl \
+ && rm -rf /var/lib/apt/lists/*
+
+ARG ACME_EMAIL=admin@example.com
+RUN curl -fsSL https://get.acme.sh | sh -s email=${ACME_EMAIL} \
+ && /root/.acme.sh/acme.sh --set-default-ca --server letsencrypt
+
+COPY default.conf.template /etc/nginx/conf.d/default.conf.template
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+ENTRYPOINT ["/entrypoint.sh"]
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/docs/docker-compose/ssl-nginx/nginx/default.conf.template b/docs/docker-compose/ssl-nginx/nginx/default.conf.template
new file mode 100644
index 000000000..d38e9b3aa
--- /dev/null
+++ b/docs/docker-compose/ssl-nginx/nginx/default.conf.template
@@ -0,0 +1,37 @@
+# This file is a template. entrypoint.sh substitutes two tokens at boot
+# from .env: the base domain (DOMAIN env var) and the full hostname
+# mStream is served at (MSTREAM_HOSTNAME env var). Placeholder strings
+# are intentionally not written inline here — they'd be substituted in
+# this very comment and obscure the docs.
+
+server {
+ listen 80;
+ server_name __DOMAIN__ *.__DOMAIN__;
+ rewrite ^ https://$host$request_uri? permanent;
+}
+
+server {
+ listen 443 ssl;
+ server_name __MSTREAM_HOSTNAME__;
+ ssl_certificate /etc/ssl/certs/fullchain.pem;
+ ssl_certificate_key /etc/ssl/private/privkey.pem;
+
+ location / {
+ proxy_pass http://mstream:3000;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ }
+}
+
+# Catch-all for unknown hostnames under the wildcard — return 404 instead of
+# defaulting to the first server block (which would expose mStream on any
+# subdomain that resolves to this host).
+server {
+ listen 443 ssl default_server;
+ server_name __DOMAIN__ *.__DOMAIN__;
+ ssl_certificate /etc/ssl/certs/fullchain.pem;
+ ssl_certificate_key /etc/ssl/private/privkey.pem;
+ location / { return 404; }
+}
diff --git a/docs/docker-compose/ssl-nginx/nginx/entrypoint.sh b/docs/docker-compose/ssl-nginx/nginx/entrypoint.sh
new file mode 100644
index 000000000..aac8666e6
--- /dev/null
+++ b/docs/docker-compose/ssl-nginx/nginx/entrypoint.sh
@@ -0,0 +1,64 @@
+#!/bin/bash
+set -e
+ACME=/root/.acme.sh/acme.sh
+
+: "${DOMAIN:?DOMAIN env var required (e.g. example.com)}"
+: "${MSTREAM_HOSTNAME:?MSTREAM_HOSTNAME env var required (e.g. music.example.com)}"
+: "${CF_Token:?CF_Token env var required (Cloudflare API token, Zone:Read + Zone.DNS:Edit)}"
+export CF_Token
+
+# Render the nginx config from the template
+sed -e "s|__DOMAIN__|${DOMAIN}|g" \
+ -e "s|__MSTREAM_HOSTNAME__|${MSTREAM_HOSTNAME}|g" \
+ /etc/nginx/conf.d/default.conf.template \
+ > /etc/nginx/conf.d/default.conf
+
+# Issue cert on first boot. Subsequent boots find the cert in the cert-out
+# volume and skip — renewals are driven by the cron job acme.sh installs at
+# install time (fires daily, only rotates within 30 days of expiry).
+if [ ! -s /etc/ssl/certs/fullchain.pem ]; then
+ # If issuance fails (bad token, DNS misconfig) DO NOT exit — `restart:
+ # always` would loop us against Cloudflare + LE rate limits. Park instead
+ # so the container stays up but inert. To retry: fix .env, then
+ # `docker compose restart nginx`.
+ if ! "$ACME" --issue --dns dns_cf \
+ -d "*.${DOMAIN}" -d "${DOMAIN}" --keylength ec-256; then
+ echo "[entrypoint] acme.sh --issue failed."
+ echo "[entrypoint] Fix credentials in .env, then: docker compose restart nginx"
+ echo "[entrypoint] Parking to avoid hammering Cloudflare + LE rate limits."
+ exec sleep infinity
+ fi
+
+ install -d -m 0755 /etc/ssl/certs /etc/ssl/private
+ # reloadcmd is recorded by acme.sh and reused by the daily renewal cron.
+ # The PID guard makes first-boot install a no-op (nginx not running yet);
+ # the `exec nginx` below picks up the freshly-installed cert. On renewals
+ # nginx is already running, the guard passes, reload fires normally.
+ "$ACME" --install-cert -d "*.${DOMAIN}" --ecc \
+ --key-file /etc/ssl/private/privkey.pem \
+ --fullchain-file /etc/ssl/certs/fullchain.pem \
+ --reloadcmd "[ -f /run/nginx.pid ] && nginx -s reload || true"
+fi
+
+# Register a notify hook with acme.sh if configured in .env. Idempotent:
+# --set-notify just rewrites the relevant lines in account.conf each call,
+# so this handles both first boot and later credential rotations. `|| true`
+# guards against a misconfigured hook taking nginx down — a broken notify
+# channel shouldn't break TLS termination.
+if [ -n "${ACME_NOTIFY_HOOK:-}" ]; then
+ "$ACME" --set-notify \
+ --notify-hook "${ACME_NOTIFY_HOOK}" \
+ --notify-level "${ACME_NOTIFY_LEVEL:-2}" \
+ || true
+fi
+
+# Renewal check on every startup, not just the daily cron — covers the case
+# where the container restarts at a time that misses cron's window (host
+# reboots, image updates, etc.). Idempotent: --cron only acts on certs
+# within 30 days of expiry, no-ops otherwise. `|| true` keeps a transient
+# API failure from taking nginx down; the existing cert serves until
+# actual expiry.
+"$ACME" --cron --home /root/.acme.sh || true
+
+service cron start
+exec "$@"
diff --git a/docs/docker-compose/with-mpd/README.md b/docs/docker-compose/with-mpd/README.md
new file mode 100644
index 000000000..b4b35f349
--- /dev/null
+++ b/docs/docker-compose/with-mpd/README.md
@@ -0,0 +1,37 @@
+# mStream + MPD recipe
+
+mStream with server-side audio playback through a sidecar [MPD](https://www.musicpd.org/) daemon. mStream streams to browsers as usual *and* can play out of a sound device physically attached to the host — useful for a headless box wired to an amp.
+
+**Linux host only.** Audio passthrough mounts `/dev/snd`, which doesn't exist on Docker Desktop for Windows/macOS (containers run inside a VM with no host sound device).
+
+## How it works
+
+- MPD runs as a sidecar with `/dev/snd` passed through and the `audio` group added, playing to the host's ALSA device.
+- mStream and MPD share a named volume (`mpd-socket`) mounted at `/run/mpd` in both containers. MPD listens on a Unix socket there; mStream connects to it.
+- `MSTREAM_MPD_HOST=/run/mpd/socket` tells mStream where the socket is. A Unix socket (not TCP) is required because MPD 0.22+ only honours `file://` playback URIs from local socket clients.
+- **No config override is needed to make mStream prefer MPD.** With `autoBootServerAudio` at its default (`false`), mStream deliberately prefers MPD as its server-audio backend — it detects the daemon by probing `MSTREAM_MPD_HOST` and boots it automatically. (See `src/api/server-playback.js` and `src/api/cli-audio/`.)
+
+## Setup
+
+```bash
+cp -r docs/docker-compose/with-mpd ~/mstream-mpd
+cd ~/mstream-mpd
+mkdir config music
+# drop some audio into ./music
+docker compose up -d
+```
+
+Then trigger server playback from the mStream UI. Confirm the backend came up with:
+
+```bash
+docker compose logs mstream | grep -i "cli-audio\|server-audio\|mpd"
+```
+
+A line like `started MPD as fallback audio player` means it connected.
+
+## Gotchas
+
+- **Socket permissions across containers.** MPD creates the socket in the shared volume; mStream (running as `PUID=1000`) has to be able to connect to it. If logs show the MPD probe failing with a permission error, the socket's owner/mode doesn't line up with mStream's UID. The simplest fix is to run MPD with the same `PUID`/`PGID` as mStream so the socket is owned consistently, or set MPD's socket permissions permissively in `mpd.conf`. This is the most common failure mode for this recipe — check it first if server audio never appears.
+- **Startup race.** `depends_on` only waits for the MPD *container* to start, not for MPD to be *listening*. If mStream probes the socket before MPD is ready, server audio shows unavailable until the next probe — re-trigger detection from Admin → Server Audio (or just restart mStream) once MPD is up. It self-heals; it isn't a permanent failure.
+- **One process owns the sound device.** ALSA gives exclusive access to a PCM device. If something else on the host (or another container) holds `/dev/snd`, MPD's output will fail to open. Stop the other consumer or point MPD at a different ALSA device in `mpd.conf`.
+- **`auto_update no` is intentional.** mStream feeds MPD absolute `file://` URIs at play time, so MPD doesn't index the library itself — its own database stays empty by design. Your library browsing happens in mStream, not MPD.
diff --git a/docs/docker-compose/with-mpd/compose.yml b/docs/docker-compose/with-mpd/compose.yml
new file mode 100644
index 000000000..e49226067
--- /dev/null
+++ b/docs/docker-compose/with-mpd/compose.yml
@@ -0,0 +1,41 @@
+services:
+ mpd:
+ image: vimagick/mpd:latest
+ container_name: mstream-mpd
+ devices:
+ - /dev/snd:/dev/snd
+ group_add:
+ - audio
+ volumes:
+ - ./music:/music:ro
+ - ./mpd.conf:/etc/mpd.conf:ro
+ - mpd-socket:/run/mpd
+ restart: unless-stopped
+
+ mstream:
+ image: lscr.io/linuxserver/mstream:latest
+ container_name: mstream
+ environment:
+ - PUID=1000
+ - PGID=1000
+ - TZ=Etc/UTC
+ # mStream's MPD adapter connects here. Unix socket is required for
+ # MPD's file:// playback (MPD 0.22+ restricts file URIs to local
+ # clients). The shared `mpd-socket` volume puts the socket in both
+ # containers at the same path.
+ - MSTREAM_MPD_HOST=/run/mpd/socket
+ volumes:
+ - ./config:/config
+ # Must mount at the SAME path as in the mpd service — mStream sends
+ # absolute paths over the wire (e.g. file:///music/foo.mp3) without
+ # any rewriting, so both containers must agree on /music.
+ - ./music:/music:ro
+ - mpd-socket:/run/mpd
+ ports:
+ - "3000:3000"
+ depends_on:
+ - mpd
+ restart: unless-stopped
+
+volumes:
+ mpd-socket:
diff --git a/docs/docker-compose/with-mpd/mpd.conf b/docs/docker-compose/with-mpd/mpd.conf
new file mode 100644
index 000000000..1fd803e8f
--- /dev/null
+++ b/docs/docker-compose/with-mpd/mpd.conf
@@ -0,0 +1,23 @@
+# Minimal MPD config for the mStream server-audio sidecar.
+#
+# mStream feeds MPD absolute file:// URIs at play time, so MPD doesn't need
+# to scan or index the library — `auto_update no` is intentional.
+
+music_directory "/music"
+db_file "/var/lib/mpd/mpd.db"
+log_file "/var/log/mpd/mpd.log"
+pid_file "/run/mpd/mpd.pid"
+state_file "/var/lib/mpd/mpd.state"
+
+# Unix socket only — mStream connects via the shared `mpd-socket` volume.
+# MPD 0.22+ requires a local (socket) client for file:// URI playback.
+bind_to_address "/run/mpd/socket"
+
+auto_update "no"
+default_permissions "read,add,control,admin"
+
+audio_output {
+ type "alsa"
+ name "alsa-out"
+ mixer_type "software"
+}
diff --git a/docs/docker-compose/with-transmission/.env.example b/docs/docker-compose/with-transmission/.env.example
new file mode 100644
index 000000000..ce74e3a92
--- /dev/null
+++ b/docs/docker-compose/with-transmission/.env.example
@@ -0,0 +1,6 @@
+# Copy to `.env` and rotate the password before first boot.
+# These same credentials get entered into mStream's admin UI when you
+# connect the torrent backend (see this recipe's README).
+
+TRANSMISSION_USER=mstream
+TRANSMISSION_PASS=changeme
diff --git a/docs/docker-compose/with-transmission/README.md b/docs/docker-compose/with-transmission/README.md
new file mode 100644
index 000000000..5808d8e4a
--- /dev/null
+++ b/docs/docker-compose/with-transmission/README.md
@@ -0,0 +1,88 @@
+# mStream + Transmission recipe
+
+mStream alongside a Transmission daemon, with completed downloads served straight back into mStream as a library. Browse + play your torrented music without copying files around.
+
+## Three clients, one recommendation
+
+mStream supports three torrent backends — **Transmission**, **qBittorrent**, and **Deluge**. This recipe uses Transmission, which is what we recommend for new setups:
+
+- **Path auto-detection is verified, not inferred.** Transmission exposes a `free-space` RPC call that mStream uses to probe candidate paths and confirm the daemon can actually see them — `verified` confidence even on an empty daemon. qBittorrent and Deluge have no equivalent: both fall back to a content-match probe (verified, but only if at least one existing torrent's content lines up with mStream's library) then known-paths prefix matching (`inferred` — usually correct, but a guess rather than a check).
+- **Smallest resource footprint** of the three, especially noticeable on low-end NAS / SBC deployments.
+- **Stable, well-documented RPC protocol** with a single auth model (Basic + CSRF). qBittorrent's session-cookie WebAPI v2 has more moving parts; Deluge's WebUI JSON-RPC is single-user-by-design.
+
+If you'd rather use qBittorrent or Deluge, the path-mapping shape in `compose.yml` carries over — only the daemon service definition and the RPC-whitelist handling change.
+
+## Setup
+
+```bash
+cp -r docs/docker-compose/with-transmission ~/mstream-torrents
+cd ~/mstream-torrents
+cp .env.example .env
+# edit .env: rotate TRANSMISSION_PASS to something real
+mkdir downloads mstream-config transmission-config
+docker compose up -d
+```
+
+First boot pulls both images, populates the linuxserver wrappers' `/config` dirs, and brings everything up. Watch with `docker compose logs -f`.
+
+## Connecting mStream to Transmission
+
+mStream needs the Transmission credentials to talk RPC. They're set in the admin UI rather than baked into a config file, because the same UI flow probes the daemon for its download paths during setup:
+
+1. Open mStream at `http://localhost:3000`, log in as admin (or create the admin account if this is first boot).
+2. Admin → Torrents → set **Client: Transmission**.
+3. Fill in the connection form:
+ - **Host:** `transmission` (the compose service name — Docker DNS resolves it on the internal network)
+ - **Port:** `9091`
+ - **Username:** value of `TRANSMISSION_USER` from your `.env`
+ - **Password:** value of `TRANSMISSION_PASS`
+ - **RPC path:** `/transmission/rpc` (default; the linuxserver image doesn't relocate it)
+ - **HTTPS:** off (RPC stays inside the compose network)
+4. Save. mStream probes Transmission and reports the detected download path. If you see `/downloads/...` with a "verified" badge, the path mapping is wired up correctly — anything torrented from here on becomes browsable + playable in the mStream library.
+
+## Why the WHITELIST env vars matter
+
+This is the one piece that's easy to get wrong, so the recipe handles it for you. A fresh Transmission daemon ships with `rpc-whitelist-enabled: true` and a whitelist of just `127.0.0.1,::1`, plus a DNS-rebinding guard (`rpc-host-whitelist`). mStream runs in a *separate* container and reaches Transmission across the Docker network, so:
+
+- Its client IP is a Docker-subnet address (something in `172.x` / `10.x` / `192.168.x`), **not** `127.0.0.1` → the default IP whitelist would `403` every RPC call.
+- It connects using the Host header `transmission` (a DNS name) → the rebinding guard would reject it.
+
+The `compose.yml` sets two linuxserver env vars to fix both:
+
+- `WHITELIST=127.0.0.1,10.*.*.*,172.*.*.*,192.168.*.*` — widens the IP whitelist to the private ranges Docker uses, while still excluding the public internet.
+- `HOST_WHITELIST=transmission` — allows the service-name Host header.
+
+The real security boundary is the `USER` / `PASS` RPC authentication; the IP whitelist is defense-in-depth. If you change the mStream service name, update `HOST_WHITELIST` to match.
+
+## Layout
+
+| Path on host | Mount in transmission | Mount in mstream | Why |
+|---|---|---|---|
+| `./downloads/` | `/downloads` (rw) | `/music` (ro) | Same host source. Daemon writes, mStream reads. Path mapping `/downloads/X → /music/X` is auto-detected. |
+| `./transmission-config/` | `/config` | — | linuxserver-style config tree for Transmission (settings.json, resume files, etc.) |
+| `./mstream-config/` | — | `/config` | linuxserver-style config tree for mStream (DB, album art, logs) |
+
+`PUID`/`PGID` are `1000` in both services so files Transmission writes to `./downloads` are readable by mStream. Match them to the owner of your host directories (`id -u` / `id -g`).
+
+## Exposed ports
+
+| Port | Service | Bound to | Notes |
+|---|---|---|---|
+| 3000 | mStream WebUI | `0.0.0.0` | The thing you actually browse |
+| 9091 | Transmission WebUI | `127.0.0.1` | Host-loopback only by default — the recipe ships with a placeholder password; rotate before exposing |
+| 51413 (tcp+udp) | Transmission peer | `0.0.0.0` | Must be reachable from the public internet for peering. Port-forward at the router if you're behind NAT |
+
+## Adding torrents
+
+Three entry points after setup:
+
+- **mStream's web UI** has an "Add torrent" panel (Admin → Torrents) that talks to Transmission via mStream's API. New downloads land in the shared `./downloads/` dir and become library-visible.
+- **Transmission's own WebUI** at `http://localhost:9091` works the same way — anything added here lands in the same dir and becomes visible to mStream on its next library scan.
+- **Drop `.torrent` files** into `./transmission-config/watch/` and Transmission picks them up automatically (linuxserver image enables the watch dir by default).
+
+## Gotchas
+
+- **`PASS=changeme` is a placeholder.** The recipe binds Transmission's WebUI to `127.0.0.1` specifically because the default password isn't safe. Rotate it in `.env`, then optionally drop the `127.0.0.1:` prefix in `compose.yml` if you want the WebUI reachable on the LAN.
+- **Peer port 51413** needs to be reachable from the public internet for peering to actually work. If you're behind NAT, port-forward it. Behind CGNAT, downloads will stall — that's a peer-discovery problem, not an mStream problem.
+- **Library scans are mStream's, not Transmission's.** When a torrent completes, mStream sees the files on its next scan cycle (`scanOptions.scanInterval` in mStream's config; default 24h). For testing, kick off a manual rescan from the admin UI.
+- **No `/dev/snd` here.** This recipe is streaming-only — no server-side audio. Stack with the [`with-mpd/`](../with-mpd/) recipe if you want both.
diff --git a/docs/docker-compose/with-transmission/compose.yml b/docs/docker-compose/with-transmission/compose.yml
new file mode 100644
index 000000000..0569b722b
--- /dev/null
+++ b/docs/docker-compose/with-transmission/compose.yml
@@ -0,0 +1,59 @@
+services:
+ transmission:
+ image: lscr.io/linuxserver/transmission:latest
+ container_name: mstream-transmission
+ environment:
+ - PUID=1000
+ - PGID=1000
+ - TZ=Etc/UTC
+ # RPC + WebUI auth. Interpolated by Compose from .env at parse time.
+ - USER=${TRANSMISSION_USER}
+ - PASS=${TRANSMISSION_PASS}
+ # CRITICAL for cross-container RPC. A fresh Transmission only
+ # whitelists 127.0.0.1, so it would 403 mStream's RPC calls — mStream
+ # connects from a Docker-network IP (one of the private ranges below),
+ # not loopback. WHITELIST fills rpc-whitelist; HOST_WHITELIST fills
+ # rpc-host-whitelist (Transmission's DNS-rebinding guard, which would
+ # otherwise reject the `transmission` Host header). USER/PASS auth is
+ # the real security boundary; the IP whitelist stays narrowed to
+ # private ranges as defense-in-depth.
+ - WHITELIST=127.0.0.1,10.*.*.*,172.*.*.*,192.168.*.*
+ - HOST_WHITELIST=transmission
+ volumes:
+ - ./transmission-config:/config
+ # Completed downloads. The same host directory is mounted into the
+ # mstream container below as /music:ro — mStream's torrent
+ # integration auto-detects the mapping between the daemon's
+ # /downloads and its own /music view via Transmission's free-space
+ # RPC probe.
+ - ./downloads:/downloads
+ ports:
+ # WebUI bound to 127.0.0.1 so the placeholder password in .env
+ # isn't exposed to the LAN. Drop the host prefix to expose it
+ # (after rotating PASS to something real).
+ - "127.0.0.1:9091:9091"
+ # Peer port — must be reachable from the public internet for
+ # actual peering. Both TCP and UDP.
+ - "51413:51413"
+ - "51413:51413/udp"
+ restart: unless-stopped
+
+ mstream:
+ image: lscr.io/linuxserver/mstream:latest
+ container_name: mstream
+ environment:
+ - PUID=1000
+ - PGID=1000
+ - TZ=Etc/UTC
+ volumes:
+ - ./mstream-config:/config
+ # Read-only view of Transmission's download dir. Same host source
+ # as transmission's /downloads above — mStream's torrent module
+ # resolves /downloads/ (daemon-reported) → /music/
+ # (mStream-readable) automatically.
+ - ./downloads:/music:ro
+ ports:
+ - "3000:3000"
+ depends_on:
+ - transmission
+ restart: unless-stopped