Jellyfin
We document the setup of Jellyfin running at 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.
# 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:
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.
$ cd /home/julian/jellyfin
$ docker compose up -d
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
# 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:
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) andjellyfin_admins(reserved for future automatic admin grants, currently unused — see below). - An LDAP Provider with
base_dn: dc=piribauer,dc=chand bind modedirect. - A dedicated authorization flow for the
provider, rather than reusing the default one. The stock
default-provider-authorization-implicit-consentflow requires an authenticated Django session (authentication: require_authenticated), which an LDAP direct bind never has — using it throwsFlowNonApplicableException. The custom flow needsauthentication: require_outpostand at least two stages, an Identification Stage followed by a User Login Stage (a flow with zero stages throwsEmptyFlowException, and a login stage with nothing preceding it never gets apending_userand 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 againstAnonymousUsercrashes 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, port389, no encryption (traffic stays inside the docker network)Bind DN:
cn=jellyfin_ldap,ou=service-accounts,ou=goauthentik.io,dc=piribauer,dc=chService accounts live under
ou=service-accounts,ou=goauthentik.io, notou=userslike regular people — the exact path is visible on the user’s object via the Authentik API (pathfield).Base DN:
dc=piribauer,dc=chUser 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:
$ 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:
$ 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:
$ 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