All articles
TutorialsMar 15, 2026 · 23 min read

Self-Host Jellyfin on a VPS for Private Media Streaming

Self-Host Jellyfin on a VPS for Private Media Streaming

Streaming services keep changing. Shows leave the catalog the day you finally sit down to watch them, prices creep up every six months, and the same movie is split across three different platforms. If you already have a media collection, Jellyfin lets you stream it the way Netflix streams theirs: a clean web UI, native apps for every device, user profiles, watch progress, and subtitle support, all running on a server you own.

This guide walks through a production-ready Jellyfin install on a VPS using Docker, with Caddy in front for automatic HTTPS and proper WebSocket forwarding. It covers the gotchas that bite first-time self-hosters: transcoding on cheap hardware, locking down public signups, and getting Chromecast and DLNA to behave behind a reverse proxy.

Disk size matters far more than CPU for Jellyfin. A 1080p movie is 4 to 15 GB. A 4K HDR remux can be 60 GB. Plan storage before you plan anything else.

TL;DR

  • Install Docker and Docker Compose on a fresh VPS
  • Point a subdomain like media.example.com at your server
  • Run Jellyfin and Caddy with a single docker-compose.yml
  • Mount three volumes: /config, /cache, and /media
  • Caddy handles HTTPS and WebSocket upgrades automatically
  • Disable public signups after creating the owner account
  • Prefer direct play, leave heavy transcoding to clients

Total time: about 20 minutes.

What You Need

  • A VPS with at least 2 GB RAM (4 GB if you plan to transcode at all)
  • Plenty of disk space - at least 250 GB for a starter library, ideally a storage VPS for anything serious
  • A domain you can add DNS records to
  • Ports 80 and 443 open to the internet (required by Let's Encrypt)
  • Root or sudo access

The library size is what makes Jellyfin different from most self-hosted apps. Docker, Caddy, and Jellyfin itself together use under a gigabyte. Your media is what fills the disk.

Step 1: Point a Subdomain at Your VPS

In your DNS provider, create an A record:

media.example.com → YOUR_VPS_IPV4

Add an AAAA record for IPv6 if you use it. Check propagation:

dig +short media.example.com

The output should match your VPS IP. Caddy needs DNS to resolve before it can issue a Let's Encrypt certificate.

Step 2: Install Docker and Docker Compose

On a fresh Ubuntu 22.04 or 24.04 server:

sudo apt update sudo apt install -y ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Confirm it works:

docker --version docker compose version

Step 3: Open the Firewall

sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable

Caddy needs port 80 for the ACME HTTP challenge and 443 for HTTPS. Do not expose port 8096 directly. Caddy is the only thing that should answer on the public internet.

Step 4: Create the Project Directory

sudo mkdir -p /opt/jellyfin cd /opt/jellyfin sudo mkdir -p config cache media caddy-data caddy-config

The layout matters because Jellyfin keeps state in three different places:

  • config/ holds the database, settings, user accounts, and metadata
  • cache/ is scratch space for transcoded segments and image thumbnails
  • media/ is where your library lives

If your VPS has a separate large disk mounted somewhere like /mnt/storage, point media/ at it instead:

sudo mkdir -p /mnt/storage/jellyfin-media sudo ln -s /mnt/storage/jellyfin-media /opt/jellyfin/media
Library scan paths must be inside the directory you mount into the container. If you tell Jellyfin to scan `/data/Movies` but only mounted `/data/Series`, the scan finds nothing and you spend half an hour debugging permissions that were never the problem.

Step 5: Write the Compose File

Create /opt/jellyfin/docker-compose.yml:

services: jellyfin: image: jellyfin/jellyfin:latest container_name: jellyfin restart: unless-stopped user: "1000:1000" environment: JELLYFIN_PublishedServerUrl: "https://media.example.com" TZ: "Europe/Berlin" volumes: - ./config:/config - ./cache:/cache - ./media:/media:ro networks: - jellynet caddy: image: caddy:2 container_name: jellyfin-caddy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy-data:/data - ./caddy-config:/config networks: - jellynet networks: jellynet:

A few notes:

  • The user: line runs Jellyfin as UID 1000, which is usually the first non-root user on Ubuntu. Match this to whoever owns your media files. If id -u returns something different, change it.
  • Mounting media as :ro (read-only) is a small but real safety win. Jellyfin only needs to read your files, not delete them.
  • The internal port 8096 stays inside the Docker network. Caddy reaches it over jellynet.
  • JELLYFIN_PublishedServerUrl lets Jellyfin advertise its public URL correctly to clients during discovery.

A note on network_mode: host

Some Jellyfin guides use network_mode: host so the container can broadcast on the local network for DLNA discovery. That makes sense on a home server sitting next to your TV, but on a VPS in a datacenter there is no LAN to broadcast on, and host mode disables Docker's network isolation. Leave host networking off and use the published-URL setup above. Clients connect over HTTPS instead of DLNA, which works from any network anyway.

Step 6: Write the Caddyfile

Create /opt/jellyfin/Caddyfile:

media.example.com { encode zstd gzip # Big upload limits for posters, subtitles, and metadata edits request_body { max_size 100MB } reverse_proxy jellyfin:8096 { # Pass real client info to Jellyfin header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Host {host} # WebSocket support for live updates and remote control header_up Upgrade {http.request.header.upgrade} header_up Connection "upgrade" } }

Caddy v2 actually upgrades WebSocket connections out of the box for reverse_proxy, but the explicit Upgrade and Connection headers make the intent obvious and survive copy-pasting into other proxies. The X-Forwarded-Proto header is the one that matters most: without it, Jellyfin generates broken http:// URLs in API responses and clients refuse to connect over mixed-content rules.

Step 7: Start the Stack

cd /opt/jellyfin sudo docker compose up -d sudo docker compose logs -f

Wait until the Caddy logs show the Let's Encrypt certificate was issued, then open https://media.example.com in a browser. You should see the Jellyfin first-run wizard.

Step 8: First-Run Wizard

The wizard walks through:

  1. Display language - pick yours.
  2. Owner account - create the admin user. Use a strong password. This account can manage everything.
  3. Add a media library - choose a content type (Movies, TV Shows, Music, Books) and point it at a folder under /media inside the container. Filenames matter: Jellyfin and the underlying TheTVDB and TMDb scrapers expect Show Name (Year)/Season 01/Show Name - S01E01.mkv and Movie Name (Year)/Movie Name (Year).mkv style. Read the naming guide before moving files.
  4. Metadata language - usually the same as your display language.
  5. Remote access - leave on, since the whole point is reaching it from outside your network.

The wizard will trigger an initial library scan. For a multi-terabyte library this can take an hour or more. Let it finish before tinkering with settings.

Step 9: Restrict Public Signups

Jellyfin does not enable public signups by default, which is good. But it does have a "Quick Connect" option and forgot-password reset behavior worth tightening.

In the dashboard, go to Administration -> Dashboard -> General:

  • Confirm Allow remote connections to this server is on.
  • Leave Quick Connect disabled unless you actively use it. It is a code-based pairing flow that is fine for local use but unnecessary attack surface on a public server.

Then go to Users -> New User:

  • For everyone you add, uncheck "Allow this user to manage the server" unless they really need admin.
  • Set library access per user. Family members do not need to see your test imports folder.

There is no public signup form to disable - new users have to be created by an admin from the dashboard. That is by design and you should keep it that way.

Step 10: Hardware Transcoding (or Why You Should Skip It)

This is the part that surprises people. Jellyfin can transcode video on the fly to match a client's bitrate or codec, but software transcoding is brutally CPU-heavy. A 1080p H.264 transcode pegs four CPU cores. A 4K HEVC transcode pegs eight. Cheap VPS plans do not have a GPU, and their CPUs are shared, which means a transcode will both tank quality and annoy your hosting provider.

The good fix is to avoid transcoding entirely:

  • Install the native Jellyfin app on the client (Android TV, iOS, Apple TV, Roku, Fire TV all have one).
  • Native apps decode video locally and stream the file as-is. This is direct play.
  • Direct play uses almost no CPU on the server. It is just file delivery over HTTP.

For most home libraries with H.264 and HEVC content, native apps direct-play everything. The only reason you would need server-side transcoding is web browsers playing exotic codecs, and even there it is rare.

If you do need transcoding, do it on a dedicated server with a real GPU (Intel iGPU with QuickSync is the cheapest path) or an Nvidia card with NVENC. A VPS is the wrong shape for that workload.

If a stream stutters or buffers, check the dashboard's "Active Devices" view first. If it says "Transcoding" instead of "Direct Play," that is your problem. Switch to a native client or re-encode the file to a friendlier codec.

Step 11: Install Clients

Jellyfin has official apps for every platform that matters:

  • Android / Android TV / Fire TV: Jellyfin for Android
  • iOS / iPadOS / Apple TV: Swiftfin (third-party but officially blessed)
  • Windows / macOS / Linux: Jellyfin Media Player
  • Web browser: built into the server, no install needed
  • Roku, LG WebOS, Samsung Tizen: check your TV's app store

In each app, set the server URL to https://media.example.com and log in with your Jellyfin account. The connection sticks.

Optional: Put Jellyfin Behind a VPN

There is a strong argument for not exposing your media library to the open internet at all. Bots scan for Jellyfin instances, and an open server has been used as an unwitting indexer for piracy in the past. If you only stream to your own devices, putting Jellyfin behind Tailscale gives you full bandwidth, no public attack surface, and works from anywhere.

The setup is the same as the public version. Just skip the firewall openings for 80 and 443, and reach media.example.com over your tailnet instead. WireGuard works equally well if you prefer.

A VPN-side library has another nice property: full bandwidth. Public traffic on a VPS is often metered or shaped during peak hours. A WireGuard tunnel between your devices and your server keeps the throughput high and the latency low.

Troubleshooting

Caddy returns a 502 on first boot. Jellyfin takes 20 to 60 seconds to initialize the database on the first run, and Caddy hits it before it's ready. Wait a minute and reload. If it persists, check docker compose logs jellyfin - you are usually waiting on a slow library scan.

The library shows zero items after a scan. Almost always a path or naming problem. Confirm the folder you pointed Jellyfin at is actually inside the /media mount, and that filenames follow the Jellyfin naming guide. SSH in and run docker exec -it jellyfin ls /media/Movies to see what the container actually sees.

Audio gets out of sync during playback. That is a transcoding artifact. Check the active device - if it says "Transcoding (Audio)" you can usually fix it by switching to a native client that supports the source audio codec directly. If the source itself has drift, re-mux it with ffmpeg -i input.mkv -c copy output.mkv.

Cannot cast to Chromecast over the reverse proxy. Chromecast needs a fully valid HTTPS certificate (Let's Encrypt is fine), X-Forwarded-Proto set to https, and proper WebSocket upgrade headers. The Caddyfile above has all three. Cast also fails if the device is on a different network than the controlling phone, since it pulls the stream itself - both need to reach media.example.com.

WebSocket connection keeps dropping. Make sure no upstream proxy (Cloudflare, another reverse proxy, a load balancer) is buffering or terminating the connection. If you front Caddy with Cloudflare, enable WebSockets in the Cloudflare dashboard and consider setting that hostname to "DNS only" rather than proxied.

Going Further

  • Add Jellyseerr as a request portal. Family members log in with their Jellyfin account and request new content; you approve it from one dashboard. Pairs naturally with Sonarr, Radarr, and friends.
  • Move transcoding off the VPS. If you genuinely need on-the-fly transcoding, run Jellyfin on a dedicated server with an Intel iGPU or Nvidia GPU and use the VPS only as a Caddy front door over a WireGuard tunnel. Best of both worlds.
  • Use a VPN-side library. Pair Jellyfin with Tailscale so the public internet never sees your server. Streaming over a tailnet is fast, secure, and avoids every bot scan in existence.
  • Schedule library scans for off-hours. In Dashboard -> Scheduled Tasks, push the metadata refresh to 3 AM. Big libraries hammer the disk during a scan.
  • Back up the config/ folder daily. Losing your library scan results is annoying but recoverable. Losing user accounts, watch progress, and playlists is worse. The whole config/ directory is a few hundred megabytes at most - sync it to object storage with restic or rclone.

That's it. Self-hosted Jellyfin gives you the convenience of a streaming service without the catalog roulette and without your viewing habits feeding someone else's recommendation engine.


A media server lives or dies by its disk. Our storage VPS plans ship with terabytes of HDD space and the same NVMe boot disk you get on the regular Linux plans. See the options.