How to Deploy Jellyfin on a VPS
Updated Sep 2026
verified on Ubuntu 26.04 · Sep 2026Self-host Jellyfin on a VPS with Docker Compose and HTTPS — the free, account-free media server — plus the transcoding reality check every VPS install needs before you buy the box.
- A VPS with at least 1 GB RAM — 2 vCPU / 2–4 GB if anyone will need transcoding
- Enough disk (or attached block storage) for your media library
- A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
- A domain you can point at the server
- Docker Engine + Compose installed (see the base guide below)
What Jellyfin is
Jellyfin is a free, open-source media server: it scans folders of films, TV, music and photos, fetches artwork and metadata, and streams the result to a web UI and to client apps on phones, TVs and set-top boxes. It is GPL-2.0, written in C# / .NET, and has no central account system and no premium tier — every feature, including hardware transcoding, is part of the free server.
That is the difference from Plex, whose clients sign in through Plex's own service and whose hardware transcoding sits behind Plex Pass. Plex vs Jellyfin and Emby vs Jellyfin lay out the trade-offs; the short version is that Jellyfin gives you total control in exchange for client apps that are rougher around the edges.
Server sizing — and the transcoding question
Jellyfin itself is light. On our test box (a GCP e2-standard-2 on Ubuntu 26.04) the idle server used about 170 MB of RAM and 2.4 GB of disk for the image and first-run data. The catalog's floor is 1 GB of RAM for direct-play streaming to a couple of devices.
What decides the size of the box is not the server — it is transcoding. When a client can play the file as-is ("direct play"), Jellyfin just ships bytes and the CPU barely moves. When it can't (an unsupported codec, a subtitle that has to be burned in, a bitrate cap on a mobile connection), Jellyfin re-encodes the video on the fly, and that is expensive.
On a home server a GPU or Intel Quick Sync does that work. Most VPS plans have no GPU, so on a VPS every transcode runs on the CPU. Plan for it:
- 1 vCPU / 1 GB — direct play only, one or two viewers.
- 2 vCPU / 2–4 GB — the realistic minimum if anyone will transcode, and expect roughly one software transcode at a time.
- More cores — the only lever you have for more simultaneous transcodes.
The simplest way around the problem is to not transcode: store media in formats your clients play natively and set the client's streaming quality to the original. Disk is the other constraint — a media library outgrows an entry-tier VPS disk quickly, so budget for attached block storage or a storage-heavy plan. Best VPS for Jellyfin ranks hosts with that in mind.
Prepare the server
This guide assumes Docker Engine and the Compose plugin are installed, along
with a non-root user and a ufw firewall. If not, work through
Docker & Compose on Ubuntu first.
Open SSH and the reverse proxy ports only — Jellyfin's own port stays on loopback:
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw --force enable
sudo ufw status verbose
Create the media directory. This guide uses /srv/media, with one subfolder
per library type — Jellyfin works best when films, shows and music live in
separate folders:
sudo mkdir -p /srv/media/movies /srv/media/shows /srv/media/music
sudo chown -R $(id -u):$(id -g) /srv/media
Paid link — we earn a commission if you shop through it.
Install Jellyfin (Docker Compose)
The official image is jellyfin/jellyfin. Upstream's compose example runs the
server as a specific uid:gid instead of root, keeps /config and /cache on
persistent storage, and bind-mounts the media. Record your user's IDs in a
.env file so the compose file doesn't hard-code them:
mkdir -p ~/jellyfin && cd ~/jellyfin
mkdir -p config cache
printf 'PUID=%s\nPGID=%s\n' "$(id -u)" "$(id -g)" > .env
cd ~/jellyfin
cat > docker-compose.yml <<'YAML'
services:
jellyfin:
image: jellyfin/jellyfin
container_name: jellyfin
user: "${PUID}:${PGID}"
ports:
# Loopback only — Caddy is the sole route in from outside.
- "127.0.0.1:8096:8096"
volumes:
- ./config:/config
- ./cache:/cache
- type: bind
source: /srv/media
target: /media
read_only: true
restart: unless-stopped
YAML
docker compose up -d
Two deliberate differences from the upstream example:
- No
7359/udpport. That port is for client auto-discovery on a local network. A VPS isn't on your LAN, so there is nothing for it to discover. - Media mounted read-only. Jellyfin only needs to read your library. If you
later want it to write artwork or subtitles next to the files, drop
read_only: true.
Wait for the server to answer and check the container is healthy:
cd ~/jellyfin
for i in $(seq 1 60); do curl -fsS -o /dev/null http://127.0.0.1:8096/ && break; sleep 2; done
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8096/
docker compose ps
A 302 (a redirect to the web UI) means Jellyfin is up.
HTTPS + domain
Point an A record for media.example.com at the server's public IP and
wait for it to resolve. Then put Caddy in front — see
Automatic HTTPS with Caddy for the full
setup. Jellyfin's own reverse-proxy documentation gives the Caddyfile as a
single block:
media.example.com {
reverse_proxy 127.0.0.1:8096
}
Caddy obtains and renews the certificate and passes WebSocket connections
through without extra configuration. If you run Caddy as a container,
127.0.0.1 is the proxy's own loopback — put both services in one compose
file and use reverse_proxy jellyfin:8096 instead.
If you'd rather serve Jellyfin under a path such as
example.com/jellyfin, set Base URL under Admin → Networking first,
then proxy only that path. A dedicated subdomain is simpler and is what the
client apps expect by default.
First run: the setup wizard
Open https://media.example.com. The first visit starts Jellyfin's setup
wizard, which asks for a display language, then creates the administrator
account, then offers to add libraries. Do this straight away — until the
wizard is finished, anyone who reaches the URL can claim the server.
When adding libraries, pick the content type (Movies, Shows, Music) and point
each at its folder under /media — /media/movies, /media/shows,
/media/music. The path is the one inside the container, not /srv/media.
Securing it
- Keep the admin separate. Create a normal user for day-to-day watching and keep the administrator account for administration.
- Leave the port on loopback. The compose file above never publishes 8096 to the internet; everything goes through HTTPS.
- Check who is signed in. Dashboard → Devices and the activity log show which clients have sessions; revoke anything you don't recognise.
- Don't expose an unfinished wizard. If you install now and configure later, keep the firewall closed until the admin account exists.
Backups
Jellyfin's state — users, watch history, library database, metadata — lives
in ./config. The media itself is yours to back up separately. Upstream's
guidance for a manual backup is to stop the server first, because a copy of
a database in use may not restore:
cd ~/jellyfin
docker compose stop
tar czf jellyfin-config-$(date +%F).tar.gz config docker-compose.yml .env
docker compose start
./cache holds transcodes and image caches that Jellyfin regenerates, so it
doesn't need backing up. Current versions also have a built-in backup under
Dashboard → Backups, which writes a zip into config/data/backups — handy,
but it is still on the same disk, so copy archives off the box either way.
Restoring means putting config back in place, then starting the container
on the same Jellyfin version the backup came from.
Upgrades
cd ~/jellyfin
docker compose pull
docker compose up -d
The image tag tracks the latest stable release, and database migrations run on
startup. Back up config first — migrations are one-way, and a backup
restores cleanly only on the version that made it. Read the release notes
before a major version jump.
Troubleshooting
Libraries stay empty. The container user can't read the folders, or the
library points at the host path. Libraries must use the container path
(/media/...), and ls -ln /srv/media should show the IDs from .env, or
permissions that let them read.
Playback buffers and the CPU sits at 100%. You're transcoding on the CPU. The Dashboard's active-sessions view shows each stream's play method: if it says Transcoding, set the client to the original quality, or store the file in a format the client plays directly.
The container restarts in a loop. Read the log:
cd ~/jellyfin
docker compose logs --tail 50 jellyfin
A permissions error on /config or /cache means those folders aren't owned
by the IDs in .env — sudo chown -R $(id -u):$(id -g) ~/jellyfin/config ~/jellyfin/cache fixes it.
Apps can't connect through the domain. Test that the proxy answers with
curl -I https://media.example.com from another machine. A certificate error
means DNS didn't point at the box when Caddy asked for the certificate.
Verification + next steps
You're done when you can load https://media.example.com over a valid
certificate, sign in as a non-admin user, play a film in a browser and on a
phone, and see Direct Play rather than Transcoding in the Dashboard's
active sessions for your usual clients.
From there, automate the library: Sonarr for TV and Radarr for films. For music, a dedicated server such as Navidrome is lighter than a full media server, and Audiobookshelf handles audiobooks and podcasts with progress sync.