I recently needed to move my Jellyfin media server installation from a Docker container to a full installation in order to use hardware acceleration for transcoding because the Docker image doesn’t include the right drivers for my GPU (AMD RX 5700), and adding them would be more difficult than setting it all up myself.

I couldn’t find any instructions or guidance for this online aside from “don’t”, so here’s the steps I took that worked for me.

Note that I am moving from Jellyfin 10.8 in Docker to Jellyfin 10.9 (currently unstable) in Linux, so these instructions may not work for significantly newer or older versions.

Why am I running 10.9 when it’s still unstable?

Let’s ask the Jellyfin hardware acceleration documentation:

Jellyfin 10.9 enables full acceleration for AMD Vega and newer GPUs on Linux via VA-API and Vulkan interop.

1. Operating system installation

As much as I hate the direction Ubuntu has gone with snaps and all, it’s the only free Linux distro that AMD offers official drivers for, so I started by installing Ubuntu Server 22.04 LTS in a virtual machine (though this guide will still work if you’re installing Jellyfin in a bare-metal OS).

I say this because choosing a distro other than Debian or Ubuntu might change the destination file paths. In that case, check out the Jellyfin server paths documentation to find the right locations.

2. Media files

To make everything else easier, I mounted my media files in the VM at the same location they were mounted to in the Docker container (/mnt/media). I did this by sharing a folder into the VM with my hypervisor, but you can also use a physical drive, network share, or symlink to make the path match your Docker media path.

If you happen to be running a VM with qemu, you can pass through a directory in 9p mode. Here’s the /etc/fstab incantation for the guest OS to make it work:

media /mnt/media 9p trans=virtio,version=9p2000.L,_netdev,rw 0 0

The leading media is the share name set in the hypervisor and /mnt/media is where it will appear when mounted.

3. Jellyfin installation

Next, I installed Jellyfin by following the official instructions for installing on Ubuntu using a repository. Other methods and distros are listed on that page.

It basically comes down to running this as the user you want to run Jellyfin under:

curl https://repo.jellyfin.org/install-debuntu.sh | sudo bash

Make sure Jellyfin is running with sudo systemctl start jellyfin and access it at system’s IP, port 8096. If it won’t load the setup wizard, troubleshoot your installation before continuing.

You can view the Jellyfin server log with sudo journalctl -u jellyfin for more details of what’s going wrong.

Following the setup wizard to completion is optional and doesn’t matter.

Now that we know it works, stop Jellyfin with sudo systemctl stop jellyfin so it doesn’t clobber any of the files we’re about to touch.

It’s also a good idea to create backup copies of /etc/jellyfin and /var/lib/jellyfin. If something goes wrong, you can restore these copies to start from scratch without purging and reinstalling Jellyfin.

Switching to unstable

First of all: don’t.

Edit /etc/apt/sources.list.d/jellyfin.sources and replace the Components: main line with Components: unstable, then run sudo apt update && sudo apt dist-upgrade.

Make sure you have a backup of /etc/jellyfin and /var/lib/jellyfin before you switch to unstable because switching back might not be possible!

4. Copying over data

Stop the existing Jellyfin Docker container if it’s not already. This will make it finish its disk writes so we don’t copy incomplete files.

Copy these files from the Docker container host system to the new Linux installation:

  • Contents of /config to /etc/jellyfin
  • Contents /data to /var/lib/jellyfin

To do this, I ran tar -czf jellyfin-config.tar.gz ./config (repeated for data) on the old host, copied over the archive, and then ran tar -xzf jellyfin-config.tar.gz to extract it onto the new system.

Then make sure you’re copying files to the right places by comparing filenames between your backups of /etc/jellyfin and /var/lib/jellyfin with the filenames in your copied and extracted archives. There may be some missing on one or the other.

At this point, you can start Jellyfin again with sudo systemctl start jellyfin. You should be able to log in with your old account information. If not, something has gone wrong and you should try again and double check the files are in the right places.

Again, you can run sudo journalctl -u jellyfin to view the Jellyfin server log.

If it’s complaining about missing files, run these commands and to find references to the old file locations and edit the files with vi or nano to update them:

  • grep -RF '/config' /etc/jellyfin
  • grep -RF '/data' /etc/jellyfin
  • grep -RF '/config' /var/lib/jellyfin
  • grep -RF '/data' /var/lib/jellyfin

Remember to restart Jellyfin with sudo systemctl restart jellyfin after making changes.

5. Updating stored paths

At this point, Jellyfin should be working enough to browse media. However, library scanning operations will immediately fail because the database still contains references to the old folder structure. To fix this, we will need to modify the database directly.

Install SQLite by running sudo apt install sqlite3 or your distro’s equivalent.

Stop Jellyfin with sudo systemctl stop jellyfin.

Create a backup of the library database. For example: cp /var/lib/jellyfin/data/library.db /var/lib/jellyfin/data/library.db.bak

Replace the paths in the library database by running:

sqlite3 /var/lib/jellyfin/data/library.db \
"UPDATE TypedBaseItems SET data = replace(data, '/config', '/var/lib/jellyfin') WHERE data LIKE '/config%'; UPDATE TypedBaseItems SET Path = replace(Path, '/config', '/var/lib/jellyfin') WHERE Path LIKE '/config%';"

Create a backup of the configuration database. For example: cp /var/lib/jellyfin/data/jellyfin.db /var/lib/jellyfin/data/jellyfin.db.bak

Replace the paths in the configuration database by running:

sqlite3 /var/lib/jellyfin/data/jellyfin.db \
"UPDATE ImageInfos SET Path = replace(Path, '/config', '/var/lib/jellyfin') WHERE Path LIKE '/config%';"

Where did this command come from?

Short answer: I wrote this command at 1am while on an edible, so make sure you followed my advice above to back up library.db first ;)

Long answer:

When the media file scanning failed, many lines like this appeared in the log output:

System.IO.DirectoryNotFoundException: Could not find a part of the path '/config/root/default/Collections'

Since /config was the old path and no longer existed, I needed to figure out why it was still looking there. Running each of the grep commands above, grep -RF '/config' /var/lib/jellyfin found a match in /var/lib/jellyfin/data/library.db and /var/lib/jellyfin/data/jellyfin.db, both binary files.

I knew this was an SQLite database from the account unlocking instructions, so I started exploring it:

$ sqlite3 /var/lib/jellyfin/data/library.db
> .tables
AncestorIds       ItemValues        TypedBaseItems    mediaattachments
Chapters2         People            UserDatas         mediastreams

But which table(s) stored /config? Searching the entire database with sqlite3 /var/lib/jellyfin/data/library.db .dump | grep -F --text '/config' showed it appeared at least in the TypedBaseItems table.

Now to figure out which column(s) it appears in:

$ sqlite3 /var/lib/jellyfin/data/library.db
> .schema TypedBaseItems
CREATE TABLE TypedBaseItems (guid GUID primary key NOT NULL, type TEXT NOT NULL, data BLOB NULL, ParentId GUID NULL, Path TEXT NULL, StartDate DATETIME NULL, EndDate DATETIME NULL, ChannelId Text NULL, IsMovie BIT NULL, CommunityRating Float NULL, CustomRating Text NULL, IndexNumber INT NULL, IsLocked BIT NULL, Name Text NULL, OfficialRating Text NULL, MediaType Text NULL, Overview Text NULL, ParentIndexNumber INT NULL, PremiereDate DATETIME NULL, ProductionYear INT NULL, Genres Text NULL, SortName Text NULL, ForcedSortName Text NULL, RunTimeTicks BIGINT NULL, DateCreated DATETIME NULL, DateModified DATETIME NULL, IsSeries BIT NULL, EpisodeTitle Text NULL, IsRepeat BIT NULL, PreferredMetadataLanguage Text NULL, PreferredMetadataCountryCode Text NULL, DateLastRefreshed DATETIME NULL, DateLastSaved DATETIME NULL, IsInMixedFolder BIT NULL, LockedFields Text NULL, Studios Text NULL, Audio Text NULL, ExternalServiceId Text NULL, Tags Text NULL, IsFolder BIT NULL, InheritedParentalRatingValue INT NULL, UnratedType Text NULL, TopParentId Text NULL, TrailerTypes Text NULL, CriticRating Float NULL, CleanName Text NULL, PresentationUniqueKey Text NULL, OriginalTitle Text NULL, PrimaryVersionId Text NULL, DateLastMediaAdded DATETIME NULL, Album Text NULL, IsVirtualItem BIT NULL, SeriesName Text NULL, UserDataKey Text NULL, SeasonName Text NULL, SeasonId GUID NULL, SeriesId GUID NULL, ExternalSeriesId Text NULL, Tagline Text NULL, ProviderIds Text NULL, Images Text NULL, ProductionLocations Text NULL, ExtraIds Text NULL, TotalBitrate INT NULL, ExtraType Text NULL, Artists Text NULL, AlbumArtists Text NULL, ExternalId Text NULL, SeriesPresentationUniqueKey Text NULL, ShowId Text NULL, OwnerId Text NULL, Width INT NULL, Height INT NULL, Size BIGINT NULL, LUFS Float NULL);

The Path column looks promising, but let’s search all of them to be safe:

sqlite3 -header -line /var/lib/jellyfin/data/library.db \
'SELECT * FROM TypedBaseItems;' \
| grep -F --text '/config'

This output included both the data and Path columns, so my command runs a find and replace operation in both.

Repeat for jellyfin.db.

6. Testing

Start Jellyfin with sudo systemctl start jellyfin and make sure library scanning works by going to Menu > Dashboard > Scheduled Tasks and clicking the play button next to “Scan Media Library”.

And now Jellyfin should fully work in its new environment.