Jellyfin on TrueNAS with media on HDD and everything else on NVMe
Media wants capacity, the database wants random IO, and putting both on the same pool makes the interface feel slow for reasons no bandwidth graph shows. A complete build with the two split, plus why a fresh config beats migrating an old one.
Before you start
- TrueNAS SCALE with two pools: an NVMe pool and an HDD pool
- Docker available on the host
- Shell access with permission to create datasets
- Media either already on the HDD pool, or ready to be copied there
Your values
Fill these in and every command below updates to match. Nothing is sent anywhere.
Run this at your own risk. These steps worked on my hardware; yours may differ. Take a backup first, read each command before running it, and see the disclaimer.
Jellyfin has two completely different storage appetites and most setups ignore the difference.
The media is large, written once, and read sequentially. It belongs on spinning disks, where capacity is cheap.
The config directory, the metadata database, the image cache and the transcode scratch space are small, and hammered with random reads and writes every time anybody browses the library or starts a stream. On the same spinning array, those small operations queue behind the big sequential ones and the interface feels sluggish for reasons that never show up in a bandwidth graph.
This guide builds Jellyfin with those two workloads on separate pools: media on a RAIDZ HDD pool, everything Jellyfin thinks with on NVMe.
It also explains why the config directory should be created fresh rather than carried across from a previous install, because that is the decision that turns this from a weekend into an evening.
Plan the layout before creating anything
Four separate datasets, not one. Separating them means you can snapshot, replicate and destroy them independently - and destroying the cache is a routine operation you will want to do without touching anything else.
| Dataset | Pool | Holds |
|---|---|---|
{{NVME_POOL}}/apps/jellyfin/config |
NVMe | Database, settings, users, library definitions |
{{NVME_POOL}}/apps/jellyfin/cache |
NVMe | Images, chapter thumbnails, transient metadata |
{{NVME_POOL}}/apps/jellyfin/transcode |
NVMe | Scratch space while transcoding |
{{HDD_POOL}}/media |
HDD | The media itself |
The transcode directory earns its place on NVMe. Transcoding writes segments continuously for the whole duration of a stream, and putting that on the media array means every playback session competes with itself.
A separate transcode dataset does nothing on its own. Jellyfin writes transcode segments underneath its cache directory unless you explicitly set Transcode path in the transcoding settings. Mount a transcode dataset and forget that step, and it sits there empty while transcodes go to the cache.
So is the fourth dataset worth it? Not for speed. With cache already on NVMe, transcodes are on NVMe either way, and separating them changes nothing you can measure during playback.
It is worth it for three operational reasons, and only if any of them apply to you:
- A quota. A stuck transcode, or several at once, can write a surprising amount of scratch data. A dataset with a quota cannot fill the pool that also holds your application config.
- Snapshots. If you snapshot the cache dataset, you are capturing transient transcode segments along with it, and those snapshots hold on to that space long after the files are gone. A separate dataset can simply be left out of the snapshot schedule.
- Clearing one without the other. Emptying transcode scratch becomes independent of clearing the image cache.
If you do not snapshot these datasets and your NVMe pool has room to spare, leaving Transcode path blank is a perfectly good answer, and one fewer thing to get wrong. If you do add it, set the path as described below and then play something that actually transcodes, to confirm files appear there. A transcode path pointing at a mount that does not exist, or that the container user cannot write to, breaks playback rather than falling back gracefully.
Create the datasets
zfs create -p nvme/apps/jellyfin/config
zfs create -p nvme/apps/jellyfin/cache
zfs create -p nvme/apps/jellyfin/transcode
zfs create -p tank/mediaThen the media libraries themselves, one dataset each so they can be snapshotted and shared separately:
zfs create tank/media/movies
zfs create tank/media/tv
zfs create tank/media/kids
zfs create tank/media/music-videosUse zfs create, and later zfs rename, for datasets. Do not use mv to
reorganise them - mv moves the contents between filesystems rather than
renaming the dataset, which on a large library means copying terabytes to
achieve what a rename does instantly.
Decide now: fresh config, not a migrated one
If you are coming from an existing Jellyfin install, this is the important decision, and the temptation is to copy the old config directory across so you keep watch history and users.
Here is what you are actually inheriting.
Jellyfin stores the absolute internal path of every library. Not the file
names - the paths as the container saw them. If the old container mounted media
at a different path, or the same path with different capitalisation or spacing,
every library resolves to nothing. /Media/TV Shows and /media/tv are
unrelated places as far as the database is concerned.
It also stores libraries by GUID. A library that was ever deleted and recreated has a new GUID, and anything still referencing the old one silently resolves to nothing.
The config directory contains marker files claiming a directory is already set up. Copied to a new install, they describe a layout that does not exist, and the container can crash on start.
And there are link files associating collections and playlists with items. Stale ones point at items that no longer exist, and fail quietly.
Quietly is the pattern. Almost none of this produces a useful error. You fix one thing, another appears, and it is 2am.
What a fresh config actually costs you: watch history, user accounts, and any manual metadata edits. What it does not cost you: your media, which was never in the database. A fresh instance rescans and rebuilds the libraries unattended while you sleep.
If you have already fixed two problems and a third has appeared, stop and rebuild. The sunk cost is the trap.
Deploy the container
Pin the version. An unpinned latest means an unattended update can change
behaviour on a Tuesday evening for no reason you will connect to the cause.
mkdir -p /mnt/nvme/apps/jellyfin
cat > /mnt/nvme/apps/jellyfin/compose.yaml <<'EOF'
services:
jellyfin:
image: jellyfin/jellyfin:12.0
container_name: jellyfin
restart: unless-stopped
network_mode: host
user: "1000:1000"
environment:
- TZ=Etc/UTC
- JELLYFIN_CACHE_DIR=/cache
volumes:
- /mnt/nvme/apps/jellyfin/config:/config
- /mnt/nvme/apps/jellyfin/cache:/cache
- /mnt/nvme/apps/jellyfin/transcode:/transcode
- /mnt/tank/media:/media:ro
EOFTwo choices worth understanding.
network_mode: host is what makes DLNA and client auto-discovery work. Drop it
and Jellyfin still serves fine over HTTP, but clients stop finding it by
themselves.
The media mount is read-only. Jellyfin does not need to write to your media,
and a media server with write access to the library is one bug away from
deleting things. If you later want Jellyfin to write .nfo metadata beside your
files, change it deliberately then, not now.
Start it
cd /mnt/nvme/apps/jellyfin
docker compose up -d
docker compose logs -f[INF] Jellyfin version: 10.11.11
[INF] Application directory: /config
[INF] Cache directory: /cache
[INF] Startup complete 0:00:06.83Stop following the logs with Ctrl+C once you see startup complete. The
container keeps running.
Add the libraries, carefully
In the setup wizard, add each library pointing at the path inside the
container - /media/movies, not the host path.
Write down exactly what you enter. These strings are what the database stores forever, and matching them again later is the single most common cause of empty libraries after any future change. If you ever move the data, change the mount so the container path stays identical, rather than editing the library.
For the same reason, decide your internal paths once and keep them. /media/tv
today and /media/TV Shows next year are two different libraries.
Verify
docker exec jellyfin ls -la /mediadrwxr-xr-x 2 1000 1000 4 Sep 12 21:14 kids
drwxr-xr-x 2 1000 1000 4 Sep 12 21:14 movies
drwxr-xr-x 2 1000 1000 4 Sep 12 21:14 music-videos
drwxr-xr-x 2 1000 1000 4 Sep 12 21:14 tvIf the container cannot see the files, no amount of configuration in the web interface will help. Check this before touching anything else.
Confirm the config and cache are landing on NVMe rather than quietly falling back somewhere else:
docker inspect -f '{{{{range .Mounts}}}}{{{{.Destination}}}} <- {{{{.Source}}}}{{{{"\n"}}}}{{{{end}}}}' jellyfinClearing the cache is safe. Clearing the config is not.
Worth knowing the difference, because they sit next to each other.
The cache is regenerable. If images are stale or the metadata looks wrong, stopping the container and emptying the cache dataset costs you a rescan and nothing else.
The config holds your users, your watch history and your library definitions. Emptying it is the rebuild described earlier.
docker compose stop jellyfin
rm -rf /mnt/nvme/apps/jellyfin/cache/*
docker compose start jellyfinTranscoding: check the GPU is reachable before tuning anything
This section is deliberately in this order, because I had it backwards and lost a lot of evenings to it.
Before changing a single transcoding setting, prove the container can see the GPU. Two commands:
docker exec {{CONTAINER_NAME}} ls -l /dev/dri
docker exec {{CONTAINER_NAME}} idcrw-rw---- 1 root video 226, 0 card0
crw-rw---- 1 root 107 226, 128 renderD128
uid=568 gid=568 groups=568,44(video),107Two things must both be true. The device nodes must exist inside the container,
and the container's group list must include the group that owns renderD128 -
107 in the output above. On the host, stat -c '%g %G' /dev/dri/renderD128
tells you which group that is.
If /dev/dri does not exist in the container, no setting on the Jellyfin
transcoding page can do anything. If the device exists but the group is missing,
ffmpeg cannot open it and transcodes fail the moment they start.
Why this hides so well
You can run for months with hardware acceleration selected, believing it works, because most playback never asks for it:
- A capable client direct plays - the server sends the file untouched
- A browser usually copies the video and converts only the audio, because the video is fine but the audio codec is not
Neither touches the GPU. Playback is fast, the server is quiet, and everything looks healthy. The GPU only gets asked for anything when a real video re-encode is needed - and if it is not reachable, that is the moment playback dies with an error, long after you stopped suspecting the configuration.
Watch what is actually happening
Run this on the host while you play something. It needs nothing installed.
while true; do
line=$(docker top {{CONTAINER_NAME}} 2>/dev/null | grep -i ffmpeg)
g=$(cat /sys/class/drm/card0/gt_act_freq_mhz 2>/dev/null \
|| cat /sys/class/drm/card0/gt/gt0/rps_act_freq_mhz 2>/dev/null || echo n/a)
if [ -n "$line" ]; then
c=$(echo "$line" | grep -oE 'qsv|vaapi|libx264|libx265' | sort -u | tr '\n' ' ')
echo "$(date +%T) ffmpeg running codecs=[$c] gpu=${g}MHz"
else
echo "$(date +%T) idle or copying gpu=${g}MHz"
fi
sleep 2
doneWorking hardware transcoding looks like this:
17:37:15 idle or copying gpu=0MHz
17:37:17 ffmpeg running codecs=[qsv vaapi ] gpu=1100MHz
17:37:46 ffmpeg running codecs=[qsv vaapi ] gpu=1100MHz
17:37:50 ffmpeg running codecs=[qsv vaapi ] gpu=0MHzHow to read it:
codecs=[qsv vaapi]- the hardware path is genuinely engagedcodecs=[libx264]- software encoding, whatever the settings page claimscodecs=[]with ffmpeg running - the video is being copied. Only the audio is being converted. The GPU is idle because there is no video work, and this is the fastest outcome available.gpu=0MHzwhileqsvis running - throttling working. The encoder has built its buffer and paused. Without throttling it would sit at full clock until the entire film was converted.
Those last two catch people out, because both look like hardware acceleration has failed when in fact one means it was not needed and the other means it is being polite. Watch for a minute before concluding anything: a single sample tells you almost nothing.
Changing the quality setting mid-playback switches between these states, which makes it a good way to see all of them in one sitting. Original quality usually copies; a forced lower quality usually transcodes.
Read the ffmpeg command line
The single most informative command, run while something plays:
ps -eo args | grep ffmpeg | grep -v grepWhat to look for:
-codec:v:0 copy- the video is being copied, not transcoded. Only the audio is being converted. The GPU is idle because there is nothing to do, and this is the fastest possible outcome.h264_qsv,hevc_qsv,-hwaccel vaapi- hardware doing real worklibx264- software encoding-ac 2- the audio is being folded down to stereo, which browsers force. Use a desktop client if you want surround.
Only now, the settings
Hardware acceleration. Select QuickSync for an Intel iGPU - after the device check above passes, not before.
Enable hardware decoding: tick HEVC as well as HEVC 10bit. This trap is easy to miss. Ticking only the 10-bit box means 8-bit HEVC files - a large share of 4K content - fall back to software decoding while 10-bit files get hardware. The result is performance that is good on some files and poor on others, with no obvious pattern.
Enable hardware encoding. Leave the Intel Low-Power encoders off unless you have confirmed the i915 HuC firmware is loaded; the interface warns about this and the warning is real.
Throttle Transcodes: on. Without it the encoder converts the whole film at full speed the moment playback starts, pinning the GPU for minutes.
Delete segments: on. Otherwise every segment of a 4K transcode stays on disk until the session ends.
Transcode path. Leave blank, or set /cache/transcodes. See below.
Encoding format options. Leave Allow encoding in HEVC and AV1 unchecked
unless you have a reason. They let Jellyfin transcode into those formats, which
costs more and plays on less. H.264 is always available and universally
supported.
Tone mapping only applies when HDR content is genuinely being transcoded. Without it, HDR-to-SDR output looks washed out rather than failing outright. On Intel, try VPP tone mapping first - it uses the fixed-function path instead of needing the OpenCL runtime.
The setting that beats all of them is not on this page. A transcode that never happens is faster than any transcode. If one file always struggles, find out why the client cannot direct play it - often the audio track or the container rather than the video - and keep a compatible copy instead of converting it live, every time, forever.
What this layout gives you afterwards
Snapshot the config dataset before any Jellyfin upgrade and a bad release is a rollback rather than a rebuild. Replicate config without dragging terabytes of media along. Destroy and recreate the cache whenever it misbehaves, without touching anything that matters. And keep the transcode churn off the disks holding your media.
None of that is possible if it all lives in one directory on one pool.