Self-Hosted Media Server with Jellyfin

Stream your own movie, TV, and music library to any device, on your own hardware, with no subscription and no cloud dependency.

What you'll build and why

Jellyfin indexes your own media files, fetches matching artwork and metadata, and serves them to apps on phones, TVs, browsers, and streaming boxes -- free, open-source, with no subscription and no account with a third-party company. Your library, your metadata, your server.

Don't build this if: you don't actually have a media library to serve -- there's nothing here for Jellyfin to index without files already on disk, and downloading/acquiring content is explicitly outside what this guide covers or endorses. You also don't need this if you're happy with an existing streaming subscription and have no specific reason (cost, control, content you already own) to self-host instead -- this is a real time investment, not a strict improvement for everyone.

This guide covers a working single-server Jellyfin setup with correctly organized libraries and, optionally, hardware transcoding. It doesn't cover acquiring content, or the "*arr stack" (Sonarr/Radarr) some people pair with Jellyfin to automate library management -- see Go further.

How it works

  Client (phone, TV, browser app)
│
│  Direct play: client can play the file
│  as-is -- Jellyfin just streams the bytes
▼
Jellyfin server
│
│  Transcode: client CAN'T play the file
│  as-is (wrong format/bitrate/codec) --
│  Jellyfin converts it in real time first
▼
Transcoded stream ──► Client

Direct play (no conversion) is cheap: Jellyfin just reads the file and sends it. Transcoding (converting on the fly, e.g. downscaling for a phone on mobile data, or converting a format the client's app doesn't support) costs real CPU -- or GPU, if hardware-accelerated -- and is the thing that determines how many simultaneous streams your hardware can actually handle. Step 4 covers making transcoding fast instead of slow.

Before you start

Organize your media before you start. Jellyfin's automatic metadata matching depends on consistent naming:

/media/
movies/
Inception (2010)/
Inception (2010).mkv
tv/
Breaking Bad/
Season 01/
Breaking Bad S01E01.mkv

Movies: their own folder, Title (Year). TV: Show/Season NN/Show SNNENN.ext. Getting this right up front saves real time later -- Jellyfin's matching is good, but it's matching against exactly this pattern, and fixing dozens of misidentified items after the fact is tedious.

Decision: software or hardware transcoding? This guide sets up software transcoding first (works everywhere, no extra config) and covers enabling hardware transcoding as Step 4, which you can do immediately or defer until you actually have more than one person streaming at once and notice CPU struggling.

Steps

Step 1: Run Jellyfin with Docker Compose

$ mkdir -p ~/jellyfin && cd ~/jellyfin
services:
jellyfin:
image: jellyfin/jellyfin:12.1
container_name: jellyfin
network_mode: "host"
volumes:
- "./config:/config"
- "./cache:/cache"
- "/path/to/media:/media"
restart: unless-stopped

Replace /path/to/media with your actual media directory from Before you start. Pinned to 12.1 (the current release as of this guide) rather than :latest, so an upstream update doesn't silently change your running version -- move to latest deliberately once you're comfortable tracking Jellyfin's own release notes yourself. network_mode: host is the simplest option for local network discovery to work correctly; a bridge network with explicit port mapping (8096:8096/tcp, plus 7359:7359/udp for DLNA discovery if you want it) also works if you'd rather isolate the container.

$ docker compose up -d

Step 2: Run the setup wizard

Visit http://<host-ip>:8096 and follow the wizard: language, create an admin account, then add a Library for each folder from Before you start (Movies/media/movies, TV Shows/media/tv). Jellyfin scans and fetches metadata automatically once a library is added -- this can take a while on first run for a large library.

Step 3: Add other users

Dashboard → Users → Add User for each person who should have their own profile, watch history, and permissions.

Step 4 (optional): Enable hardware transcoding

Only worth doing once you've confirmed the basics work, or once you know more than one person will stream simultaneously.

For Intel Quick Sync (the common case on a mini PC or NUC-class machine):

    devices:
- "/dev/dri/renderD128:/dev/dri/renderD128"
group_add:
- "<HOST-RENDER-GROUP-ID>"

Find your host's render group ID:

$ getent group render | cut -d: -f3

Use that number in place of <HOST-RENDER-GROUP-ID>. Add both lines to the compose file's jellyfin service, then docker compose up -d to recreate the container with the new device access.

In the Jellyfin dashboard: Dashboard → Playback, set Hardware acceleration to Intel QuickSync (QSV), and check the codecs you want accelerated (H.264 at minimum). For Nvidia NVENC, the equivalent Nvidia-specific device/driver setup is different -- see the link in Resources.

Verify it works

  • http://<host-ip>:8096 loads the web player and shows your libraries with fetched artwork
  • Playing a file works, and seeking to a random point works (not just playing from the start)
  • A second user account can log in and see the same libraries with their own separate watch history
  • If you enabled hardware transcoding: playing the same file from two devices at once doesn't stall the first, and Dashboard → Dashboard's active-sessions view shows the transcode as hardware-accelerated, not software
  • Failure test: restart the container (docker compose restart). Confirm it comes back up with your libraries, users, and watch history all intact (confirms /config is actually where the state lives, not somewhere ephemeral)

Secure it

  • What's exposed: the web UI/API on port 8096 (or 443 if you've put a reverse proxy with HTTPS in front of it -- see the Docker Compose homelab stack guide). This guide assumes LAN-only access.
  • Remote access: if you want to watch away from home, don't port-forward 8096 directly to the internet -- Jellyfin has had real vulnerabilities in its history like any actively-developed exposed service. Use a VPN (WireGuard, Tailscale) back into your home network instead, so Jellyfin is never directly internet-facing; a reverse proxy alone doesn't fix this, it just moves where the exposure is.
  • Default credentials: none -- the admin account you create during the Step 2 wizard is the only one, with the password you set then.
  • Least privilege: the container doesn't need to run as root -- the official image supports PUID/PGID environment variables to run as a specific host user; use them if you want the container's file access scoped to a non-root user rather than defaulting to root.
  • Update strategy: watch Jellyfin's release notes for security-relevant fixes; docker compose pull && docker compose up -d after bumping the pinned version in your compose file.
  • The failure that hurts most: losing your /config volume (library metadata, users, watch history, all your organizational work) -- not the media files themselves, which are presumably reproducible or already backed up elsewhere. See Back it up and maintain it.

Back it up and maintain it

What matters: /config (mapped to ./config in this guide) -- this is where users, libraries, watch history, and all settings live. It's small and worth backing up regularly. /media is your actual content -- back it up if it's irreplaceable, but it's a separate concern from Jellyfin itself (Jellyfin doesn't modify your media files, it only reads them). /cache needs no backup at all -- it's disposable, rebuilt automatically.

Update cadence: check Jellyfin's release notes periodically; bump the pinned version in your compose file deliberately rather than tracking :latest silently (Step 1).

What to monitor: disk space on whatever volume holds /media (obvious, but the most common actual failure mode for a media server is simply running out of room), and active transcoding sessions if playback quality complaints come in -- often a resource contention issue, not a Jellyfin bug.

Troubleshooting

Logs: docker logs jellyfin, or Dashboard → Logs in the web UI.

Symptom Likely cause Diagnostic Fix
A movie or show doesn't get matched correctly Naming doesn't match the pattern in Before you start Compare the folder/file name against the Title (Year) / Show/Season NN/Show SNNENN pattern Rename to match, or match manually: item → Edit metadata → Identify, search by title
Playback stutters or fails with a second simultaneous stream Software transcoding maxing out CPU with no hardware acceleration enabled Dashboard → Dashboard, check active sessions' transcode type Enable hardware transcoding (Step 4), or confirm the file can direct-play on that client instead
Hardware transcoding doesn't activate even though it's enabled The container can't actually see the render device -- device mapping or group ID wrong docker exec jellyfin ls -la /dev/dri; confirm the group ID matches getent group render on the host Fix the devices:/group_add: mapping in the compose file, recreate the container
Remote/external playback fails even though local playback works Port not reachable from outside (correctly, if you followed Secure it), or a VPN/proxy misconfiguration Confirm you're connecting through your VPN, not attempting direct external access This is expected if you never opened external access -- connect via VPN as designed
Library scan finds files but never finishes / hangs A very large library on first scan, or a permissions issue on the media mount docker logs jellyfin during the scan for specific file errors Large libraries just take time on first scan; permission errors need the container's user (or PUID/PGID, see Secure it) to actually have read access to the media path
Restarting the container loses settings/users /config isn't actually persisted -- a volume mapping mistake Check the compose file's volume line for /config actually points at a real host path, not left as an anonymous volume Fix the volume mapping; existing lost config isn't recoverable without a backup

Undo

docker compose down in the Jellyfin directory stops and removes the container. Delete the ./config and ./cache directories to remove all Jellyfin state; your /media files are untouched either way, since Jellyfin only ever reads them.

Go further

  • Managing a Homelab with Docker Compose, Portainer, and Watchtower -- for running Jellyfin alongside other services with a consistent structure
  • Look into Sonarr/Radarr (the "*arr stack") if you want library acquisition automated as well as playback -- a separate, more involved setup this guide doesn't cover
  • Put a reverse proxy with real HTTPS in front of Jellyfin instead of the bare :8096 port, if you're also setting up other web-facing services

Resources

Official documentation:

Source and releases:

Community:

Go deeper:

Related DaemonPress projects:


Last verified: 2026-09-21, checked against official Jellyfin documentation (see Resources above).

Changelog:

  • 2026-09-21 -- Full rewrite: corrected the version from stale 10.x to the confirmed current 12.1 (a two-major-version gap, not a number typo -- the whole hardware-transcoding section was re-verified against Jellyfin's own Intel Quick Sync docs rather than carried forward, and turned out to need real correction: the specific render device path, group_add, and render-group-ID lookup were missing from the original entirely). Pinned the image tag to 12.1 instead of :latest, with a stated reason. Added a direct-play-vs-transcode diagram, a "don't build this if," explicit /config vs /media vs /cache backup guidance, Secure it, Undo, and a full Resources section. Expanded Troubleshooting from 3 to 6 entries. Moved from homelab to self-hosted-services.
  • 2026-09-21 (earlier) -- Original version published, targeting a stale Jellyfin version with incomplete hardware-transcoding steps.