Add Jellyfin to your arr stack
Add a Jellyfin service to an existing arr stack compose file: matching PUID and PGID, one media mount at the same path, and a port bound to localhost.
Add Jellyfin to an existing arr stack
Adding Jellyfin to your arr stack is one service block in the compose file you already run. Jellyfin needs the same PUID and PGID as Sonarr and Radarr, the same media tree mounted at the same path it has inside every other container, and a /config volume that belongs to Jellyfin alone. The existing services do not change.
The two halves do different jobs. The arr services fetch files and file them into a folder layout. Jellyfin reads that layout and streams it. They share exactly one thing, the media tree, so the media tree is the part to get right. Everything else in this guide follows from that. If you have not built the acquisition half yet, start from a working arr stack in one Docker Compose file and come back here.
Why Jellyfin belongs in the same compose file
Jellyfin is usually published as a standalone compose file, so the common move is to drop that file into its own directory and run it beside the stack. That creates a second Compose project, and a second project means a second default network. The reverse proxy container in the first project cannot resolve the name jellyfin on a network it is not attached to, so you end up publishing ports on the host to get the two halves talking, which is the thing you were trying to avoid.
A second project also drifts. docker compose down run in one directory leaves the other half up. The media path is written out twice, so an edit to one copy silently disagrees with the other, and a disagreement in that path is what breaks imports. Keeping one file keeps one copy of every path.
If you do want the stack split across files for editing reasons, split it the supported way. Layering several compose files into one project keeps a single project name and a single network while letting Jellyfin live in its own file.
The Jellyfin service block
Append this under the existing services: key, at the same indentation as your sonarr block.
services:
jellyfin:
image: lscr.io/linuxserver/jellyfin:latest
container_name: jellyfin
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
volumes:
- /srv/appdata/jellyfin:/config
- /srv/media:/data
ports:
- "127.0.0.1:8096:8096"
restart: unless-stoppedThis is the LinuxServer image, because it reads PUID and PGID the same way the arr images do, so one pair of numbers covers the whole stack. The official jellyfin/jellyfin image works too, but it takes the identity from a compose user: 1000:1000 line instead, and it expects /config plus /cache rather than /config alone.
Two changes from the upstream example are deliberate. The upstream example mounts /path/to/movies and /path/to/tvseries as two separate volumes, and it publishes 8096:8096 on every interface. Both are wrong for a VPS running an arr stack, for reasons in the next two sections.
Use the same PUID and PGID as the arr services
Sonarr writes a file. Jellyfin reads it. That works when both processes run as the same user, or as members of the same group with the right mode bits on the files. Set PUID and PGID on Jellyfin to the exact values your arr services already use, then set TZ to the same zone so the schedules line up.
Run id for the host user that owns the media tree and copy the two numbers out of it.
id mediauser
ls -n /srv/media/moviesls -n prints numeric owner and group instead of names, which is what you want here: the names inside the container do not have to match, the numbers do. Compare those numbers against the PUID and PGID in the compose file. If they differ, Jellyfin's library scan can fail to traverse the directory even though the path is mounted correctly, and the failure appears as an empty library rather than as an obvious error. The mechanics of that mapping, including why the container's own user name is irrelevant, are covered in how PUID and PGID map a container process to host file ownership.
Mount the media tree at the same path in every container
The arr stack works because one mount covers both the download directory and the library directory. When /srv/media holds torrents/ and media/ under it, and every container mounts /srv/media at /data, an import from /data/torrents/movies to /data/media/movies stays inside one mount of one filesystem, so the arr service can create a hardlink. The file is filed into the library and stays seeding, using the disk space once.
Split that into two mounts and the hardlink becomes impossible, because a hardlink cannot cross a filesystem boundary and Docker presents each bind mount as its own boundary. The import silently becomes a copy. You pay for the bytes twice, the move is no longer atomic, and a later edit to one copy does not reach the other. This is the single most common way a working stack is broken while adding a media server, so give Jellyfin the same /srv/media:/data mount and nothing narrower. The full layout, and the checks that prove a hardlink was actually made, are in the arr stack folder structure that keeps hardlinks and atomic moves working.
Jellyfin only reads the tree, so :ro is tempting:
- /srv/media:/data:roBefore you add it, know that Jellyfin can write into the media folder. Downloaded subtitles, saved artwork and trickplay images each have a library setting that chooses between the media folder and Jellyfin's own data directory. Point each of those at Jellyfin's data directory first, then mount read only. If you would rather not audit the settings, leave the mount writable.
Why bind port 8096 to localhost on a VPS
Jellyfin listens on TCP 8096 for plain HTTP. On a first start the setup wizard runs with no account and no password, so anything that can reach the port before you finish the wizard can claim the server. ports: - "8096:8096" binds every interface, including the public one.
A host firewall does not save you here. Docker publishes a port by writing DNAT rules into the nat table, and traffic to a published container port is forwarded rather than delivered locally, so it is never evaluated by the filter INPUT chain that ufw manages. A ufw deny 8096 looks like it closed the port and did not. Binding the published port to 127.0.0.1 is what actually closes it, because the socket is only reachable from the host itself. A reverse proxy container on the same Compose network then reaches Jellyfin over the internal network by its service name, with no published port needed at all. The arr stack port map and the bind address rules covers that in full, and the same rule applies to every service in this file.
Skip the other ports from the upstream example. 7359/udp and 1900/udp are local network discovery, and no client is on the same network segment as a VPS. 8920 is HTTPS served by Jellyfin, which needs a certificate loaded into Jellyfin, so it duplicates work the reverse proxy is already doing. Once the proxy is in front, set JELLYFIN_PublishedServerUrl to the public URL so clients are handed the right address.
Start the new service without restarting the stack
docker compose config
docker compose up -d jellyfin
docker compose psdocker compose config renders the merged file and exits non-zero on a YAML error, so an indentation mistake is caught before anything is recreated. Naming the service in up -d limits the action to that service, because Compose recreates only containers whose definition changed. Your arr containers keep running.
Then confirm the mounts arrived as written, rather than assuming they did.
docker inspect -f '{{json .Mounts}}' jellyfin
docker compose exec jellyfin ls /data/mediaThe first command lists every bind with its source, its destination and whether it is writable. Compare that list against the compose file. The second lists the library directory from inside the container, using the same identity Jellyfin runs as, which is the check that tells apart a wrong path from a permission problem. If the container is not running at all, read docker compose logs jellyfin before changing anything.
Reach the setup wizard
With the port bound to localhost there is no public URL yet, so forward the port over SSH from the machine you are sitting at.
ssh -L 8096:127.0.0.1:8096 you@your-vpsLeave that session open and load http://127.0.0.1:8096 in your browser. Complete the wizard and create the admin account before you expose anything.
When you add libraries, type the path as the container sees it: /data/media/movies, not /srv/media/movies. The host path does not exist inside the container, so a library pointing at it scans an empty directory. This is the same path vocabulary Radarr uses in its root folder setting, which is the payoff for mounting the tree identically everywhere.
After that, put a reverse proxy with TLS in front. Running Jellyfin on a VPS with a reverse proxy and a real certificate walks through the proxy configuration and the headers Jellyfin needs. One warning if you protect the stack with single sign-on: a forward auth proxy answers any request without a session cookie with a redirect to a login page, and Jellyfin's native apps cannot follow that, so forward auth through Authentik belongs in front of the arr services rather than in front of Jellyfin's API paths.
Hardware transcoding is a separate job
Hardware transcoding is a real topic with real setup, and half of it is worse than none. It needs a GPU the host will pass into the container, a device node such as /dev/dri or an NVIDIA container runtime, and driver versions that agree with the image. Most VPS plans have no GPU to pass through, so /dev/dri does not exist on the host and the settings page has nothing to select. Read the Jellyfin hardware transcoding setup for an NVIDIA GPU when you have a machine that can do it.
Without a GPU, the thing that keeps CPU use low is direct play: the client receives the original file and does no conversion. Transcoding starts when a client cannot handle the container format, the codec or the bitrate, so the practical lever on a CPU only VPS is which formats you keep and which quality your clients request.
Failure modes to check first
An empty library after a scan usually means a host path was typed into the library instead of a container path, or the container identity cannot traverse the directory. Run the docker compose exec jellyfin ls check above, since it separates those two cases.
Permission problems point back at PUID and PGID. Compare ls -n on the host against the numbers in the compose file, and remember that files already written by a different user keep their old ownership. Fixing the compose file does not rewrite existing ownership, so run chown -R over the tree after you change the numbers.
Imports that suddenly copy instead of hardlinking mean the arr mounts were edited to match a single library Jellyfin example. Put them back to the one shared mount.
/config is the whole server: the database, the users, the watch state, the library settings. It belongs in your backup set, and pinning an image tag instead of latest means an upgrade happens when you choose it. Backing up and upgrading a Docker Compose stack covers both, and the Jellyfin service is now part of that stack rather than a separate thing to remember.
FAQ
Can Jellyfin go in the same docker-compose.yml as Sonarr and Radarr?
Yes, and that is the arrangement to prefer. One Compose project gives one default network, so a reverse proxy container can reach Jellyfin by its service name with no published port. It also gives one copy of the media path instead of two copies that drift apart. A separate project needs its own network wiring and its own lifecycle commands.
Which paths do I enter when adding a Jellyfin library?
Enter the path as it exists inside the container. With /srv/media:/data in the compose file, a movie library points at /data/media/movies. The host path /srv/media/movies is not visible inside the container, so a library configured with it scans nothing. Use docker compose exec jellyfin ls /data/media to list the paths the container can actually see.
Should Jellyfin use the same PUID and PGID as the arr services?
Set them to the same values. Jellyfin reads files that Sonarr and Radarr wrote, so a different user id means the read depends on the mode bits happening to allow it. Matching the ids removes that dependency, and it avoids running Jellyfin as root to work around it. Run id for the user that owns the media tree, then copy those numbers into every service in the file.
Do I need to publish port 8096 to the internet?
No. Bind it with "127.0.0.1:8096:8096" and put a reverse proxy with TLS in front. Jellyfin speaks plain HTTP on 8096 and its setup wizard starts with no account, so a publicly bound port during setup is an open door. A host firewall rule does not cover a published Docker port, because forwarded traffic never reaches the INPUT chain that ufw filters.
Can I mount the media tree read only for Jellyfin?
You can, once you have checked the settings that write into it. Downloaded subtitles, saved artwork and trickplay images each have a library setting that selects the media folder or Jellyfin's own data directory. Point them at the data directory before adding :ro, otherwise those features fail against a read only mount.