On this page (9)
This Jellyfin Docker Compose file runs the official jellyfin/jellyfin image pinned to 12.2 (the server reports 12.2.0), with named volumes for config and cache and a read-only media folder. We verified it on Oct 6, 2026 and measured it as the median of three runs on Oct 8, 2026, on a GitHub Actions runner. It was healthy 11 s after docker compose up, used 233 MiB of RAM at idle (before the first library scan, range 206 to 235 MiB over the three runs; about 250 to 296 MiB right after the scan and one transcode, 324 MiB after all our scans and transcodes on our earlier host) and peaked at 508 MiB (range 485 to 533) while it scanned 13 test films and transcoded one 1080p stream to 720p on the CPU of a 2-vCPU lab host. By our sizing rule, using the highest run, that is 1 GB of RAM, but RAM is not what limits the number of streams, CPU is. That one transcode ran at 3.07x real time and used about 198% of the two vCPUs, because ffmpeg runs unthrottled and takes all the CPU it gets. Playback needs only 1x, so 3.07x says it has room to spare, not that one stream fills the machine. In a separate test on the same runner type (a different CPU model, see below), 3 simultaneous 1080p-to-720p transcodes just kept up (1.03x to 1.06x, no margin) and 4 did not, so plan for 2 at once (see How many streams can it transcode?). Three things trip people up: the user and folder permissions, hardware acceleration (it needs device passthrough; we researched it and did not test it) and the address the server tells clients to use.
Measured readings
Test box: Jellyfin 12.2
Measured in our lab on : 3 full runs, each from a fresh stack. Each figure is the median, with the range over the runs under it. The numbers come from the raw result file.
- Idle RAM
- 233 MiBrange 206–235 · 3 runs all containers, median of 3 runs; each run is the mean over 30 s after the stack settled
- Startup peak RAM
- 240 MiBrange 221–245 · 3 runs highest total between healthy and settled
- Peak RAM
- 508 MiBrange 485–533 · 3 runs under load, median of the run peaks
- Settled after
- 125 srange 125–127 · 3 runs after the first healthy response
- Idle CPU
- 1.9 %range 1.8–1.9 · 3 runs of one core, lab host
- Startup
- 11 ssame when rounded in all 3 runs to first healthy response
- App version
- 12.2
- Tested
- , 3 runs (compose file verified the same day)
- How the figures are made
- Each run starts a fresh stack and goes through setup, settling, the idle window and the load step. We publish the median over the runs and the lowest and highest value, because the lab host and some apps vary from run to run. Runs, median and range
- Lab host
- AMD EPYC 7763 64-Core Processor, 2 cores, 7.8 GiB RAM, x86_64.Ubuntu 24.04.5 LTS, Docker 28.0.4.
- Disk for the images
- About 2.8 GB with the containerd image store, the default on fresh installs of Docker Engine 29 and later (our estimate: unpacked plus compressed size, plus 10%; not measured on a containerd host). About 1.7 GB with the older overlay2 store (the unpacked column, as measured on this lab host). Plan for the larger figure. How we size disk
- How idle was measured
- After the stack was healthy and set up, we sampled total RAM until it stayed within max(2% of its mean, 10 MiB) over 60 s (at least 120 s, at most 600 s), then measured idle over the next 30 s. Rule in full
- Peak RAM measured
- Under load. One scan, then one software transcode. A Python script (standard library only, on the Compose network) starts a library scan of the 13 generated films (local files only, no internet metadata lookups) and waits for the task to finish. It then plays the 12-minute film as one simulated player for 60 seconds: it asks for a 1280x720, 4 Mb/s H.264 HLS stream that cannot direct play, so Jellyfin runs ffmpeg with libx264 on the CPU (no hardware acceleration in the lab), and it fetches the 3-second segments in real time. The 1080p H.264 source is synthetic: a test pattern with added noise at about 8 Mb/s, with AAC stereo audio that Jellyfin copies. ffmpeg runs without throttling (the default), so it works as fast as the CPU allows. No browser, no subtitles, no second viewer.
| Container | Idle RAM | Peak RAM (under load) | Image, compressed (download) | Image, unpacked (disk, overlay2 store) |
|---|---|---|---|---|
| jellyfin | 233 MiBrange 206–235 · 3 runs | 508 MiBrange 485–533 · 3 runs | 754 MB | 1.7 GB |
Every run (3)
| Run | Idle RAM | Peak RAM | Startup peak | Startup | Lab host CPU |
|---|---|---|---|---|---|
| 1 | 206 MiB | 485 MiB | 221 MiB | 11 s | AMD EPYC 7763 64-Core Processor |
| 2 | 235 MiB | 508 MiB | 240 MiB | 11 s | AMD EPYC 7763 64-Core Processor |
| 3 (closest to median idle) | 233 MiB | 533 MiB | 245 MiB | 11 s | AMD EPYC 7763 64-Core Processor |
0 s to 160 s after healthy; the line spans 0 to 244 MiB. Dashed line: settled.
Timeline as a table
| Seconds after healthy | Total RAM |
|---|---|
| 30 | 244 MiB |
| 60 | 232 MiB |
| 90 | 227 MiB |
| 120 | 228 MiB |
| 150 | 234 MiB |
| 160 | 236 MiB |
RAM is MiB (220 bytes); image sizes are MB (106 bytes). The unpacked column is what our lab host's Docker reported; see Disk for the images for other setups. Per-container peaks happen at different moments, so they do not add up to the total peak. Medians of separate columns need not add up either.
Download verified compose fileRaw result JSONHow we measure
Lab notes (5)
- Image: the official `jellyfin/jellyfin` (Debian based, jellyfin-ffmpeg 8.1.3 inside). The media folder is generated by lab/apps/jellyfin/make_media.sh with ffmpeg (13 synthetic films, about 1.1 GB, nothing downloaded) and bind-mounted read-only at /media, so the compose file has one bind mount (./media).
- Metadata fetchers for movies are switched off in the library options (TypeOptions), not just EnableInternetProviders: with only that flag the first scan still called TheMovieDb for every film (13 failed lookups, 14.4 s instead of 3.6 s in the sandbox, where the proxy's certificate breaks TLS). The scan time here is local file probing only; a scan with TheMovieDb on a normal connection was not measured.
- Idle is measured after the wizard and library creation, before the first scan: the library exists but has no items. The first scan is part of the load step.
- CPU is software transcoding on the 4-vCPU lab host and is valid for that host only. There is no GPU or iGPU in the lab: hardware acceleration is not tested.
- service jellyfin: bind mount /home/runner/work/claudeftw/claudeftw/lab/apps/jellyfin/media refers to a path on the daemon host; prefer named volumes so remote runs and `down -v` stay clean
Verified compose file
One service, jellyfin, from the official image. The file follows the Compose example in the Jellyfin docs and differs from it in these ways:
- Pinned tag.
12.2. On Oct 6, 2026 the12.2,12andlatesttags on Docker Hub pointed at the same image (digestsha256:357724bf..., pushed Oct 5, 2026). The docs saylatestfollows the latest stable release “including through major and minor version bumps”, so a file that sayslatestcan jump a major version when you pull. The lab resolved the tag and wrote the digest into the file’s comment. - Named volumes for
/configand/cache. The docs example uses folders on the host. - Media read-only at
/media, from a folder next to the file (./media). Replace it with your library’s folder. - A 5 s health check. The image defines its own:
curlon/healthevery 30 s with a 10 s start period. Ours calls the same endpoint every 5 s, sodocker compose up --waitreturns as soon as the server answers. In our run/healthreturned HTTP 200 with the bodyHealthy. - Left out: the docs example’s
7359/udpport (client autodiscovery on the local network, see the gotchas),container_name, auser:line, and any GPU device.
The comments in the file are the lab’s.
# Verified compose for Jellyfin, tested 2026-10-08 by the SelfHostBench lab (3 runs; the figures are the median).
# Lab host: AMD EPYC 7763 64-Core Processor, 2 vCPU, 7.8 GiB RAM, x86_64, Docker 28.0.4.
# Floating image tags below were replaced by the version resolved at test time;
# the registry digest is in the comment on each image line.
# Measurements: jellyfin.json (schema 3).
# Jellyfin (official jellyfin/jellyfin image): config and cache in named volumes, the media library read-only from a folder.
# Follows the Compose example in the Jellyfin docs (https://jellyfin.org/docs/general/installation/container/).
# Differences from that example, all marked below: named volumes for /config and /cache, a tighter health check,
# no published autodiscovery port (7359/udp), no container_name, and the media folder mounted read-only.
# There is no TLS in this file. Put a reverse proxy in front before you expose it (see the article).
name: jellyfin
services:
jellyfin:
# Pin a release tag. `latest` follows every release, major ones included (tags: see the docs link above).
image: jellyfin/jellyfin:12.2 # sha256:357724bf0ae27a672c7cbaa899db2d9abeb13dbd8657ccce750258a4c059d037 (version 12.2, from label:org.opencontainers.image.version)
restart: unless-stopped
# Runs as root by default, as in the docs. To run as another user, add `user: "1000:1000"` here (docs: "Optional - specify
# the uid and gid you would like Jellyfin to use instead of root"). With the named volumes below that works as it is.
# If you bind-mount /config and /cache from folders instead, chown them to that user first: otherwise Jellyfin stops with
# "Access to the path '/config/log' is denied". The user must be able to read the media folder too.
ports:
# Web UI and API. Clients connect here.
- "8096:8096/tcp"
# In Compose's default bridge network the server reports its container address (for example http://172.18.0.2:8096) as its
# own address. To advertise the address your clients use, add the variable below (docs: "alternative address used for autodiscovery").
# environment:
# - JELLYFIN_PublishedServerUrl=http://media.example.lan:8096
volumes:
# Settings, the SQLite database, users, metadata, logs and the built-in backups. This is your server: back it up.
- config:/config
# Transcoding segments, image cache and other data that can be rebuilt. Not part of a backup.
- cache:/cache
# Your library. Replace ./media with the folder that holds your movies and shows, for example /srv/media.
# Read-only means Jellyfin cannot change or delete your files. It still has to be able to read them.
- ./media:/media:ro
healthcheck:
# The image has its own check (curl on /health every 30 s). This is the same endpoint with a 5 s interval
# so `docker compose up --wait` returns as soon as the server answers "Healthy".
test: ["CMD-SHELL", "curl -fsS http://localhost:8096/health | grep -q Healthy"]
interval: 5s
timeout: 5s
retries: 30
start_period: 5s
volumes:
config:
cache:The lab’s ./media held 13 films made with ffmpeg: a test pattern or fractal with film-grain-style noise added and a sine tone, 1080p, 30 s to 12 minutes each, 11 in H.264 at about 8 Mb/s and two in 10-bit HEVC at about 6 Mb/s. Nothing was downloaded. For the 12.2 test the files were made again (the first set had been deleted to save disk) with the 12.1 image’s ffmpeg, so the encoder was the one that made the first set. The folders are named like a movie library (Title (Year)/Title (Year).mp4) and Jellyfin found all 13. The generator, make_media.sh, and the setup and load script, jellyfin_client.py, are in lab/apps/jellyfin/ in the lab repository.
Install steps
We ran these steps in a scratch directory on our earlier sandbox lab host on Oct 4, 2026 on 12.1 (Docker 29.6.2, Compose 5.3.1), and repeated the start, the wizard calls and the first scan on 12.2 on Oct 6, 2026. We did not repeat them on the Actions runner. The numbers on this page come from the lab run in jellyfin.json (Oct 8). The raw output of the manual steps is in lab/notes/jellyfin-2026-10-06.md (12.2) and lab/notes/jellyfin-2026-10-04.md (12.1) in the lab repository.
-
Install Docker Engine with the Compose plugin.
-
Make a folder, save the file above as
compose.yamlin it, and put your films inmedia/movies/, one folder per film.bash mkdir -p jellyfin/media/movies cd jellyfin -
Start the server. The first start pulls the image: 754.0 MB compressed (table below).
--waitreturns when the health check passes.bash docker compose up -d --waitWith the image already pulled, this returned after 10.9 s.
-
Check the container and the health endpoint.
bash docker compose ps --format 'table {{.Name}}\t{{.Status}}\t{{.Ports}}' curl -sS -i http://127.0.0.1:8096/healthOur output on 12.1 for
docker compose psshowedUp 10 seconds (healthy)and0.0.0.0:8096->8096/tcp. Thecurlcall returnedHTTP/1.1 200 OK,Content-Type: text/plainand the bodyHealthy. -
Finish the setup wizard. Open
http://<host>:8096/: the server redirects/to/web/(we saw HTTP 302 on 12.1). Not run in our lab: the browser wizard. The lab completed the same wizard through the server’s Startup API, in this order:POST /Startup/Configuration(server name, language, metadata country),GET /Startup/User,POST /Startup/User(the admin name and password),POST /Startup/RemoteAccess,POST /Startup/Complete. Each call returned HTTP 204 except theGET(200). Before the wizard,GET /System/Info/Publicreported"StartupWizardCompleted": false; afterwardstrue. -
Add your library: type Movies, path
/media/movies. Not run in our lab: the browser dialog. The lab did it withPOST /Library/VirtualFolders(HTTP 204) and started the first scan withPOST /Library/Refresh. The scan of the 13 films finished in 3.6 s (all three runs) and found all 13 (jellyfin.json, the load step). We switched off the metadata fetchers for that library, so the scan only read our files and made no lookups on the internet. A scan that fetches posters and descriptions, which is what a normal library does, takes longer. Not measured in our lab.
To stop the stack, docker compose down removes the containers and keeps the named volumes. Adding -v deletes config and cache, which means your users, library and watch history. Not run in our lab: the plain command.
docker compose downMeasured resources
Source: jellyfin.json, three runs on Oct 8, 2026, each from a fresh stack, on the lab host (a GitHub Actions ubuntu-latest runner: 2 vCPU AMD EPYC 7763, 7.8 GiB RAM, Docker 28.0.4). The earlier runs of this page were on a 4-vCPU sandbox host with a different CPU, so CPU figures cannot be compared between the two. The Test box notes for Jellyfin still say “4-vCPU lab host” and mention the sandbox proxy: that wording comes from the earlier host and stays in the result file until the next lab run (the app’s test definition is already corrected). Every figure below is the median of the three runs, with the range (lowest to highest run) in brackets where it matters (why we repeat runs). In each run idle is the mean of 16 samples over 30 s, taken after the stack’s total memory had settled (125 s after the stack was healthy in the median run, 125 to 127 s over the three; rule on the methodology page). At that point the wizard was complete and the Movies library existed, but nothing had been scanned yet: the first scan is part of the load. Startup peak is the highest total between healthy and settled. Peak is the highest value seen while the load ran. The runs agreed within 14% on idle and 10% on peak, so none was flagged as varying (the flag needs the highest run to be more than 1.5 times the lowest). The first run was the lowest on idle and on peak.
The load was one scan and one transcode, over plain HTTP on the Compose network. A Python script in a throwaway container started a library scan of the 13 films and waited for it (3.6 s in every run). It then played the 12-minute film as one simulated player for 60 s (every player kept up in all three runs, with the first segment after 3.0 to 3.2 s). It asked for a 1280x720, 4 Mb/s H.264 stream in HLS that could not be direct played, so Jellyfin ran ffmpeg with libx264 on the CPU. The player fetched the 3-second segments in real time. The source was 1080p H.264 at about 8 Mb/s, and its AAC stereo audio was copied, not re-encoded (the ffmpeg command in jellyfin.json has -codec:a:0 copy). ffmpeg ran without throttling, which is the default (EnableThrottling was false on a fresh 12.2 install, lab/notes/jellyfin-2026-10-06.md, section 25), so it worked as fast as the CPU allowed. No browser, no subtitles, no second viewer.
| Container | Idle RAM, settled (MiB) | Startup peak (MiB) | Peak RAM under load (MiB) | Image compressed (MB) | Image unpacked (MB) |
|---|---|---|---|---|---|
| jellyfin | 232.95 (206.05 to 234.93) | 240.30 (220.50 to 244.90) | 507.50 (485.00 to 532.60) | 754.0 | 1,730 |
Each cell is the median of the three runs with the range in brackets. Jellyfin runs ffmpeg as a child process inside its container, so the transcoder’s memory counts in this row. The unpacked size is what Docker reports on the lab host, which has Docker 28.0.4 with the classic overlay2 image store. Docker 29 and later uses the containerd image store on fresh installs, and that store also keeps the compressed layers: the earlier host (Docker 29.6.2, containerd store) showed 2,500 MB for the same image digest. For disk we plan for unpacked plus compressed size plus 10%, about 2.8 GB against 1.7 GB on overlay2 (how we size disk). CPU, medians of the three runs; this runner has 2 vCPUs, so 200% is the most the container can use:
| Phase | CPU, mean (% of one core, lab host) | CPU, max (% of one core, lab host) |
|---|---|---|
| Idle | 1.9 | 14.5 |
| Transcode, one stream (about 59 s of the load) | 198 | 200 |
The transcode row is computed from the load samples of each run in jellyfin.json, leaving out the first and last 4 s of the transcode phase (the method of lab/notes/jellyfin-2026-10-06.md, section 2a); the per-run means were 197%, 198% and 198%, and the idle maximum is the highest of the three runs’ maxima (9.0 to 14.5%). The lab’s own mean over the whole load window is 175% (174% to 175%), because that window also holds the scan and a short idle tail.
What the numbers say:
- Idle is 233 MiB before any library has been scanned (206 to 235 MiB over three runs). Right after the lab’s own scan and one transcode the load series ended at 250, 296 and 290 MiB (
jellyfin.json, last three samples of each run). After the scan and all our later transcode runs on the earlier host the same container sat at 324 MiB with 0.08% CPU (30 s after the last load, a manual reading,lab/notes/jellyfin-2026-10-06.md, section 7). A reader with a scanned library should expect the second and third figures, not 233. We would size for 324 MiB or more, and a library bigger than 13 films was not measured. - The median peak is 508 MiB, 275 MiB above the median idle, while one ffmpeg process transcoded (range 485 to 533 MiB). During the transcode RAM ranged from 468 to 533 MiB over the three runs (468 to 485 in the first run, 493 to 508 in the second, 510 to 533 in the third).
- By our sizing rule (the highest peak of any run, 532.6 MiB, times 1.5, rounded up to 1, 2, 4, 8, 16 or 32 GB) one stream needs 1 GB. The rule does not see CPU, and CPU is what runs out first (next section). RAM grows with simultaneous transcodes: about 220 to 245 MiB for each extra player from 1 to 3 on the lab host (table in How many streams can it transcode?; the earlier host showed about 190 MiB in manual runs on Oct 6).
- The image is 754.0 MB to download and 1,730 MB unpacked on this runner’s overlay2 store, so plan for about 2.8 GB of disk for it on Docker 29 and later (12.1 was 745.4 MB to download). On 12.1 on the earlier host after the scan,
/configwas 10 MB and/cache15 MB (lab/notes/jellyfin-2026-10-04.md, section 8). Not re-measured on 12.2. - A fresh start was fast: healthy after 10.9 s (median of three runs, 10.9 to 11.0 s), with the image already pulled.
- Run-to-run spread: the three runs of this table idled at 206, 235 and 233 MiB (peak 485, 508 and 533), all on the same CPU model. On the earlier sandbox host, eight runs of 12.1 and 12.2 across three host sessions ranged from 187 to 270 MiB idle and 444 to 533 MiB peak, and we could not credit any difference between 12.1 and 12.2 to the version. We do not know why those host sessions differed, and three runs on one CPU model do not prove the Actions runner is steadier. The earlier runs’ result files were superseded and not kept, so the notes (section 2a) hold their summary lines.
Transcode speed on 2 vCPUs
On the 2-vCPU Actions runner, one software transcode of a 1080p H.264 source to 720p ran at 3.07x real time (3.04x to 3.08x over the three lab runs in jellyfin.json) and used about 198% of the 200% available. That does not mean a stream needs that much. ffmpeg ran unthrottled, so it used every cycle to go as fast as it could, and a player only needs 1x. On the earlier 4-vCPU host, where the same test used about 380% of 400%, players began to fall behind at about 8 simultaneous streams, close to the 6.2x to 7x speed of one stream (Oct 6, 12.2, lab/notes/jellyfin-2026-10-06.md). We measured how many streams this runner keeps up with in the next section. These are lab-host CPU numbers. They are valid for this host only. Do not compare them with figures from another CPU.
Earlier versions of this page had a table of 1 to 10 simultaneous transcodes. Those numbers came from our previous 4-vCPU sandbox host (Oct 4 to 6, 2026) and do not carry over to a 2-vCPU host, so they were removed on Oct 9 and are not shown again. The table in the next section is measured on the 2-vCPU lab host.
What we can say from the runs we have:
- The CPU, not RAM, decides how many players one host serves. Our one-stream run used 198% of two vCPUs (unthrottled, so that is its top speed, not its need) while RAM peaked at 508 MiB. If your players transcode, count vCPUs first.
- The source matters. It was a synthetic test pattern with added noise at about 8 Mb/s. Real films vary in how hard they are to decode and re-encode, and we did not measure any. Audio was copied, not transcoded. No subtitle was burned in and no HDR was tone-mapped.
- A 10-bit HEVC source was slower than the H.264 one in single-player runs on 12.1 on the earlier host (Oct 4,
lab/notes/jellyfin-2026-10-04.md, section 6b). These were whole-file averages that include startup. Not re-run on 12.2 or on the Actions runner. - Our player was built so that Jellyfin had to transcode. Not measured in our lab: direct play and direct stream.
How many streams can it transcode?
On a 2-vCPU host with software x264, plan for 2 simultaneous 1080p-to-720p transcodes and keep headroom. In our ladder test, 3 players just kept up (ffmpeg at 1.03x to 1.06x real time, no late segment in any of 3 runs) with no margin, and 4 players did not keep up in any run (0.77x to 0.79x, 51 to 53 late segments). These are lab-host numbers, valid for this host only. Do not compare them with figures from another CPU.
| Players | Kept up? (runs) | ffmpeg speed, median player (x real time, lab host) | Late segments (all players) | First segment, slowest player | Stack RAM, peak (MiB) |
|---|---|---|---|---|---|
| 1 | Yes, 3 of 3 runs | 3.12x (range 3.05 to 3.14) | 0 | 3.0 s | 566 MiB (range 542 to 572) |
| 2 | Yes, 3 of 3 runs | 1.58x (range 1.58 to 1.60) | 0 | 5.6 s (range 4.9 to 5.6) | 810 MiB (range 790 to 823) |
| 3 | Yes, 3 of 3 runs | 1.06x (range 1.03 to 1.06) | 0 | 7.2 s (range 7.0 to 7.2) | 1,031 MiB (range 983 to 1,039) |
| 4 | No, 0 of 3 runs | 0.78x (range 0.77 to 0.79) | 52 (range 51 to 53); worst segment 10.9 s late (up to 12.1 s) | 9.7 s (range 9.6 to 9.9) | 1,204 MiB (range 1,176 to 1,253) |
| 6, 8 | Not run: the ladder stops at the first level that clearly fails (4 players, in all 3 runs). | ||||
Source: the raw result file (jellyfin-ladder.json), 3 full runs on 2026-10-11 on the lab host (github-actions ubuntu-latest (2 vCPU, 8 GB); Intel Xeon Platinum 8573C). Each cell is the median of the runs with the range in brackets where the runs differed. Each level ran for 60 s of playback. Mean CPU of the stack was 197% to 198% of one core at every level that ran (200% is both vCPUs). RAM added by each extra player: +244 MiB from 1 to 2, +221 MiB from 2 to 3, +173 MiB from 3 to 4.
How the ladder was run: the same Compose file and Jellyfin 12.2 as above, as a separate lab app (jellyfin-ladder, with its own result file, jellyfin-ladder.json; the definition is in lab/apps/jellyfin-ladder/ in the lab repository). The source was one synthetic 12-minute 1080p H.264 film at about 8 Mb/s, like the one above. Each simulated player had its own device ID and asked for a 1280x720, 4 Mb/s H.264 stream in HLS that could not be direct played, so Jellyfin ran one libx264 ffmpeg per player, unthrottled, and the player fetched the 3-second segments in real time for 60 s of playback. A level kept up when no segment on any player arrived after its playhead needed it (one late segment fails the level). We requested 1, 2, 3, 4, 6 and 8 players. The ladder stops at the first level that clearly fails (at least half of the players late on a quarter or more of their segments), which was 4 in all 3 runs, so 6 and 8 were skipped on purpose. All 3 runs started from a fresh stack on Oct 11, 2026, on the lab host: a GitHub Actions runner with 2 vCPUs (Intel Xeon Platinum 8573C, 7.8 GiB RAM, Docker 28.0.4). The one-stream results above ran on an AMD EPYC 7763 runner, so the CPU model differs between the two result files (one player: 3.12x here, 3.07x there). More on runs, median and range and the lab host on the methodology page.
What the table means for your server:
- Plan for 2 simultaneous transcodes. The median player ran at 1.58x to 1.60x over the 3 runs, about 1.6 times the speed playback needs, which leaves some room (we did not test harder films or other work on the host). Three kept up in all 3 runs, but the slowest player in a run ran at 1.03x to 1.04x: that is a pass on our synthetic film with no margin, and we would not count on it for real films, which vary (a 10-bit HEVC source was slower than H.264 in single-player runs on the earlier host, see above). Four players fell behind: the worst segment arrived 10.6 to 12.1 s after it was needed.
- The CPU is the limit, and it is shared. Both vCPUs were busy at every level (mean 197% of 200%), because ffmpeg runs unthrottled. Each player’s speed times the number of players came to 3.1 to 3.2 at every level, so adding a player divides the same CPU instead of adding capacity.
- Waiting grows too. The slowest player waited about 3 s for its first segment with 1 player, about 7 s with 3 and about 10 s with 4.
- RAM per extra stream was about 220 to 245 MiB between 1 and 3 players (stack peak 566, 810 and 1,031 MiB), and 173 MiB from 3 to 4. These peaks come from the ladder stack during each level and are not the Test box peak (566 MiB for 1 player here against 508 MiB above); we did not investigate the difference. By our sizing rule, the highest run at 2 players (823 MiB) and at 3 players (1,039 MiB) both land in the 2 GB tier. The Where to run it module below uses the one-stream result (1 GB), so if you expect 2 players to transcode at once, take the 2 GB tier.
- Direct play costs almost nothing, but we did not measure it. The Jellyfin docs say direct play delivers the file without transcoding, with “almost no additional load on the server” (page read Oct 11, 2026). Every player in our test was forced to transcode. Not tested in our lab.
- Hardware acceleration changes the picture, and we did not test it. The docs say hardware transcoding is much faster. Our lab host has no GPU, so everything above is software x264. See the hardware acceleration section, which is researched, not tested.
- More cores: we expect a higher number, but we did not test it. The speeds above add up to roughly 3 times real time on 2 vCPUs. We did not run the ladder on a host with more cores, so we give no stream count for one. Not tested in our lab.
Hardware acceleration (researched, not verified in our lab)
Every transcode above ran on the CPU. Our earlier sandbox host had no GPU and no iGPU: /dev/dri did not exist on it, and there was no NVIDIA device. We did not check the Actions runner for GPU devices. A fresh install has HardwareAccelerationType set to none (lab/notes/jellyfin-2026-10-06.md, section 25). So this section is researched, not tested. It is from the Jellyfin docs for the Intel and NVIDIA setups, read Oct 4, 2026 (the quoted limits were checked again on Oct 6).
What we did check, without a GPU: the ffmpeg in the image (8.1.3-Jellyfin) lists vaapi, qsv, cuda, drm, opencl, vulkan and amf as acceleration methods, and has the encoders h264_vaapi, h264_qsv, h264_nvenc, hevc_vaapi, hevc_qsv and hevc_nvenc (notes section 18; the list was the same on 12.1). That shows the build supports them. It does not show any of them working. We ran vainfo from the image on the sandbox host and it printed error: failed to initialize display, which is what you would expect with no device.
Intel (QSV or VA-API)
The docs say the official image “comes with all necessary user mode Intel media drivers and the OpenCL runtime”, and that you have to pass the host’s render group id to Docker and the render device. Their example uses user: 1000:1000, group_add, network_mode: 'host' and a devices entry. The lines that add the GPU to our file are these. Both snippets below add a user: line. On a stack that has already run as root, read the permissions section first (chown the existing files, or Jellyfin will not start). Replace 122 with the output of getent group render | cut -d: -f3 on your host (the docs note the group can be video or input instead of render on some releases), and use renderD129 for a second GPU.
Not run in our lab: no Intel GPU.
services:
jellyfin:
user: 1000:1000
group_add:
- '122'
devices:
- /dev/dri/renderD128:/dev/dri/renderD128The docs then say to check the codecs with docker exec -it jellyfin /usr/lib/jellyfin-ffmpeg/vainfo, and to enable QSV or VA-API in Jellyfin’s playback settings and uncheck the codecs your GPU does not support. The docs’ command names the container jellyfin. Our file sets no container_name, so run it as docker compose exec jellyfin /usr/lib/jellyfin-ffmpeg/vainfo, which is how we ran it. Not run in our lab: a successful check.
Some limits the docs state for Intel. QSV and VA-API need no monitor. Unlike NVIDIA’s NVENC there is no concurrent-encoding-session limit on Intel iGPUs. HEVC 10-bit decoding and encoding needs Gen 9.5 (Kaby Lake, 7th Gen Core), Apollo Lake, Gemini Lake or newer, and AV1 decoding needs Gen 12 (Tiger Lake, 11th Gen Core) or newer. The docs’ tier list says the entry-level iGPUs “usually come from mini PC boxes or NASes”, can transcode HEVC 10-bit and apply tone mapping, and are “adequate for personal use”. That is the docs’ statement; we measured none of it.
NVIDIA (NVENC)
The docs say the official image “doesn’t include any NVIDIA proprietary driver”: you install the driver and the NVIDIA Container Toolkit on the host. The image already sets NVIDIA_VISIBLE_DEVICES and NVIDIA_DRIVER_CAPABILITIES (we read them from the image, lab/notes/jellyfin-2026-10-06.md, section 18). The docs’ Compose example adds:
Not run in our lab: no NVIDIA GPU.
services:
jellyfin:
runtime: nvidia
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]The docs page says that for Jellyfin 10.11 the minimum NVIDIA driver version on Linux is 520.56.06; it names no 12.x minimum, so check the page for your version. It tells you to check the GPU with docker exec -it jellyfin nvidia-smi and to enable NVENC in Jellyfin. Not run in our lab.
We have not tested any GPU or mini PC, so we make no hardware recommendation here.
Gotchas, upgrade and backup
The manual checks in this section ran on 12.1 or 12.2 on our earlier sandbox host (Oct 4 to 6, 2026). We did not repeat them on the Actions runner.
Permissions and the user setting
The image runs as root unless you set user:. In the lab we tested user: "1000:1000" three ways on 12.1 on Oct 4 (lab/notes/jellyfin-2026-10-04.md, sections 9 to 11; not re-run on 12.2):
-
Named volumes, as in our file: works. The server started healthy, completed the wizard and scanned 13 films. The image’s
/configand/cacheare world-writable (drwxrwxrwx), and the files Jellyfin wrote there belonged to uid 1000. -
Bind-mounted
/configand/cachethat root had created (mode 755): Jellyfin did not start. The container went unhealthy and the log saidAccess to the path '/config/log' is denied. Afterchown 1000:1000on both folders it started and was healthy. The fix is tochownthe folders to the user you set. -
An existing volume that Jellyfin already filled as root: not tested. The files in
/configof our root run were owned by root and writable only by root (Oct 4 notes, section 8), so by the same failure a switch touser: "1000:1000"should be refused until the files arechowned. Take a backup and change the owner first, then add theuser:line. Not run in our lab:bash docker compose stop jellyfin docker run --rm --entrypoint chown -v jellyfin_config:/config -v jellyfin_cache:/cache jellyfin/jellyfin:12.2 -R 1000:1000 /config /cache -
A media folder only root could read (mode 700): adding the library failed with HTTP 400 and the log message
The specified path does not exist: /media/movies.The path existed. The user could not read it. If a library will not add or shows no items, check read access for the container’s user on every folder level down to the files.
services:
jellyfin:
user: "1000:1000"The address clients are told to use
In Compose’s default bridge network, GET /System/Info/Public before the wizard returned "LocalAddress":"http://172.18.0.2:8096", the container’s own address, which nothing outside Docker can reach. With JELLYFIN_PublishedServerUrl=http://media.example.lan:8096 set in environment:, the same field returned that URL (tested on 12.1 on Oct 4, notes section 12; the container address before the wizard was seen again on 12.2 on Oct 6). The docs’ Compose example describes the variable as the “alternative address used for autodiscovery”. It lives in the file as a commented-out example, so uncomment it and use the address your clients type.
Client autodiscovery uses 7359/udp, which the networking page lists as “Client Discovery” and says works on the local network. Our file does not publish it, and we did not test any client discovering the server. The Docker tab of the docs adds that host networking (--net=host) is “required in order to use DLNA”, and the DLNA page says DLNA has been a first-party plugin, not part of the base install, since 10.9, and uses 1900/udp on the local network. We did not test DLNA.
Metadata lookups and scan time
A first scan that calls online metadata providers can be much slower than one that only reads your files. In the lab, a library created through the API with EnableInternetProviders set to false still called TheMovieDb for every film in 12.1: the log showed 13 Error in TheMovieDb entries (TLS errors, because the proxy in front of our earlier sandbox lab host broke the provider’s certificate; observed Oct 4, 2026, lab/notes/jellyfin-2026-10-04.md) and the first scan took 14.4 s. Listing no metadata fetchers for movies in the library’s TypeOptions stopped the calls: no errors and a first scan of 3.6 s (Oct 4 notes, section 21). The lab’s library uses the second setting, so the 3.6 s on this page is local file reading only. We did not re-run this comparison on 12.2, and we did not measure a scan with TheMovieDb working. If you set up libraries through the web interface you will not meet this API detail, but it explains why scan times differ between setups.
HTTPS
The file has no TLS. The networking page says that while Jellyfin supports HTTPS, “it is strongly recommended to handle HTTPS termination separately on a reverse proxy”, and that reverse-proxy access only works if known proxies are set up correctly. Our Vaultwarden Docker Compose guide puts Caddy in front of a stack and shows the Compose side of it. We did not run Jellyfin behind a proxy, so the known-proxies setting is untested here.
Back up and restore
What to back up: the config volume. The docs say that for the official Docker image the data directory is wherever your /config volume comes from, and that it holds the config directory as well. cache is rebuilt, so it is not part of the backup. After our scan and loads, config was 10 MB.
Manual backup. The docs say to stop Jellyfin first, “as otherwise the database will be locked and might not be recoverable when restoring”. We ran it this way on the live stack, with a marker set first (one film marked played and favorite for the admin user). On Oct 6 we ran the commands below on 12.2 with the verified file in a scratch folder, so the project was jellyfin (the file’s name:) and the volume jellyfin_config; docker volume ls shows yours. The commands use the Jellyfin image, so there is nothing else to pull.
mkdir -p backup # create it as your user, or Docker creates it owned by root
docker compose stop jellyfin
docker run --rm --entrypoint tar -v jellyfin_config:/config:ro -v "$PWD/backup":/backup jellyfin/jellyfin:12.2 czf /backup/jellyfin-config.tgz -C /config .
docker compose start jellyfinThe tar step took 0.53 s and wrote a 49 KB archive of 35 entries. The server was healthy again after the start. (On 12.1 on Oct 4 the same step wrote 2.7 MB and 142 entries, from a library created with a different metadata setting, see “Metadata lookups and scan time” below. Both are small: a library with downloaded artwork will be larger.)
Restore, into a separate Compose project. We never touched the live stack for this. A second project, jfrestore, got new empty volumes, the archive was unpacked into its config volume, and it started on port 8097 through an override file:
services:
jellyfin:
ports: !override
- "8097:8096"docker compose -p jfrestore -f compose.yaml -f override-restore.yaml create
docker run --rm --entrypoint tar -v jfrestore_config:/config -v "$PWD/backup":/backup:ro jellyfin/jellyfin:12.2 xzf /backup/jellyfin-config.tgz -C /config
docker compose -p jfrestore -f compose.yaml -f override-restore.yaml up -d --waitIt was healthy after 10.8 s. The !override tag needs Docker Compose 2.24 or newer (our run used 5.3.1); on an older version, change the published port in a copy of the file instead. We compared the two servers by API: the same server ID, the same user (the same password logged in), the same library, 13 movies, and the played and favorite flags we had set. A transcode also started on the restored copy, which had an empty cache. The live container kept running the whole time (same start time, no restart). Then we removed the copy with down -v.
Built-in backup. The docs say Jellyfin 10.11 added a backup you can create while the server runs: Dashboard, Backups tab, Create Backup. You choose to include metadata, subtitles and trickplay data, and the database is always in. Not run in our lab: the dashboard buttons. We called the API behind them: POST /Backup/Create with the database and metadata included returned in 0.6 s and wrote a 21 KB zip into /config/data/backups, which is where the docs say the official image puts it. The zip held 44 entries: JSON exports of the database, the XML config files, Data, Root and a manifest. To test the restore without touching the live stack, we copied the zip into the restored project, removed the played and favorite flags there, and called POST /Backup/Restore. The server re-initialized inside the same container (no container restart) and the flags came back. Not run in our lab: the web UI’s restore button, --restore-archive, restoring over a changed live install, and restoring a backup into a newer version.
On 12.2, a config volume restored without its database file does not start an empty server: Jellyfin stops with a fatal error (see Update below). A backup you have not restored into an empty volume is a guess. The same rule applies in our Nextcloud restore test. Your media folder is not in either backup, so back it up separately.
Update
Jellyfin’s container docs list the tags: X.Y tracks a minor version, and latest crosses major versions. On Oct 6, 2026 12.2 was the newest release on Docker Hub (the 12.2 release notes call it a minor release with bug fixes and say “please ensure you take a full backup before upgrading!”). We ran the update from the previous release: a stack on 12.1 with a scanned library and our marker flags (one film played and favorite), stopped; its config volume copied into a second project whose file pins 12.2; then:
docker compose pull
docker compose up -d --waitThe update took 10.9 s. The server reported 12.2.0 afterwards, the log showed four migrations (FixNullEncoderPreset, MakeOwnerIdIndexesPartial, HarmonizeConflictingUserData and MigrateRatingLevels), and the server ID, user, library, 13 movies and flags were unchanged (lab/notes/jellyfin-2026-10-06.md, section 16). Our earlier test, 12.0 to 12.1 on Oct 4, edited the tag in place and ran three migrations with the data intact (lab/notes/jellyfin-2026-10-04.md, section 16).
The backup docs say that “Jellyfin does not have a downgrade mechanism” and that once a new version starts, pending migrations are applied immediately and your data “will no longer work with the old version”. To see what that means for a minor update, we set the tag back to 12.1 on the migrated copy: it started healthy and showed the same data, with no migration lines in the log (notes section 17). The same worked from 12.1 back to 12.0 on Oct 4. That is two minor updates with three or four small migrations each, one test each, and is not a promise. Take the backup first, as the docs say. Not run in our lab: updates from 10.x, from 12.0 straight to 12.2, and to a later 12.x patch.
What is in 12.2. The release page (Oct 5, 2026, read through a web-fetch tool on Oct 6) lists 51 changes and no breaking change. The items that touch what this page measures or tells you:
- Database. The OwnerId indexes are made partial (the
MakeOwnerIdIndexesPartialmigration we saw run), orphaned item values are cleaned up after deletion, and theChangeOwnerIdToGuidmigration is faster. The one item about CPU or memory use is “Stop rebuilding the inherited-tag set per row, and count only the people /Persons can return” (a query path our test does not run). Nothing on this list points at idle or peak RAM, and our 12.1 control on the same host measured the same idle and peak (see the run-to-run bullet above). - Transcoding. A fix for video quality at low bitrates when reducing bit depth,
hvc1for Dolby Vision profile 8, the stereo downmix filter applied consistently to progressive audio transcodes, and a fix for the segment cleaner during MKV remuxing. Our test transcodes 8-bit H.264 and copies the audio, so it exercises none of them. The ffmpeg in the image went from 8.1.2 to 8.1.3. On the sandbox host in one session, one-stream speed was level between the versions (7.07x on 12.2, 7.19x on 12.1). - Startup. “Refuse to start a set-up server whose database is missing or empty.” We tried the missing case on a set-up stack by deleting
data/jellyfin.dband its-shmand-walfiles from the config volume. 12.2 stopped with a fatal error that says the server “has been set up before” but “the database does not exist” and that Jellyfin “will not start an existing server with an empty database”. 12.1 on the same test also failed to start, with a SQLiteno such table: __EFMigrationsHistoryerror (lab/notes/jellyfin-2026-10-06.md, section 26b). In both cases the container kept restarting underrestart: unless-stoppedanddocker compose up --waitreported it unhealthy. So a restore that misses the database fails loudly rather than starting an empty server. We did not test a database file that exists but is empty. - Security. None listed on the release page.
What we did not measure
- Real clients: a browser, the Android or Apple TV apps, a phone. The lab’s player is a script that fetches HLS segments.
- Hardware acceleration of any kind, which needs a GPU the lab does not have.
- Direct play and direct stream, audio transcoding, subtitle burn-in, HDR tone mapping and 4K sources.
- Simultaneous transcodes on a host with more than 2 vCPUs, and on sources other than our one synthetic 1080p H.264 film (real films, 10-bit HEVC). The ladder ran on one CPU model, an Intel Xeon Platinum 8573C.
- A normal metadata setup (online lookups for posters and descriptions), and libraries bigger than 13 films.
- Client autodiscovery on
7359/udp, DLNA, and Jellyfin behind a reverse proxy. - Plugins, several users with their own libraries, ARM hosts, and memory growth over days.
The RAM advice in the module below comes from the memory we measured with one transcode. It says nothing about CPU. If your players will transcode, count vCPUs first: on our 2-vCPU lab host 2 simultaneous transcodes ran at about 1.6x real time, 3 just kept up and 4 did not (see How many streams can it transcode?, which also gives the RAM for 2 and 3 streams: the 2 GB tier). Remember that those speeds are for our host only. We did not test a VPS or any other host.
Where to run it
- Hardware. Jellyfin idled at 233 MiB and peaked at 508 MiB under load (medians of 3 runs) in our lab. The highest reading in any run was 533 MiB. Plan for at least 1 GB of RAM, sized from the highest run.
- VPS. The smallest plan that runs this comfortably has 1 GB of RAM, plus disk for your data and the images. Plan for about 2.8 GB for the images (Docker 29 and later with the containerd image store, the default on fresh installs; 1.7 GB with the older overlay2 store).
- Keep it safe. Back up the data volume before you rely on it, and put the app behind a reverse proxy with HTTPS before you expose it to the internet. See backing up self-hosted apps.
How we size: recommended RAM is the highest reading in any of our runs (under load or during startup, not the median) times 1.5, rounded up to the next of 1, 2, 4, 8, 16 or 32 GB. The extra half covers the operating system, Docker, file cache and spikes our sampling can miss. Your own data and extra apps need more. Disk for images: Docker Engine 29 and later uses the containerd image store on fresh installs, which keeps each image twice (as downloaded and unpacked); older installs use overlay2, which keeps it unpacked only. We plan for the larger figure: unpacked plus compressed size, plus 10%, rounded up. Your data volumes and growth come on top.
Related
- Photos or video: Jellyfin serves films and shows. For a photo and video library from your phone with machine-learning search, see the Immich Docker Compose guide and its RAM numbers. We did not compare the two apps’ features.
- Files and sync rather than a media library: the Nextcloud Docker Compose guide, which has the same style of restore test.
- HTTPS in front of a Compose stack with Caddy: the Vaultwarden Docker Compose guide.
- Home automation next to the media server: the Home Assistant Docker Compose guide, a Container install that uses host networking instead of published ports, with an update test and a restore into a separate project.
- A network-wide DNS ad blocker for the same box: AdGuard Home vs Pi-hole v6, idle 9.3 MiB for Pi-hole and 39.3 MiB for AdGuard Home. We did not run either next to Jellyfin.
- How the lab measures RAM, CPU and image sizes: methodology. Other apps in the same format: the self-hosted apps index.
- Official sources used on this page, read Oct 4, 2026 and re-checked on Oct 6, 2026: the Jellyfin docs on container installation, networking, backup and restore, hardware acceleration (with the Intel and NVIDIA pages), the 12.2 release, the Docker Hub tags and the Jellyfin API reference.
Update log
- Linked the new AdGuard Home vs Pi-hole v6 page from Related. No measurements changed.
- The table of simultaneous transcodes is back, measured on the 2-vCPU lab host (GitHub Actions runner, Intel Xeon Platinum 8573C), median of 3 runs of a new ladder test (lab app jellyfin-ladder, same Compose file and Jellyfin 12.2). 1, 2 and 3 players kept up in all 3 runs (ffmpeg at 3.05x to 3.14x, 1.58x to 1.60x and 1.03x to 1.06x); 4 players failed in all 3 runs (0.77x to 0.79x, 51 to 53 late segments), so 6 and 8 were not run. Stack RAM peaked at 566, 810, 1,031 and 1,204 MiB for 1 to 4 players. New advice: plan for 2 simultaneous transcodes on 2 vCPUs. Replaces the sentence that said we had not measured this and the rough estimate of about three streams (the measurement agrees with it). The idle, peak and one-stream figures were not re-measured; the Test box is unchanged.
- Disk sizing now covers both Docker image stores: plan for about 2.8 GB for the image (unpacked plus compressed size plus 10%, for Docker 29 and later with the containerd store), 1.7 GB on overlay2; was 1.7 GB only. Dated the sandbox proxy remark (observed Oct 4, 2026). No new measurements.
- Re-tested on our new standard lab host (GitHub Actions runner, 2 vCPU / 8 GB), median of 3 runs, same Jellyfin 12.2. Idle 233 MiB (range 206 to 235; was 225), peak 508 MiB (range 485 to 533; was 479), startup 10.9 s (was 11.1). One 1080p-to-720p software transcode ran at 3.07x real time (range 3.04 to 3.08) and used about 198% of the two vCPUs; on the earlier 4-vCPU host it ran at 6.2x. The table of 1 to 10 concurrent transcodes and the advice to plan for 4 streams were measured on that earlier host (Oct 4 to 6) and do not apply to 2 vCPUs, so we removed them; the stream ladder will be re-measured on the new host. The manual checks (permissions, backup, restore, update) are from the earlier sandbox host and were not repeated. The title no longer says transcode limits.
- Linked the new Home Assistant Docker Compose page from Related. No measurements changed.
- Results re-measured as the median of 3 runs (methodology change 2026-10-06): idle 225 MiB (range 222 to 260), peak 479 MiB (range 478 to 530), startup 11 s (range 11 to 29), one-stream speed 6.2x (range 6.2 to 6.5). The earlier entry of this date quotes the single run it was based on (268 and 533 MiB). The new runs landed 3 to 17% below that run on idle RAM, 10% below on peak RAM and about 12% below on transcode speed, which is the host-to-host and session-to-session spread the three-run method exists to show. The manual transcode and backup results further down are from earlier on Oct 6 and were not repeated.
- Re-tested on Jellyfin 12.2 (was 12.1), a minor release of Oct 5 with 51 changes and no breaking change listed. Idle RAM 268 MiB (was 189) and peak 533 MiB (was 444), but the same stack idled at 234 to 270 MiB across runs on the new test host, 12.1 included, so we do not credit the rise to 12.2. One-stream speed 7.07x (was 7.04x). Concurrent transcodes: 8 players no longer kept up (0.93x to 0.97x, was 0.99x to 1.01x), the other rows moved by about 7% or less. The 12.1 to 12.2 update ran four migrations with the data intact. The test host reports a 2.10GHz CPU model (was 2.80GHz).
- First published. Jellyfin 12.1 (server 12.1.0) verified in the lab with 13 generated test films: wizard through the API, a library scan, one software transcode, up to 10 concurrent transcodes, user and permission checks, a manual backup restored into a separate Compose project, the built-in backup, and an update from 12.0. Hardware acceleration is researched from the Jellyfin docs and not tested.