tech_notes

A collection of notes on setups and implementations.
Log | Files | Refs | README

commit 245a87fba95d7c681d15b57203f08ba214a33f66
parent 9b967f97435a89a238d5763576fcb10f6e1a02c6
Author: Julian Piribauer <julian.piribauer@gmail.com>
Date:   Fri, 17 Jul 2026 10:54:46 +0200

Add Jellyfin documentation

Diffstat:
Ajellyfin.md | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 180 insertions(+), 0 deletions(-)

diff --git a/jellyfin.md b/jellyfin.md @@ -0,0 +1,180 @@ +# Jellyfin + +We document the setup of [Jellyfin](https://jellyfin.org) running at [jellyfin.piribauer.ch](https://jellyfin.piribauer.ch), our media server for films. + +Authentication is delegated to Authentik, but unlike the other apps on the hub, Jellyfin does not support Authentik's forward-auth proxy flow — it needs its own login form. +The official `jellyfin-plugin-sso` (OIDC) plugin is archived and unmaintained, so we follow Authentik's documented alternative instead: an **LDAP outpost** that exposes Authentik's user database over LDAP, paired with Jellyfin's own LDAP Authentication plugin. +Jellyfin's login page stays exactly as it is; the plugin validates the entered username/password against the LDAP outpost in the background. There is no redirect to Authentik's login screen. + +We assume Docker, nginx, and Authentik are already running. + +## Storage + +Media lives on a dedicated external HDD, mounted at `/mnt/media`, kept separate from the restic-backed `/home/julian` tree so the (large, replaceable) media library is never included in backups. + +```console +# mkfs.ext4 /dev/sda1 +# mkdir -p /mnt/media +# mount /dev/sda1 /mnt/media +``` + +An entry in `/etc/fstab` makes the mount persist across reboots. + +## Docker Compose + +`/home/julian/jellyfin/docker-compose.yml`: + +```yaml +services: + jellyfin: + image: docker.io/jellyfin/jellyfin:latest + container_name: jellyfin + restart: unless-stopped + user: "1000:1000" + volumes: + - ./config:/config + - ./cache:/cache + - /mnt/media:/media:ro + ports: + - "127.0.0.1:8096:8096" + networks: + default: {} + authentik_default: {} + +networks: + authentik_default: + external: true +``` + +Media is mounted read-only at the container path `/media` — not `/mnt/media`, which only exists on the host. +Jellyfin is additionally attached to Authentik's `authentik_default` docker network so it can reach the LDAP outpost by its container name, without exposing anything extra on the host. + +```console +$ cd /home/julian/jellyfin +$ docker compose up -d +``` + +## Nginx + +```nginx +server { + server_name jellyfin.piribauer.ch; + + location / { + proxy_pass http://127.0.0.1:8096; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + } + + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/piribauer.ch/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/piribauer.ch/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} +``` + +**Certbot warning:** `piribauer.ch` uses a single certificate shared across every subdomain (one SAN list). Adding a new site with + +```console +# certbot --nginx -d jellyfin.piribauer.ch --cert-name piribauer.ch --expand +``` + +only works safely if **every** existing domain is also passed with `-d`. Omitting any of them causes certbot to *replace* the certificate rather than expand it, silently dropping the missing domains from the SAN list and breaking TLS for those subdomains — with no error at request time until a client actually notices the hostname mismatch. Always re-run with the full `-d` list for all subdomains (`piribauer.ch`, `www.piribauer.ch`, `auth`, `mail`, `www.mail`, `git`, `hub`, `calendar`, `familytree`, `technotes`, `jellyfin`) when expanding. + +## First run + +On first start we complete Jellyfin's setup wizard at `https://jellyfin.piribauer.ch`, which creates an initial local admin account, and add a library: + +- Content type: Movies +- Folder: `/media` (the container path, not `/mnt/media`) + +## Authentik — LDAP outpost + +Authentik's LDAP outpost runs as an extra service in the existing Authentik compose stack, `/home/julian/authentik/docker-compose.yml`: + +```yaml + ldap-outpost: + image: ghcr.io/goauthentik/ldap:2024.12.3 + container_name: authentik-ldap-outpost + restart: unless-stopped + environment: + AUTHENTIK_HOST: http://server:9000 + AUTHENTIK_TOKEN: ${LDAP_OUTPOST_TOKEN} + depends_on: + server: + condition: service_healthy +``` + +`AUTHENTIK_HOST` uses the Compose service name `server`, resolved via Docker's internal DNS — not the container's display name. +`LDAP_OUTPOST_TOKEN` is the outpost's token, generated when the outpost is created in the Authentik admin UI (**Applications → Outposts**) and stored in `/home/julian/authentik/.env`. + +We configure, in the Authentik admin UI: + +- A service account (e.g. `jellyfin_ldap`) used only to bind and search — never to log in as a person. +- Two groups: `jellyfin_users` (who can log into Jellyfin) and `jellyfin_admins` (reserved for future automatic admin grants, currently unused — see below). +- An **LDAP Provider** with `base_dn: dc=piribauer,dc=ch` and bind mode `direct`. +- A dedicated **authorization flow** for the provider, rather than reusing the default one. The stock `default-provider-authorization-implicit-consent` flow requires an authenticated Django session (`authentication: require_authenticated`), which an LDAP direct bind never has — using it throws `FlowNonApplicableException`. The custom flow needs `authentication: require_outpost` and at least two stages, an **Identification Stage** followed by a **User Login Stage** (a flow with zero stages throws `EmptyFlowException`, and a login stage with nothing preceding it never gets a `pending_user` and denies access). +- An **Application** bound to the provider. We leave it *without* any policy bindings — LDAP direct-bind requests run as `AnonymousUser`, and evaluating user/group policy bindings against `AnonymousUser` crashes the outpost. Access control instead comes from the LDAP search filter on the Jellyfin side (see below). +- The **Outpost** itself, with the LDAP provider assigned to it. + +## Jellyfin — LDAP Authentication plugin + +Installed from Jellyfin's plugin catalog (**Dashboard → Plugins → Catalog → LDAP Authentication**), then configured under **Dashboard → Plugins → LDAP Authentication**: + +- LDAP server: `authentik-ldap-outpost`, port `389`, no encryption (traffic stays inside the docker network) +- Bind DN: `cn=jellyfin_ldap,ou=service-accounts,ou=goauthentik.io,dc=piribauer,dc=ch` + + Service accounts live under `ou=service-accounts,ou=goauthentik.io`, not `ou=users` like regular people — the exact path is visible on the user's object via the Authentik API (`path` field). +- Base DN: `dc=piribauer,dc=ch` +- User search filter: restricts to members of `jellyfin_users`, e.g. `(&(memberOf=cn=jellyfin_users,ou=groups,dc=piribauer,dc=ch)(|(uid={username})(cn={username})))` +- Admin filter: references `jellyfin_admins` (inert until someone is added to that group) + +## Pointing an existing local account at LDAP + +If a local Jellyfin account already exists (e.g. created by the setup wizard) and should authenticate via LDAP instead of its local password, there's no need to delete and recreate it — just repoint its auth provider: + +```console +$ curl -H "Authorization: MediaBrowser Token=<api-key>" \ + http://127.0.0.1:8096/Users/<user-id> +``` + +Take the returned `Policy` object, set `AuthenticationProviderId` to `Jellyfin.Plugin.LDAP_Auth.LdapAuthenticationProviderPlugin`, and POST it back to `/Users/<user-id>/Policy`. + +## Known issue: username casing + +If the local Jellyfin account's username differs from the LDAP username only in case (e.g. local `Julian` vs. LDAP `julian`), login fails with an HTTP 400 and the server log shows: + +``` +System.ArgumentException: The new and old names must be different. + at Jellyfin.Server.Implementations.Users.UserManager.RenameUser(...) + at Jellyfin.Plugin.LDAP_Auth.LdapAuthenticationProviderPlugin.Authenticate(...) +``` + +The LDAP plugin syncs the local username to match LDAP's casing on every login, but Jellyfin's rename check treats the two names as identical (case-insensitively) and refuses the "no-op" rename — even though the case actually differs. This affects a direct API/UI rename attempt just as much as the plugin's own sync. + +The fix is a two-step rename through an intermediate name, so no step is a case-only change: + +```console +$ curl -X POST -H "Authorization: MediaBrowser Token=<api-key>" -H "Content-Type: application/json" \ + -d '{"Name": "julian_tmp", ...}' http://127.0.0.1:8096/Users/<user-id> +$ curl -X POST -H "Authorization: MediaBrowser Token=<api-key>" -H "Content-Type: application/json" \ + -d '{"Name": "julian", ...}' http://127.0.0.1:8096/Users/<user-id> +``` + +(each `...` is the full user object as returned by `GET /Users/<user-id>`, with only `Name` changed). Once the local username exactly matches LDAP's casing, the plugin has nothing left to rename and login proceeds normally. + +## Granting admin rights + +`jellyfin_admins` is wired into the LDAP plugin's admin filter but currently has no members, so admin rights aren't granted automatically. Until that group is populated, admin rights are set directly: + +```console +$ curl -X POST -H "Authorization: MediaBrowser Token=<api-key>" -H "Content-Type: application/json" \ + -d '{"IsAdministrator": true, ...}' http://127.0.0.1:8096/Users/<user-id>/Policy +```