5.1 KiB
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 — works for the web UI, but native clients can't handle a forward-auth login page.
Links
- Jellyfin docs: https://jellyfin.org/docs/
- Hardware acceleration: upstream · this image
- Image: https://docs.linuxserver.io/images/docker-jellyfin/