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:

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:

Jellyfin — LDAP Authentication plugin

Installed from Jellyfin’s plugin catalog (Dashboard → Plugins → Catalog → LDAP Authentication), then configured under Dashboard → Plugins → LDAP Authentication:

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