SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor

Jellyfin for VPS: stream your own films without wahala

Set up Jellyfin with Docker on a VPS, mount block storage, fix media permissions, and avoid CPU transcoding when direct play fit stream your library cleanly.

Wetin you dey build

Jellyfin media server for VPS: one container, three volumes, and block-storage disk wey hold your films and shows, so you fit reach am from any browser or Jellyfin app. The install na fifteen-line compose file. Everything wey fit go wrong afterwards dey come from two places: file permissions wey container no fit read, and asking GPU-less VPS to transcode video wey e no get business transcoding. This guide spend most of the length on these two, because na there support tickets dey come from.

Jellyfin free and fully open source. E no need account, e get no paywalled features, and e get no telemetry. Na why e dey almost every list of things wey worth self-hosting for 2026. E dey play media wey you own. E no ship content, and this guide no dey talk about acquiring any.

The reality of transcoding, before you rent anything

Read this one first, because e go change wetin you go buy. Media server dey do one of two things when you press play. Direct play dey stream the file as e be: VPS dey read bytes from disk and push dem through network, so e dey use almost no CPU. Transcoding dey re-encode video as e dey play, whether na new resolution, new codec, or subtitles wey burn inside video, and na pure CPU work be that.

Typical VPS no get GPU. So every video transcode dey run for CPU with libx264/libx265, and software encoding dey cost plenty resources. One 1080p H.264 transcode fit use up several shared vCPUs; 4K or HEVC transcode usually no fit keep up with real time at all, so playback go stop and buffer forever. Hardware transcoding, wey make this work cheap for home box with Intel iGPU or Nvidia card, no dey available unless your provider rent GPU instances.

So the main strategy for VPS na: avoid transcoding. Keep your library for codecs wey your clients fit play natively: H.264 video, AAC or AC3 audio, inside MP4 or MKV container. Choose client apps wey support direct play: native Jellyfin apps for Android TV, iOS and Roku, plus Infuse, Kodi, and desktop Jellyfin Media Player. If you do am like this, VPS no go need run ffmpeg transcode during playback, and modest 2 vCPU box fit stream to several people at once. If you plan to transcode, you need much bigger and more expensive box, and even then 4K no be good choice.

Do bandwidth calculation too, because na another thing wey fit surprise you. Direct play dey send file at the file bitrate. Compressed 1080p file dey use 8-12 Mbps; 1080p Blu-ray remux dey use 20-30 Mbps; 4K HDR dey use 40-80 Mbps. If three people dey direct-play 10 Mbps files, VPS upload go send 30 Mbps continuously. Check two numbers for your plan: port speed (e fit push 30 Mbps upstream?) and monthly transfer cap. One two-hour 10 Mbps film na about 9 GB going out, so metered 1 TB/month allowance fit cover a little more than hundred such films per month, or three or four per day. Household wey dey watch 4K, with bitrate four to eight times higher, go use the allowance much faster. Count anything else wey dey send traffic out from the same box against that same budget, including self-hosted RustDesk relay, wey carries complete remote desktop session whenever two peers no fit connect directly.

Prerequisites

  • Ubuntu 24.04 KVM VPS wey fresh, with root or sudo, plus Docker and Compose plugin installed.
  • Block-storage volume for the media, wey size match your library (see sizing below). The small root disk wey come with VPS no be where your films go dey.
  • Domain name if you want public HTTPS access, or WireGuard VPN for the same VPS if you prefer make everything remain private.
  • Media wey you get legal right to stream, your own rips, your own recordings, and files wey belong to you.

Block storage first mount kar

Volume attach for your provider panel, then find am and mount am. Get the device name from lsblk. E go be something like /dev/sdb or /dev/vdb, but e must never be 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 am with UUID, no be with /dev/sdb. Device letters fit change after reboot, and you fit 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 dey important. If you no put am, and dem detach the block volume, the box no go boot. E go enter emergency shell. The biggest mistake for here na to run mkfs.ext4 on volume wey already get data, because e go wipe everything. Format only new volumes. If the disk already get your library, skip go the fstab line.

Arrange your media the way Jellyfin expect

Jellyfin dey match metadata based on folder and file names. If the layout no correct, films go show as files without title or poster, or episode fit match the wrong series. Four naming rules dey do almost all the work: each movie dey inside its own folder with matching filename, named Name (Year) (Jellyfin treat the year as optional, but e make matching much more reliable); season folders dey use Season 01, no be S01; episode files dey use S01E01; and specials dey go inside 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) for movies no be decoration. E dey help separate remakes so the matcher fit choose the correct title. Keep Movies and Shows as separate top-level folders because each one go become Jellyfin library for a particular content type, and mixing dem fit confuse the metadata provider. Jellyfin fit index a third folder for photos too, but the experience no reach wetin purpose-built photo server dey offer. So if your albums matter, give dem their own box wey dey run PhotoPrism or Immich, and make this one handle only film and TV.

Permissions: number-one reason libraries dey come up empty

Na this wrong belief dey make people waste one evening. The official jellyfin/jellyfin image no honour PUID/PGID environment variables; na LinuxServer.io image (lscr.io/linuxserver/jellyfin) get dem. For the official image, you control the user with user: key for compose. If you no include am, the container go run as root. Any one wey you use, the rule still be the same: the uid/gid wey the container run as must fit read and traverse every media directory.

We go run as uid/gid 1000, wey be the first non-root user for standard Ubuntu box. Confirm your own 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 execute bit (the x for 755), no be read only. Without am, the container no fit enter the folder, even though e fit list the name. The trap wey dey empty complete library na the parent directory: if the container's uid no fit traverse the mount itself, e no go reach /media/Movies or /media/Shows, and every library go come up empty at once with Access to the path ... is denied for the log. If e no fit read any single media folder, e go log am and skip am. So files wey you copy in as root fit silently disappear from the library. Na why we dey chown recursively and set execute bit for every directory, instead of fixing only one folder.

The docker-compose file

services:
  jellyfin:
    image: jellyfin/jellyfin:12
    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

One by one: jellyfin/jellyfin:12 dey follow current 12.x release line (12.2 as of 2026-10-10). user: "1000:1000" set the uid/gid wey Jellyfin process dey run with. E no change ownership or mode of any file, because Docker dey keep the host uid/gid for mounted directories. Na why chown above suppose match am. /config hold the complete server, accounts, libraries, metadata, and watch state. E must dey writable, and na this one you go back up. /cache na temporary working space wey you fit throw away. The media mount na :ro (read-only) deliberately. Jellyfin normally dey store artwork and metadata under /config, so e no ever need write to your library. Read-only mode also protect your files from accidental deletion or bad plugin. The port dey bound to 127.0.0.1 deliberately. Jellyfin web login na plain HTTP, so we no publish 8096 to public internet. JELLYFIN_PublishedServerUrl na the address wey server advertise for local autodiscovery. Na LAN UDP broadcast, so clients across internet no go see am. Dem go simply use the URL wey you type inside the app. Set am to the address wey clients suppose receive, and expect to enter that URL by hand for remote devices.

Bring am up from the compose directory:

docker compose up -d
docker logs -f jellyfin

First run: setup wizard and your libraries

Because the port dey bound to localhost, use SSH tunnel from your laptop reach the wizard instead of opening firewall hole:

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

Now open http://localhost:8096 for browser. The wizard go guide you through language, then create an admin user with strong password. This account na for your server, so no use throwaway password again. Add your first library: choose content type Movies, point am to /media/Movies (the path inside the container, no be the host path), then repeat with Shows for /media/Shows. Finish, and Jellyfin go scan am. Correct result na posters and titles go show within one or two minutes for small library. Later, you fit add or edit libraries under Dashboard → Libraries, and force another scan with Scan All Libraries. Na these posters also make alternative frontend worth checking after the basics work, because Halcyon rebuilds the same library as a 90s video store you fit waka through from the artwork wey Jellyfin don already fetch.

If you dey use any transcoding at all, open Dashboard → Playback → Transcoding and set transcode temp path to /cache/transcodes, so the temporary files go enter cache volume instead of making /config too big. Leave hardware acceleration as None, because no GPU dey available to accelerate am.

Remote access: TLS reverse proxy, or keep am for VPN

You get two safe ways to reach Jellyfin from outside, and one unsafe way wey you suppose avoid. The unsafe way na to publish port 8096 straight to internet: login details go travel as cleartext, and dem go brute-force the port within hours.

Option A, TLS reverse proxy. Put Jellyfin for a subdomain behind Traefik with automatic TLS for your Docker apps, or behind nginx with Let's Encrypt certificate wey Certbot issue. Jellyfin dey use WebSockets for real-time updates, so the proxy must forward the upgrade headers. Traefik dey do this automatically; for nginx, you need write dem explicitly, and you need HTTP/1.1 go the upstream. Otherwise, the upgrade no go happen:

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 any local autodiscovery go advertise the correct URL and remote apps go use the address wey you give dem. Add fail2ban to slow brute-force attempts against the login. Once the server dey public, point Uptime Kuma to the URL so you go hear about downtime before your viewers notice am. Point the notifications to a self-hosted ntfy server, so failed check go arrive as phone push instead of email wey you go read the next morning. Putting login for public internet na also good time to audit the rest of the box. No assume say only port 443 dey answer: sudo ss -tlnp lists every listening port and the process behind am. If you also deploy your own code for the box, open-kritt scans repositories and their dependencies for vulnerabilities; e audits source code, not listening ports.

Option B, keep am private on a VPN. No publish 8096 to public internet at all. Reach Jellyfin only through a WireGuard tunnel wey terminates on the same box. For house use, this na the simplest secure choice: no certificate, no public exposure, and no brute-force surface. The localhost binding from the compose file no dey enough by itself here, because a port published on 127.0.0.1 dey reachable only from the VPS itself, not from VPN peers. Publish am on the server tunnel address instead, for example "10.8.0.1:8096:8096", using your own WireGuard server IP. Another option na to keep localhost and put a proxy wey dey listen on the tunnel address in front of am, then connect through the VPN. See the WireGuard VPN setup for a private VPS for the tunnel itself.

Storage sizing and backups

Plan budget based on quality, no be file count. Compressed 1080p films dey take 4-15 GB each; one 1080p remux dey take 20-40 GB; one season of 1080p TV dey take 15-40 GB; anything for 4K dey take 40-100 GB per film. Library wey get some hundred films plus some shows need 2-4 TB volume, and e cheaper to provision block volume pass wetin you need once than to migrate am later.

/config na the complete server state, so na the one thing wey you must back up. Take snapshot, or stop server and use tar, then keep the copy outside the server:

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

/cache and the transcode folder no important; you fit recreate dem. You fit back up the media for /mnt/media separately, or accept say you go need re-rip am. Most people choose the second option because of the size. Upgrades na docker compose pull && docker compose up -d; the :12 tag above dey within the 12.x line, so moving go the next major version na deliberate tag edit. Read the Jellyfin release notes before you do am, because library schema migrations dey happen for major versions. Jellyfin remove the leading "10." from its version numbers with 12.0 (September 2026): 12.0 na wetin 10.12 for don be, 11.x no dey, and 10.11.x na the last line for the old scheme. If older compose file wey belong to you still talk say jellyfin/jellyfin:10, e dey fixed for 10.11 (that tag last move on 2026-06-06, go 10.11.11) and e no go ever pull 12.x. Moving am go :12 pass ordinary tag edit: 12.0 dey change database schema and rewrite data for first boot, so stop the server and take the backup above first, because na that backup be the only way back. 12.0 fit upgrade directly from 10.10.7 or any 10.11.x release, and e need complete library scan afterwards. This one go take noticeably longer than normal. Pinned tag plus one backed-up state directory na the complete recipe for any always-on container, and na the same pattern wey dey keep self-hosted agent memory and schedules alive across reboots for self-hosted agent memory and schedules to remain alive across reboots.

Failure mode, with the string wey you go see

Library empty after scan. The log for Dashboard → Logs (or ~/jellyfin/config/log/log_*.log) go show:

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

The container uid no fit read that path. Cause fit be say root own the media, or another uid wey no be your user: value own am. Directory fit also miss execute bit, or the parent mount no dey traversable by that uid. Fix am with chown -R 1000:1000 /mnt/media, set directories to 755, set files to 644, then scan again.

Playback dey use CPU heavily and buffer. docker stats jellyfin go show CPU close to 100% times your core count, and Dashboard → Playback go list the session as Transcode with speed below 1.0x. The client no dey direct-play, so the VPS dey transcode with CPU slower than real time and e no dey keep up. Cause fit be unsupported codec or container, subtitle burn-in, or HDR tone-mapping. Fix am by switching to direct-play client. Keep sources for H.264/AAC. Use text subtitles (SRT) instead of image subtitles (PGS/VOBSUB), because most Jellyfin clients no fit draw dem by themselves and this one force burn-in. Also, no run 4K HDR for CPU-only box at all.

"No compatible streams are available." The full message usually be "This client isn't compatible with the media and the server isn't sending a compatible media format." The client reject the source, and the fallback transcode no start successfully. Cause fit be broken ffmpeg command, unreadable file, or user profile wey block video conversion. Read the ffmpeg line for Dashboard → Logs. Confirm say the file fit play at all. Check the user's playback permissions if you depend on transcoding. Try another client to rule out browser codec problems.

Films no get poster or the poster wrong. Metadata no match. Cause fit be folder or filename wey no match the title as your metadata provider list am, remake or common title wey no get year to separate the versions, season folder named S01 instead of Season 01, or episodes wey no dey S01E01 format. Fix am by renaming to the layout above. Then use Refresh metadata → Replace all, or use Identify for one item to select the correct TMDB/TVDB entry.

FAQ

VPS fit transcode video without GPU?

Yes, but na CPU only, and e cost plenty. One 1080p software transcode fit use up several vCPUs completely, and 4K or HEVC usually no fit keep up with real time, so playback go buffer. The better move na to avoid transcoding: keep your library for H.264/AAC and use client apps wey fit direct-play, so na only bytes VPS go stream. Rent GPU instance only if you truly need on-the-fly transcoding.

Why my Jellyfin library empty after scan?

Na permissions almost every time. Official jellyfin/jellyfin image dey run as any user: wey you set (or root), and if files no readable by that uid, scan logs Access to the path ... is denied and skip dem. Fix ownership with chown -R 1000:1000 /mnt/media, give directories execute bit (755), then scan again. Check the parent directory too, because if container uid no fit traverse /mnt/media itself, e no go ever reach library folders and everything go show empty. The second common cause na folder layout wey no match wetin Jellyfin expect.

How I fit access Jellyfin remotely and safely?

Two good options dey. Put am behind TLS reverse proxy for subdomain so login and stream dey encrypted, and add fail2ban. Never expose plain port 8096, because e dey send your password as cleartext. Or keep am completely private and reach am only through VPN, wey na the simplest safe choice for household. Give the apps the public address directly; autodiscovery na local-network broadcast, so e no reach clients wey dey enter through internet.

How much disk and bandwidth Jellyfin VPS need?

Disk depend on quality: plan 4-15 GB for each compressed 1080p film, 20-40 GB for each remux, and 40-100 GB for 4K, so most libraries go need 2-4 TB block volume. Bandwidth na the direct-play bitrate, 8-12 Mbps for each 1080p stream and much more for 4K, so confirm say your port speed fit handle the number of viewers wey dey watch at the same time, and monitor the monthly transfer cap. Add CPU headroom if you plan to transcode; put bandwidth before cores if you plan to direct-play.

Jellyfin itself na free, open-source software, and running am dey completely legal. Wetin matter na the content: stream only media wey you own or get license to keep, your own disc rips, recordings, or files wey you get right to use. Jellyfin no ship any media and e no provide way to obtain any; na player for library wey you already own.