Jellyfin
Media server. Reads films and TV off disk and streams them to whatever is in the room — no account, no subscription, nothing phoning home.
Quick start
cp .env.example .env
$EDITOR .env # MEDIA_PATH and RENDER_GID are required
docker compose up -d
Open http://<host>:8096 and point your libraries at /media — the path
inside the container, whatever MEDIA_PATH is on the host.
Ports
| Port | Proto | Purpose | |
|---|---|---|---|
| 8096 | http | Web UI and client API | published |
| 7359 | udp | LAN auto-discovery | published |
| 8920 | https | Jellyfin's own TLS listener | off — TLS belongs at the proxy |
| 1900 | udp | DLNA / SSDP | off — uncomment if you use DLNA |
Why it looks like this
The LinuxServer image. I started with it years ago, it has worked every day
since, and I'm not changing a base image to fix nothing. The better reason: I
run several of their images and they all speak the same dialect —
PUID/PGID/TZ, /config, s6, one rebuild cadence. Learn one, learn the
family. Jellyfin's official image is also fine; it just doesn't do the
PUID/PGID dance, so ownership works differently.
/config lives next to the compose file, breaking this repo's own
"data outside the repo" rule on purpose. It's Jellyfin's database, metadata and
artwork — 988M after three years here. Under a gigabyte, and keeping it beside
the compose file means the whole service is one tar from being moved.
.gitignore keeps it out of git. Note that transcode scratch files land under
/config and can spike several GB — move that path in Dashboard → Playback
if the disk is small.
PUID/PGID/TZ carry defaults (${PUID:-1000}) because they're the same
for every service on a box. Compose resolves them from the default, then .env,
then the ambient environment — so a global export PUID=…, or OpenMediaVault's
compose plugin, wins. That last one is why my own copy has bare ${PUID}: OMV
supplies those three, so they're never empty on my machine. Without OMV they
would be, and Compose interpolates an empty string and starts anyway rather than
failing. Hence the defaults.
Values that can't be guessed use ${VAR:?message} instead and abort:
$ docker compose up -d
error while interpolating services.jellyfin.group_add.[]: required variable
RENDER_GID is missing a value: find yours with: getent group render | cut -d: -f3
One /media mount. My real server has three libraries on separate disks;
that's my risk appetite, not yours. Add a line per library if you need more.
Upstream's example splits /data/tvshows and /data/movies — fine on separate
disks, but if they share a filesystem, mounting the common parent is the better
habit: hardlinks only survive within one mount point inside the container.
Mount media :ro if you like — Jellyfin never writes to it.
Hardware transcoding needs devices and group_add. Passing
/dev/dri/renderD128 in is half the job; the node is owned by a host group and
the container user has to be in it or the device opens and does nothing. The
catch is that group_add wants a numeric GID and it differs per host — mine is
105, on the laptop I wrote this on it's 992. Copying someone's compose file
verbatim is exactly how you get silently broken acceleration.
getent group render | cut -d: -f3
Not on Intel? Delete both keys — CPU transcoding works, it just costs cores. NVIDIA and AMD variants: LinuxServer's hardware acceleration docs.
restart: unless-stopped, not always — if I stop it deliberately, it
should stay stopped across a reboot.
Gotchas
:latestis a choice. Pin a tag if that makes you nervous. Back up/configeither way — that directory is your server.- Auto-discovery is LAN-only. 7359/udp is broadcast; behind a proxy it does
nothing for you. Set
JELLYFIN_PublishedServerUrlthere instead. - On a subpath, set the base URL under Dashboard → Networking first, or the UI loads and every asset 404s. A subdomain avoids the question.
- Verify hardware transcoding actually engaged. Enabling VAAPI and it working are different states — force a transcode and check Dashboard → Playback. A wrong GID falls back to software silently.
Exposing it
Publishes plain HTTP; nothing here is proxy-aware.
- Caddy reverse proxy — what I do.
- Cloudflare Tunnel — not for this one. Streaming video through a tunnel breaks Cloudflare's terms and performs badly.
- Authentik SSO — forward auth works for the web UI but breaks every native client, so use a plugin instead. See below.
Single sign-on with Authentik
Needs a reverse proxy in front, same as anything else doing OIDC — see Caddy.
Authentik's own guide takes a different route than I did. Their Jellyfin page documents the LDAP approach: an LDAP provider plus an outpost, talking to Jellyfin's LDAP Authentication plugin. Their reasoning is that Jellyfin has no native external authentication. That works, but it means running an outpost and managing a bind user.
I used the SSO-Auth plugin instead, which does OIDC against Authentik directly — no outpost, no LDAP bind. If you follow their page and wonder why none of the screens match, that's why.
The setting that cost me the time
Scheme Override → https.
If the plugin redirects you to an insecure URL — you land on http:// mid-login
and it fails — this is the fix. Behind a TLS-terminating proxy, Jellyfin sees a
plain HTTP request and builds the callback from that, so the redirect goes out as
http:// even though everything the browser touches is HTTPS.
Tracked upstream at goauthentik/authentik#15936.
Note this is the same class of problem as JELLYFIN_PublishedServerUrl in
compose.yaml: Jellyfin builds absolute URLs from what it observes, and behind a
proxy what it observes is wrong until you tell it otherwise.
Links
- Jellyfin docs: https://jellyfin.org/docs/
- Hardware acceleration: upstream · this image
- Image: https://docs.linuxserver.io/images/docker-jellyfin/