On this page (9)
  1. Verified compose file
  2. Install steps
  3. Measured resources
  4. Transcode speed on 2 vCPUs
  5. How many streams can it transcode?
  6. Hardware acceleration (researched, not verified in our lab)
  7. Gotchas, upgrade and backup
  8. What we did not measure
  9. Related

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.
Per-container figures for Jellyfin: median over 3 runs, with the range
ContainerIdle RAMPeak RAM (under load)Image, compressed (download)Image, unpacked (disk, overlay2 store)
jellyfin233 MiBrange 206–235 · 3 runs508 MiBrange 485–533 · 3 runs754 MB1.7 GB
Every run (3)
Total RAM, startup and CPU model of each run of Jellyfin
RunIdle RAMPeak RAMStartup peakStartupLab host CPU
1206 MiB485 MiB221 MiB11 sAMD EPYC 7763 64-Core Processor
2235 MiB508 MiB240 MiB11 sAMD EPYC 7763 64-Core Processor
3 (closest to median idle)233 MiB533 MiB245 MiB11 sAMD EPYC 7763 64-Core Processor
Total RAM after the stack became healthy (10 s averages, run 3 of 3)
Total RAM across all containers in 10 s averages, from the moment the stack was healthy until the end of the idle window (160 s). Run 3 of 3, the one closest to the median idle. Highest 10 s average 244 MiB at 20 s, last 236 MiB at 160 s. Dashed line: the stack counted as settled at 125 s.

0 s to 160 s after healthy; the line spans 0 to 244 MiB. Dashed line: settled.

Timeline as a table
Total RAM, 10 s average ending at each time, every 30 s after healthy
Seconds after healthyTotal RAM
30244 MiB
60232 MiB
90227 MiB
120228 MiB
150234 MiB
160236 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.

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 the 12.2, 12 and latest tags on Docker Hub pointed at the same image (digest sha256:357724bf..., pushed Oct 5, 2026). The docs say latest follows the latest stable release “including through major and minor version bumps”, so a file that says latest can jump a major version when you pull. The lab resolved the tag and wrote the digest into the file’s comment.
  • Named volumes for /config and /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: curl on /health every 30 s with a 10 s start period. Ours calls the same endpoint every 5 s, so docker compose up --wait returns as soon as the server answers. In our run /health returned HTTP 200 with the body Healthy.
  • Left out: the docs example’s 7359/udp port (client autodiscovery on the local network, see the gotchas), container_name, a user: line, and any GPU device.

The comments in the file are the lab’s.

compose.yamlVerified 2026-10-08 · Jellyfin 12.2Download compose.yaml for Jellyfin
# 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.

  1. Install Docker Engine with the Compose plugin.

  2. Make a folder, save the file above as compose.yaml in it, and put your films in media/movies/, one folder per film.

    bash
    mkdir -p jellyfin/media/movies
    cd jellyfin
  3. Start the server. The first start pulls the image: 754.0 MB compressed (table below). --wait returns when the health check passes.

    bash
    docker compose up -d --wait

    With the image already pulled, this returned after 10.9 s.

  4. 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/health

    Our output on 12.1 for docker compose ps showed Up 10 seconds (healthy) and 0.0.0.0:8096->8096/tcp. The curl call returned HTTP/1.1 200 OK, Content-Type: text/plain and the body Healthy.

  5. 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 the GET (200). Before the wizard, GET /System/Info/Public reported "StartupWizardCompleted": false; afterwards true.

  6. Add your library: type Movies, path /media/movies. Not run in our lab: the browser dialog. The lab did it with POST /Library/VirtualFolders (HTTP 204) and started the first scan with POST /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.

bash
docker compose down

Measured 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, /config was 10 MB and /cache 15 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.

Simultaneous 1080p to 720p software transcodes on the lab host: kept up or not, ffmpeg speed, late segments, delay to the first segment and RAM, median of 3 runs with the range
PlayersKept up? (runs)ffmpeg speed, median player (x real time, lab host)Late segments (all players)First segment, slowest playerStack RAM, peak (MiB)
1Yes, 3 of 3 runs3.12x (range 3.05 to 3.14)03.0 s566 MiB (range 542 to 572)
2Yes, 3 of 3 runs1.58x (range 1.58 to 1.60)05.6 s (range 4.9 to 5.6)810 MiB (range 790 to 823)
3Yes, 3 of 3 runs1.06x (range 1.03 to 1.06)07.2 s (range 7.0 to 7.2)1,031 MiB (range 983 to 1,039)
4No, 0 of 3 runs0.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, 8Not 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.

yaml
services:
  jellyfin:
    user: 1000:1000
    group_add:
      - '122'
    devices:
      - /dev/dri/renderD128:/dev/dri/renderD128

The 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.

yaml
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 /config and /cache are world-writable (drwxrwxrwx), and the files Jellyfin wrote there belonged to uid 1000.

  • Bind-mounted /config and /cache that root had created (mode 755): Jellyfin did not start. The container went unhealthy and the log said Access to the path '/config/log' is denied. After chown 1000:1000 on both folders it started and was healthy. The fix is to chown the folders to the user you set.

  • An existing volume that Jellyfin already filled as root: not tested. The files in /config of our root run were owned by root and writable only by root (Oct 4 notes, section 8), so by the same failure a switch to user: "1000:1000" should be refused until the files are chowned. Take a backup and change the owner first, then add the user: 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.

yaml
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.

bash
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 jellyfin

The 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:

yaml
services:
  jellyfin:
    ports: !override
      - "8097:8096"
bash
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 --wait

It 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:

bash
docker compose pull
docker compose up -d --wait

The 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 MakeOwnerIdIndexesPartial migration we saw run), orphaned item values are cleaned up after deletion, and the ChangeOwnerIdToGuid migration 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, hvc1 for 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.db and its -shm and -wal files 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 SQLite no such table: __EFMigrationsHistory error (lab/notes/jellyfin-2026-10-06.md, section 26b). In both cases the container kept restarting under restart: unless-stopped and docker compose up --wait reported 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.

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.