SSD Nodes Learn
Guides Matt ConnorBy Matt Connor

Jellyfin on a VPS: stream your own media

Run Jellyfin in Docker on a VPS to stream your own library: block storage, media permissions, direct play vs CPU transcoding, and safe remote access.

What you are building

A Jellyfin media server on a VPS: one container, three volumes, and a block-storage disk holding your films and shows, reachable from any browser or Jellyfin app. The install is a fifteen-line compose file. Everything that goes wrong afterwards comes from two places — file permissions the container cannot read, and asking a GPU-less VPS to transcode video it has no business transcoding. This guide spends most of its length on those two, because that is where the support tickets live.

Jellyfin is free and fully open source, with no account, no paywalled features, and no telemetry — the reason it lands on nearly every list of things worth self-hosting in 2026. It plays media you own. It ships no content, and this guide is not about acquiring any.

The transcoding reality, before you rent anything

Read this first, because it changes what you buy. A media server does one of two things when you press play. Direct play streams the file as-is: the VPS reads bytes off disk and pushes them down the wire, costing almost no CPU. Transcoding re-encodes the video on the fly — new resolution, new codec, or subtitles burned in — and that is pure CPU work.

A typical VPS has no GPU. So every transcode runs on the CPU with libx264/libx265, and software encoding is expensive. A single 1080p H.264 transcode can saturate several shared vCPUs; a 4K or HEVC transcode usually cannot keep up with real time at all, so playback stalls and buffers forever. Hardware transcoding — the thing that makes this cheap on a home box with an Intel iGPU or an Nvidia card — is simply not available to you unless your provider rents GPU instances.

The whole strategy on a VPS is therefore: avoid transcoding. Keep your library in codecs your clients play natively — H.264 video, AAC or AC3 audio, in an MP4 or MKV container — and pick client apps that direct-play: the native Jellyfin apps for Android TV, iOS and Roku, plus Infuse, Kodi, and the desktop Jellyfin Media Player. Do that and the VPS never touches ffmpeg, and a modest 2 vCPU box streams to several people at once. Plan to transcode and you need a much bigger, more expensive box, and even then 4K is a bad bet.

Do the bandwidth math too, because it is the other surprise. Direct play sends the file at its own bitrate. A compressed 1080p file runs 8-12 Mbps; a 1080p Blu-ray remux 20-30 Mbps; 4K HDR 40-80 Mbps. Three people direct-playing 10 Mbps files is 30 Mbps of sustained upload out of your VPS. Check two numbers on your plan: the port speed (can it push 30 Mbps upstream?) and the monthly transfer cap. One two-hour 10 Mbps film is about 9 GB out, so a metered 1 TB/month allowance is a bit over a hundred such films a month — three or four a day — and a household watching 4K, at four to eight times the bitrate, drains it far faster.

Prerequisites

  • A fresh Ubuntu 24.04 KVM VPS with root or sudo, and Docker plus the Compose plugin installed.
  • A block-storage volume for the media, sized for your library (see sizing below). The small root disk that ships with a VPS is not where your films go.
  • A domain name if you want public HTTPS access, or a WireGuard VPN on the same VPS if you would rather keep the whole thing private.
  • Media you are legally entitled to stream — your own rips, your own recordings, files you own.

Mount the block storage first

Attach the volume in your provider's panel, then find it and mount it. Get the device name from lsblk — it will be something like /dev/sdb or /dev/vdb, never the root disk.

lsblk
sudo mkfs.ext4 /dev/sdb          # ONLY on a new, empty volume — this ERASES it
sudo mkdir -p /mnt/media
sudo blkid /dev/sdb              # copy the UUID shown for this device

Mount it by UUID, not by /dev/sdb, because device letters reorder across reboots and you can end up formatting or mounting the wrong disk. Add one line to /etc/fstab:

UUID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx  /mnt/media  ext4  defaults,nofail  0  2
sudo mount -a
df -h /mnt/media

nofail matters: without it, if the block volume is ever detached the box refuses to boot and drops to an emergency shell. The single biggest mistake here is running mkfs.ext4 on a volume that already holds data — it wipes it. Format new volumes only; if the disk already has your library on it, skip straight to the fstab line.

Lay out the media the way Jellyfin expects

Jellyfin matches metadata by folder and file names. Get the layout wrong and films arrive as untitled files with no poster, or an episode matches the wrong series. There are exactly three rules: each movie lives in its own Name (Year) folder with a matching filename; season folders are named Season 01, not S01; episode files use S01E01; and specials go in Season 00.

/mnt/media
├── Movies
│   ├── Blade Runner (1982)
│   │   └── Blade Runner (1982).mkv
│   └── Arrival (2016)
│       └── Arrival (2016).mkv
└── Shows
    └── Severance (2022)
        ├── Season 01
        │   ├── Severance - S01E01.mkv
        │   └── Severance - S01E02.mkv
        └── Season 00
            └── Severance - The Lexington Letter.mkv

The (Year) on movies is not decoration — it disambiguates remakes so the matcher grabs the right title. Keep Movies and Shows as separate top-level folders because each becomes a Jellyfin library of a specific content type, and mixing them confuses the metadata provider.

Permissions: the number-one reason libraries come up empty

Here is the misconception that costs people an evening. The official jellyfin/jellyfin image does not honour PUID/PGID environment variables — those belong to the LinuxServer.io image (lscr.io/linuxserver/jellyfin). On the official image you control the user with the user: key in compose, and if you omit it the container runs as root. Whichever you use, the rule is the same: the uid/gid the container runs as must be able to read and traverse every media directory.

We will run as uid/gid 1000, the first non-root user on a stock Ubuntu box. Confirm yours and set ownership:

id                                  # confirm your user is uid=1000 gid=1000
sudo chown -R 1000:1000 /mnt/media
sudo find /mnt/media -type d -exec chmod 755 {} \;
sudo find /mnt/media -type f -exec chmod 644 {} \;
mkdir -p ~/jellyfin/config ~/jellyfin/cache
sudo chown -R 1000:1000 ~/jellyfin

Directories need the execute bit (the x in 755), not just read — without it the container cannot enter the folder even though it can list the name. The trap that empties a whole library is the parent: if the container's uid cannot traverse the mount itself, it never reaches /media/Movies or /media/Shows, and every library comes up empty at once with Access to the path ... is denied in the log. Any single media folder it cannot read is logged and skipped, so a batch of files copied in as root silently vanishes from the library. This is why we chown recursively and set the execute bit on every directory rather than fixing one folder.

The docker-compose file

services:
  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: jellyfin
    user: "1000:1000"
    restart: unless-stopped
    ports:
      - "127.0.0.1:8096:8096"
    volumes:
      - ./config:/config
      - ./cache:/cache
      - /mnt/media:/media:ro
    environment:
      - JELLYFIN_PublishedServerUrl=https://jellyfin.example.com

Line by line: user: "1000:1000" is what actually sets file permissions, matching the ownership above. /config holds the whole server — accounts, libraries, metadata, watch state — so it must be writable and it is the thing you back up. /cache is throwaway working space. The media mount is :ro (read-only) on purpose: Jellyfin defaults to storing artwork and metadata under /config, so it never needs to write to your library, and read-only protects your files from an accidental delete or a bad plugin. The port is bound to 127.0.0.1 deliberately — Jellyfin's web login is plain HTTP, so we never publish 8096 to the public internet. JELLYFIN_PublishedServerUrl is what the mobile and TV apps autodiscover, so set it to whatever public or VPN address you will actually use.

Bring it up from the compose directory:

docker compose up -d
docker logs -f jellyfin

First run: the setup wizard and your libraries

Because the port is bound to localhost, reach the wizard through an SSH tunnel from your laptop rather than opening a firewall hole:

ssh -L 8096:127.0.0.1:8096 you@your-vps-ip

Now browse to http://localhost:8096. The wizard walks you through language, then creating an admin user with a strong password — this account is your server, so do not reuse a throwaway password. Add your first library: choose content type Movies, point it at /media/Movies (the path inside the container, not the host path), and repeat with Shows at /media/Shows. Finish, and Jellyfin scans. A correct result is posters and titles filling in within a minute or two for a small library. Add or edit libraries later under Dashboard → Libraries, and force a rescan with Scan All Libraries.

If you rely on any transcoding at all, open Dashboard → Playback → Transcoding and set the transcode temp path to /cache/transcodes so the churn lands on the cache volume instead of bloating /config. Leave hardware acceleration set to None — there is no GPU to accelerate with.

Remote access: TLS reverse proxy, or keep it on the VPN

You have two safe ways to reach Jellyfin from outside, and one unsafe way to avoid. The unsafe way is publishing port 8096 straight to the internet: the login travels in cleartext and the port gets brute-forced within hours.

Option A — TLS reverse proxy. Put Jellyfin on a subdomain behind Traefik with automatic TLS for your Docker apps, or behind nginx with a Let's Encrypt certificate issued by Certbot. Jellyfin uses WebSockets for real-time updates, so the proxy must forward the upgrade headers. Traefik does this automatically; nginx needs them spelled out, and needs HTTP/1.1 to the upstream or the upgrade never happens:

location / {
    proxy_pass http://127.0.0.1:8096;
    proxy_http_version 1.1;
    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_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

Set JELLYFIN_PublishedServerUrl to the https:// address so apps autodiscover it, and add fail2ban to slow brute-force attempts against the login.

Option B — keep it private on a VPN. Do not publish 8096 at all; reach Jellyfin only through a WireGuard tunnel terminating on the same box. For a household this is the simplest secure choice — no certificate, no public exposure, no brute-force surface. Bind the container to the tunnel address or localhost and connect over the VPN. See the WireGuard VPN setup for a private VPS for the tunnel itself.

Storage sizing and backups

Budget by quality, not file count. Compressed 1080p films run 4-15 GB each; a 1080p remux 20-40 GB; a season of 1080p TV 15-40 GB; 4K anything is 40-100 GB per film. A library of a few hundred films plus some shows wants a 2-4 TB volume, and it is cheaper to over-provision the block volume once than to migrate later.

/config is the entire server state, so it is the one thing you must back up. Snapshot or stop-and-tar it and keep the copy off the box:

docker compose down
sudo tar czf jellyfin-config-$(date +%F).tgz -C ~/jellyfin config
docker compose up -d

/cache and the transcode folder are disposable. The media on /mnt/media you back up separately or accept as re-rippable — most people do the latter given the size. Upgrades are docker compose pull && docker compose up -d; pin a major version tag if you want to control jumps, and skim the Jellyfin release notes before a major bump because library schema migrations happen on major versions.

Failure modes, with the strings you will see

Library is empty after a scan. The log at Dashboard → Logs (or ~/jellyfin/config/log/log_*.log) shows:

System.UnauthorizedAccessException: Access to the path '/media/Movies' is denied.

The container's uid cannot read that path. Cause: media owned by root or a uid other than your user: value, a directory missing its execute bit, or the parent mount itself not traversable by that uid. Fix: chown -R 1000:1000 /mnt/media, directories 755, files 644, then rescan.

Playback pins the CPU and buffers. docker stats jellyfin shows CPU near 100% times your core count, and Dashboard → Playback lists the session as Transcode with a speed below 1.0x. The client is not direct-playing, so the VPS is CPU-transcoding slower than real time and losing. Cause: an unsupported codec or container, subtitle burn-in, or HDR tone-mapping. Fix: switch to a direct-play client, keep sources in H.264/AAC, use text subtitles (SRT) rather than image subtitles (PGS/VOBSUB) which force a burn-in, and keep 4K HDR off a CPU-only box entirely.

"No compatible streams are available." The full message is usually "This client isn't compatible with the media and the server isn't sending a compatible media format." The client rejected the source and the fallback transcode also failed to start. Cause: a broken ffmpeg command, an unreadable file, or the user's profile blocking video conversion. Fix: read the ffmpeg line in Dashboard → Logs, confirm the file plays at all, check the user's playback permissions if you depend on transcoding, and try a second client to rule out browser codec quirks.

Films have no poster or the wrong one. Metadata did not match. Cause: a movie not in its own Name (Year) folder, a season folder named S01 instead of Season 01, episodes not in S01E01 form, or a missing year. Fix: rename to the layout above, then Refresh metadata → Replace all, or use Identify on a single item to pin the correct TMDB/TVDB entry.

FAQ

Can a VPS transcode video without a GPU?

Yes, but on the CPU only, and it is expensive. A single 1080p software transcode can saturate several vCPUs, and 4K or HEVC usually cannot keep up with real time, so playback buffers. The winning move is to avoid transcoding: keep your library in H.264/AAC and use client apps that direct-play, so the VPS just streams bytes. Rent a GPU instance only if you genuinely need on-the-fly transcoding.

Why is my Jellyfin library empty after a scan?

Almost always permissions. The official jellyfin/jellyfin image runs as whatever user: you set (or root), and if the files are not readable by that uid the scan logs Access to the path ... is denied and skips them. Fix ownership with chown -R 1000:1000 /mnt/media, give directories the execute bit (755), and rescan — and check the parent too, because if the container's uid cannot traverse /mnt/media itself it never reaches the library folders and everything comes up empty. The second most common cause is a folder layout that does not match what Jellyfin expects.

How do I access Jellyfin remotely and safely?

Two good options. Put it behind a TLS reverse proxy on a subdomain so the login and stream are encrypted, and add fail2ban — never expose plain port 8096, which sends your password in cleartext. Or keep it entirely private and reach it only over a VPN, the simplest safe choice for a household. Set JELLYFIN_PublishedServerUrl to the public address so the apps autodiscover correctly.

How much disk and bandwidth does a Jellyfin VPS need?

Disk depends on quality: budget 4-15 GB per compressed 1080p film, 20-40 GB per remux, and 40-100 GB for 4K, so most libraries want a 2-4 TB block volume. Bandwidth is set by the direct-play bitrate — 8-12 Mbps per 1080p stream, far more for 4K — so confirm your port speed handles the number of simultaneous viewers and watch the monthly transfer cap. Add CPU headroom if you plan to transcode; prioritise bandwidth over cores if you plan to direct-play.

Jellyfin itself is free, open-source software and running it is entirely legal. What matters is the content: stream only media you own or are licensed to hold — your own disc rips, recordings, or files you have the right to. Jellyfin ships no media and provides no way to obtain any; it is a player for a library you already own.

#jellyfin#media-server#docker#self-hosting#transcoding