Files
Media-Server-Setup/CommunityDiscordMedia.md

342 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Community Discord Media — plan
> Current state lives in **STATUS.md** (what runs, who does what, what's unfinished). This file is the design and the phase checklists.
One login for everything (Discord via Authentik), a watch page (Jellyfin), and the existing
ingest/browse/download stack (MediaMTX, copyparty, Syncthing) moved behind that login.
Host: OVH SYS-1 (Xeon E-2136, 32 GB, 2×4 TB mirror), Debian 13, Docker Compose, Caddy TLS.
Bundle: `vanguard-stream/` in this repo; live copy at `~/vanguard-stream` on the server.
## Where we are
| piece | state |
|-------|-------|
| MediaMTX ingest (RTMP 1935 / SRT 8890), per-streamer keys | live |
| copyparty browse/scrub/download/WebDAV at `https://ovh1.dumpstervigilantes.rip/` | live, local accounts |
| OBS dock (`/dock/`), dump-tonight (`/dump`) | live, dump uses its own password file |
| Syncthing clips intake (22000), admin UI at `/sync/` | live, Syncthing's own GUI login |
| normalize-sweep (one editing standard, raw files in hidden `.src/`) | live |
| nftables host firewall | live |
| Authentik (Discord login, groups) at `auth.` | live |
| Jellyfin at `watch.`, SSO via Authentik, direct play only | live |
| Vanguard Metadata plugin v0.1 | installed, libraries being switched to Shows |
| MariaDB (bot database) + `discord-bot` Python container (`bot/app`) | **in bundle, not yet applied** |
| Gitea (`git.`, MariaDB, ssh 2222) + Actions runner | live (local admin account; Authentik OIDC → Phase 10) |
## Decisions already made
- **Discord is the identity.** Authentik is the only self-hosted IdP that does Discord social login,
groups, OIDC *and* forward-auth for apps with no login of their own. Keycloak/Authelia/Pocket-ID don't fit.
- **Groups:** `streamers`, `editors`, `admins`. Access everywhere is by group, not by person.
- **Jellyfin is direct-play only** until a SYS-3 (Xeon E-2288G, Quick Sync). The E-2136 has no
hardware encoder; software transcoding tops out at ~2 concurrent viewers. Transcoding is off at
the server *and* per user.
- **MediaMTX stream keys stay machine credentials.** OBS can't do a Discord login. Keys are managed by
`configure.py`; the dock will fetch the logged-in streamer's key after Authentik login (phase 6).
- **Raw clipper files live in hidden `.src/` folders** so Jellyfin and copyparty show one item per clip.
- **Hostnames:** `auth.` and `watch.` are CNAMEs to the main host. Caddy routes by SNI, Let's Encrypt
follows CNAMEs. One A record to change if the IP ever moves.
- **WebDAV through Authentik** uses Authentik app passwords as HTTP basic auth (a mounted drive
can't do a browser login).
## Target layout
```
auth.dumpstervigilantes.rip Authentik — Discord login, groups, OIDC provider, forward-auth gate
watch.dumpstervigilantes.rip Jellyfin — libraries: streams / clips / finals, SSO plugin → Authentik
ovh1.dumpstervigilantes.rip Caddy
/ copyparty (forward-auth → Authentik; user+groups via headers; perms by @group)
/dock/ OBS dock (requires Authentik session; shows the caller's own stream key)
/dump dumpd (trusts X-Authentik-Username; admins may dump anyone)
/sync/ Syncthing (forward-auth, admins only)
rtmp://ovh1:1935 srt://ovh1:8890 MediaMTX (unchanged; per-streamer user/pass in stream key)
```
## Phases
### Phase 1 — DNS + Authentik/Jellyfin containers ✅ done → `addCommunityMediaServer.sh`
- [ ] CNAME `auth` → `ovh1.dumpstervigilantes.rip`
- [ ] CNAME `watch` → `ovh1.dumpstervigilantes.rip`
- [ ] from your machine: `scp -r bin docker-compose.yml configure.py install.sh addCommunityMediaServer.sh README.md CommunityDiscordMedia.md .env.example debian@ovh1…:~/vanguard-stream/`
- [ ] on the server: `cd ~/vanguard-stream && sudo install -m 0755 bin/normalize-sweep.sh /usr/local/bin/ && ./addCommunityMediaServer.sh`
— asks for the two hostnames (defaults derived from the main host), checks they resolve to this
box, writes Caddy blocks + `.env` (secrets) via `configure.py --hosts`, starts the containers,
waits for HTTPS on both names, then prints the Phase 2/3 steps with the real hostnames.
Re-runnable; remembers the hostnames.
- [ ] `docker compose ps` → 9 containers up; `free -h` sane
### Phase 2 — Authentik + Discord ✅ done (loose ends → Phase 8)
- [ ] `https://auth.HOST/if/flow/initial-setup/` → set `akadmin` password (store it somewhere safe)
- [ ] Discord developer portal → application → OAuth2 redirect `https://auth.HOST/source/oauth/callback/<slug>/`
(slug = the Authentik source slug, e.g. `discord`; they must match exactly)
- [ ] Authentik → Directory → Federation & Social login → Create → Discord OAuth Source
(slug `discord`; consumer key/secret = Discord client id/secret; user matching "link on unique
identifier" is fine; scopes empty; flows default-source-authentication / default-source-enrollment)
- [ ] rotate the Discord client secret after setup if it was ever pasted into a chat/screenshot
- [ ] Show the source on the login page: Flows & Stages → Stages → `default-authentication-identification`
→ Sources: select Discord, tick "Show sources' labels" (new sources are hidden until this is done)
- [ ] Make enrolled users Internal: Flows & Stages → Stages → `default-source-enrollment-write` →
User type: Internal (External users get "Interface can only be accessed by internal users")
- [ ] Directory → Groups: `streamers`, `editors`, `admins`
- [ ] Log in with Discord as a normal user; confirm the account is created and Internal; add yourself to `admins`
- [x] only members of our Discord server may log in — `authentik/discord-guild-policy.py`:
1. Discord Developer Mode → right-click server → Copy Server ID
2. Discord source → Scopes: `guilds`
3. Customization → Policies → Create Expression Policy `discord-guild-member`, paste the file,
set `ACCEPTED_GUILD_ID` / `GUILD_NAME`
4. bind it to `default-source-enrollment` **and** `default-source-authentication`
(flow → Policy / Group / User Bindings → Bind existing policy), then edit both flows →
Policy engine mode **all** (with `any`, the existing if-sso binding passes on its own)
5. test with a member and a non-member; existing users re-consent once at Discord
Later: a per-role variant uses `guilds.members.read` and `/users/@me/guilds/<id>/member`
to map Discord roles → Authentik groups automatically.
- [ ] Pin `AUTHENTIK_TAG` in `.env` to the version that was installed (don't float on upgrades)
### Phase 3 — Jellyfin ✅ done (loose ends → Phase 8)
- [ ] Wizard at `https://watch.HOST/`: admin user; libraries `/media/streams`, `/media/clips`,
`/media/finals` as "Home Videos", all metadata fetchers off
- [ ] Dashboard → Playback → Transcoding: hardware acceleration none; per user untick
"Allow video playback that requires transcoding"
- [ ] Plugins → Repositories → add `https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json` → Catalog → "SSO Authentication" → install → restart
- [ ] Authentik → Applications → Create with provider (OAuth2/OIDC) for Jellyfin, redirect
`https://watch.HOST/sso/OID/redirect/authentik`
- [ ] SSO-Auth → add provider `authentik`: OID endpoint `https://auth.HOST/application/o/<slug>/`,
client id/secret, roles claim `groups`, admin roles `admins`, enabled folders = 3 libraries,
default: transcoding disabled; Scheme Override `https`, Port Override EMPTY; Save the page
- [ ] direct test `https://watch.HOST/sso/OID/start/authentik` logs a Discord user in
(gotchas hit: unsaved provider → "Provider does not exist"; Port Override set → redirect gets `:443`
→ Authentik mismatch; opening the /redirect/ URL by hand → "missing state")
- [ ] login page: Discord button first, local form behind "Admin login" — Dashboard → General → Branding:
disclaimer = `jellyfin/branding-disclaimer.html` (set the hostname), CSS = `jellyfin/branding.css`
- [ ] button works for a Discord user in `editors`
### Phase 4 — "Vanguard Metadata" Jellyfin plugin (NEXT — usability for everyone)
Decision: this lives **inside Jellyfin as a metadata provider plugin**, not as server scripts and
timers. Files stay exactly where they land (Syncthing mirrors the gamer's folder; streams stay in
`streams/<user>/<Title>/`). No sidecar files, no symlink view, no moves. Jellyfin asks the plugin
for metadata and the plugin answers from the filename, the folder, Discord and Authentik.
**What the plugin provides** (C#, Jellyfin plugin SDK, targets the installed server's ABI)
- `IRemoteMetadataProvider<Series>` — show = the `<user>` folder: display name from Discord,
overview from the member's roles/bio
- `IRemoteImageProvider` (Series) — poster = the Discord avatar
- `IRemoteMetadataProvider<Season>` — season **name** for a season number (policy / override / event)
- `IRemoteMetadataProvider<Episode>` — **season + episode numbers from the timestamp** (rolling
policy below), title from the Discord event overlapping the recording (fallback: the dock title =
folder name), overview = event description + duration + streamer, premiere date
- `IScheduledTask` "Discord snapshot" — members + scheduled events into the plugin's data store
every 5 min (Discord only exposes upcoming/active events; history must be kept)
- `IScheduledTask` "Season roll" — nightly after 04:00 Eastern: refresh metadata on items whose
bucket changed (weekday → week → month → year). Same path, same item ID → watched state kept.
- configuration page: Discord bot token + guild ID, Authentik URL + API token (streamer → Discord ID
via the OAuth source connection), broadcast-day hour, rolling thresholds, season name overrides,
provider on/off per library
**Timestamp precedence** (plugin-side): bot `clip.json` sidecar if present (Medal/Twitch/YouTube
creation time) → container `creation_time` (Jellyfin's MediaInfo exposes it) → MediaMTX filename →
file date. Normalised to America/New_York.
**Rolling seasons by tag** (ParentIndexNumber/IndexNumber set by the provider; pure function of
timestamp + policy):
| age of item | season # | season name |
|---|---|---|
| current week | `YYYYMMDD` | "Thursday 30 Oct" |
| current month, past week | `YYYYWW` (ISO) | "Week 44" |
| current year, past month | `YYYYMM` | "October 2026" |
| past year | `YYYY` | "2026" |
episode # derived from the timestamp (`DDHHMM`-style; stable; same-minute collisions get a digit).
Broadcast day rolls at 04:00. Season names: generated → override table → Discord event series name
when every item in the bucket belongs to one.
**Libraries**: Shows type for `streams` and `clips` with this plugin as the only Series/Season/Episode
provider (TheTVDB/TMDB off), Movies type for `finals` (plugin optional). Jellyfin's scanner treats
`<user>` as the series and any file below it as an episode; the plugin supplies the numbers.
**Build & ship**: .NET 8 SDK, `Jellyfin.Controller` matching the server's major version; CI builds
the DLL + zip; served from our Caddy as a plugin repository (`/plugins/manifest.json`) so installs
and updates go through Jellyfin's own Catalog. Cost: C#, and a rebuild on each Jellyfin major.
Status: **v0.1.1.0** — v0.1.0 installed and **verified**: Jellyfin honours the provider's season numbers
(day seasons appeared on a Shows library). v0.1.1 adds the playback-policy task. Providers, Discord/Authentik
clients, event history, season-roll + snapshot tasks, Discord feed with frame-grab attachment, config page;
10 unit tests.
Checklist:
- [x] skeleton + core season policy (tests) + providers + tasks + feed → `./package.sh` → `dist/VanguardMetadata_0.1.0.0.zip`
- [ ] Discord dev portal → the existing Authentik application → **Bot** → Reset Token; enable **Server Members**
intent; OAuth2 URL generator: scope `bot`, perms View Channels + Send Messages + Embed Links + Attach Files;
invite to the server
- [ ] Authentik: read-only service account + API token for the plugin
- [ ] install the zip (README in the plugin folder), restart Jellyfin, configure the plugin page
- [x] libraries → Shows type; providers: only Vanguard; scan — day seasons appeared
- [ ] install v0.1.1 (`./installJellyfinPlugin.sh`), run "enforce playback policy" once, check a user's policy
- [ ] verify: seasons appear as "Thursday, Oct 29" / "Week 44" / "October 2026"; poster = avatar;
a new file posts to the feed channel with the frame grab
- [ ] Medal local filename pattern → add to `ItemTimestamp` (need a sample filename)
- [ ] later: serve `dist/` at `/plugins/` via Caddy as a plugin repo; theme CSS
### Phase 5 — copyparty behind Authentik (built; apply pending)
- [ ] Authentik → Providers → Proxy provider, *forward auth (single application)*, external host
`https://ovh1.HOST`; Application bound to it; add to the embedded outpost
- [ ] Caddy: `forward_auth 127.0.0.1:9000 { uri /outpost.goauthentik.io/auth/caddy; copy_headers X-Authentik-Username X-Authentik-Groups … }`
on `/`, `/dock*`, `/dump*`, `/sync*`; `reverse_proxy /outpost.goauthentik.io/* 127.0.0.1:9000`
- [ ] copyparty: `idp-h-usr: X-Authentik-Username`, `idp-h-grp: X-Authentik-Groups`, `xff-src` = Caddy only;
volume accs become `r: @editors`, `rw: @editors` (finals), `rwmd: @media-admins` on every volume incl. trash
(new group `media-admins`; techy joins it via a Discord role in the roles→groups table or a manual add —
replaces today's workaround of logging in as the local `admin` account to modify streams/clips)
- [ ] keep the local accounts in `copyparty.conf` until an `editors` Discord user has browsed and
downloaded through the new path; then remove them (`configure.py` drops the editors section)
- [ ] WebDAV: editor creates an app password in Authentik → mounts with Discord username + app password
- [ ] policy on the Application: only `editors` + `admins` + `streamers` may pass
### Phase 6 — Syncthing UI + dump behind Authentik (built together with phase 5; apply pending)
- [ ] `/sync/`: forward-auth, policy `admins` only; Syncthing GUI auth can stay as a second layer
- [ ] `dumpd.py`: read `X-Authentik-Username` / `X-Authentik-Groups` from Caddy instead of basic auth;
admin = member of `admins`; drop `dump-users.txt`
- [ ] dock: `Dump tonight` sends no credentials; session cookie carries identity
### Phase 7 — dock polish + cleanup
(partly done: dumpd `/me/stream-key` exists and the live portal's Go Live uses it; the OBS dock still holds its own copy of the key)
- [ ] `dumpd.py` gains `GET /me` → `{user, groups, stream_key}` for the logged-in streamer;
dock fills OBS settings from it — nobody types a stream password again
- [ ] `configure.py` shrinks: hostnames + streamers only (editors and admin live in Authentik)
- [ ] README: replace the per-app login notes with "log in with Discord"
### Phase 8 — hardening (loose ends from phases 2–3, plus backups)
- [ ] Jellyfin login branding: Discord button first, "Admin login" reveals the local form
(`jellyfin/branding-disclaimer.html` + `jellyfin/branding.css`)
- [ ] group check: a Discord user in `editors` but not `admins` sees the three libraries and is NOT a
Jellyfin administrator; one in `admins` is
- [x] per-user transcoding OFF for every user, automatically: the Vanguard plugin's "enforce playback
policy" task (startup + every 30 min) sets direct-play-only on all non-admin users, SSO-created
ones included. Config toggle `EnforceDirectPlayOnly`; turn off on the SYS-3
- [ ] pin Authentik: `.env` → `AUTHENTIK_TAG=<tag now running>` (`docker inspect authentik-server --format '{{.Config.Image}}'`)
- [x] guild restriction so only members of our Discord server can log in
- [ ] rotate the Discord client secret that appeared in a screenshot; paste the new one into the source
- [ ] nightly `pg_dump` of the Authentik database into `/srv/appdata/authentik/backup/` (timer), keep 14 days;
Jellyfin config and Authentik media are plain directories, covered by the archive/backup of `/srv/appdata`
- [ ] Jellyfin: library "real time monitoring" on, metadata fetchers confirmed off, scheduled scan daily
### Discord bot (separate dev) — runtime provided by the stack
- `mariadb` container, db `vanguardbot` / user `bot`, loopback only (ssh tunnel for dev access)
- `discord-bot` container: python 3.12 + ffmpeg + yt-dlp baseline; code bind-mounted from `bot/app/`;
env gives token, guild, DB creds, rw `/media/clips`, `/data` scratch; restarts on exit
- web UI at `bot.` behind Caddy + Authentik forward-auth (first use of the forward-auth pattern that
Phase 5 applies to copyparty); the bot trusts `X-Authentik-*` headers and implements no login
- `bot/AGENT.md` is the build brief for the agent writing the bot; `bot/README.md` the ops cheat-sheet. The clip-submissions feature (Option below) is a natural home
for this bot: write `clips/<user>/<name>.mp4` + `<name>.json` sidecar and the Jellyfin plugin picks it up.
- [ ] CNAME `bot` → main host; `configure.py --full` (asks the hostname, bot token + guild) → `docker compose up -d --build`
- [ ] Authentik: domain-level Proxy Provider (covers live/bot/main); Caddy requires group `bot-users` on the bot host (`BOT_REQUIRE_GROUP`)
- [ ] Discord roles → Authentik groups: `authentik/discord-roles-to-groups.py` on both source flows (scope `guilds.members.read`)
- [ ] dev drops `main.py` into `bot/app/` → `docker compose restart discord-bot`
### Phase 9 — Gitea + Actions runner
- `gitea` on MariaDB (db/user from `mariadb/init/10-gitea.sh` on first init, else `./createGiteaDb.sh`),
`git.` via Caddy, ssh 2222 (firewall opened), registration closed, Actions enabled
- `act_runner` with the Docker socket; labels ubuntu-latest / ubuntu-22.04 / dotnet / python
- [ ] CNAME `git` → main host; `./configure.py --full` (asks the hostname) or `--hosts AUTH WATCH GIT`
- [ ] https://git.HOST/ install page → admin account
- [ ] `./registerGiteaRunner.sh`; confirm the runner is idle under Site Administration → Actions → Runners
- [ ] create users (or OIDC via Authentik, same pattern as Jellyfin: Gitea → Authentication Sources → OpenID Connect)
- [ ] first use: push `vanguard-stream` itself; a workflow that runs `package.sh` on the `dotnet` label and
publishes the plugin zip is the obvious first pipeline
- risk: the runner's Docker socket is root on the host for anyone who can push a workflow
### Phase 10 — Gitea login via Authentik (OIDC) (later)
Same pattern as Jellyfin: Authentik is the identity, Gitea trusts it.
- [ ] Authentik → Applications → Create with provider → OAuth2/OpenID: name `gitea`, redirect
`https://git.HOST/user/oauth2/authentik/callback`, authorization flow implicit-consent, scopes
openid email profile (+ `groups` claim via the default profile mapping)
- [ ] Gitea → Site Administration → Identity & Access → Authentication Sources → Add → OAuth2,
provider OpenID Connect, name `authentik` (must match the callback path above),
client ID/secret, discovery URL `https://auth.HOST/application/o/<slug>/.well-known/openid-configuration`,
"Skip local 2FA", claim mapping: group claim `groups` → Gitea admin group `admins`
- [ ] Gitea app.ini / env: `GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION=true`,
`GITEA__oauth2_client__ACCOUNT_LINKING=auto`, `GITEA__oauth2_client__USERNAME=nickname` so first
Discord login creates the Gitea user with no form (registration stays closed for local accounts)
- [ ] optional: `GITEA__service__ALLOW_ONLY_EXTERNAL_REGISTRATION=true`, hide the password form on the
login page (`GITEA__oauth2_client__OPENID_CONNECT_SCOPES`, custom template) so Discord is the only way in
- [ ] policy on the Authentik application: who may reach Gitea at all (e.g. `admins` + a `devs` group)
- [ ] test with a non-admin Discord user; confirm admin mapping via `admins` group
### Phase 11 — MediaMTX auth through Authentik (no stream keys anywhere)
Goal: nobody holds a MediaMTX password. "May publish" = Authentik identity + `streamers` group (filled by
Discord roles); the publish path must start with the user's own name.
- MediaMTX `authMethod: http`, `authHTTPAddress: http://127.0.0.1:8080/mtx-auth` (dumpd becomes the
arbiter; reads stay open to `any`, Caddy gates viewers). MediaMTX posts user/pass/token/action/path/ip.
- **Browser (Go Live, WHEP)**: Caddy forwards Authentik's `X-Authentik-Jwt` as `Authorization: Bearer` on
the `/webrtc` hop. dumpd verifies the JWT against Authentik's JWKS (`/application/o/<slug>/jwks/`),
reads `preferred_username` + `groups`, allows publish iff `path` starts with `<username>/` and user ∈ streamers.
No `/me/stream-key`, no `streamers.json`, nothing in the browser.
- **OBS**: streamer creates an **app password** in their Authentik profile and pastes it in the dock once;
dumpd validates Basic `user:app-password` against Authentik (outpost basic-auth endpoint or the API),
same path rule. Revoking the app password revokes the stream key.
- configure.py loses the streamers section (folders are created on first publish by MediaMTX anyway;
the sweep and Jellyfin cope). Dock: "paste your Authentik app password" instead of a stream password.
- [ ] dumpd `/mtx-auth` (JWT via JWKS, Basic via Authentik, path rule, 5 s cache)
- [ ] Caddy: `header_up Authorization "Bearer {http.request.header.X-Authentik-Jwt}"` on `/webrtc/*` (replaces X-Stream-Auth)
- [ ] MediaMTX `authMethod: http`; keep `any` read; drop `authInternalUsers`
- [ ] dock + README; remove streamers from configure.py; delete `streamers.json`
- [ ] test: OBS with an app password; Go Live with no key; a non-streamer gets denied
### Option — more clip sources (Medal, Twitch/YouTube/Kick clips, Discord uploads)
Today the only clip intake is a local clipper folder synced by Syncthing. Members without that
setup have no way in. Two additions cover everyone, and neither depends on Authentik.
**A. `#clip-submissions` Discord bot** (one container, outbound-only, no new ports)
- member posts a Medal.tv / Twitch clip / YouTube / Kick / Streamable link, or drops a file
- bot maps the poster's Discord username → `/srv/media/clips/<username>/` (same identity Authentik uses)
- downloads at best quality with **yt-dlp** (has extractors for all of the above incl. Medal)
- writes `clip.json` beside it: source URL, title, submitter, timestamp — context for editors
- reacts ✅ / ❌ on the message; replies with the reason on failure
- normalize-sweep handles whatever arrives (Medal's HEVC/VFR etc.)
- config: bot token + channel id in `.env`; yt-dlp pinned, self-updates weekly (extractors rot)
- quality note: Medal/Twitch/YouTube deliver their re-encode (~1080p). Fine for clips; originals
still come via Syncthing or upload below.
- skipped on purpose: polling Medal profiles via its API — needs per-member IDs, pulls clips nobody
submitted, and only returns embed URLs; the bot covers it with explicit intent.
**B. Web upload for members (after Phase 5)**
- copyparty per-user volume `clips/${u}` writable by its owner, readable by `editors`/`admins`
- anyone who can log in with Discord drags an original into the browser or WebDAV — full quality,
no bot, no install
| source | path in | quality |
|--------|---------|---------|
| local clipper + Syncthing | automatic | original |
| Medal / Twitch / YouTube / Kick link | bot (A) | platform re-encode |
| Discord attachment | bot (A) | original, Discord size cap |
| file in hand | web upload (B) | original |
Checklist if adopted:
- [ ] Discord developer portal → bot user, Message Content intent, invite to the server with
read/react/reply in `#clip-submissions`
- [ ] `bot/` in the bundle: python + discord.py + yt-dlp, runs as uid 1000, mounts `/srv/media/clips`
- [ ] `.env`: `DISCORD_BOT_TOKEN`, `CLIPS_CHANNEL_ID`
- [ ] per-user clips volume in `copyparty.conf` once forward-auth is live (Phase 5)
### Later — when it hurts
- [ ] **SYS-3 (E-2288G)**: Quick Sync → Jellyfin transcoding on, sweep re-encodes in minutes.
Swap, not redesign: same compose, same `/srv` layout; restore `/srv/appdata` + `/srv/media`.
- [ ] home archive puller (`home/rclone-archive.sh`) on the home box, nightly cron
## Risks / gotchas
- **Phase 5 lockout.** Keep one SSH session open and a copyparty local admin account until the
Authentik path is proven. Caddy config errors stop *all* sites — `docker logs caddy` first.
- **Authentik outpost header names** differ by version (`X-Authentik-Username`, `X-Authentik-Groups`,
`X-Authentik-Email`). Verify with a test route that echoes headers before wiring copyparty.
- **Jellyfin SSO plugin** creates users with transcoding allowed by default unless the provider's
defaults are set — set them, or the per-user toggle has to be fixed after every first login.
- **RAM**: Authentik ~1.5 GB, Jellyfin ~0.5 GB idle. Fine on 32 GB; check `free -h` after phase 1.
- **nftables**: never `systemctl stop|restart nftables` (flushes Docker's NAT). Reload only.
- **Firewall unchanged**: Authentik (9000) and Jellyfin (8096) bind to 127.0.0.1; only Caddy is public.
## Monthly cost
Unchanged: the SYS-1 (~$30 + disks). No new services cost money. Discord OAuth is free.