I self-hosted Jellyfin — libraries scanned empty until the jellyfin user could read /srv/media

I self-hosted Jellyfin — libraries scanned empty until the jellyfin user could read /srv/media

I wanted a private stream of files I already own, not another hosted library. Jellyfin on Ubuntu 24.04 came up behind Nginx, then every library scan stayed empty until the container user could actually read /srv/media.

 Jellyfin self-hosted media server overview

Caption: My files, Docker, Nginx, clients. UDP discovery stays on the LAN. Config under /opt/jellyfin is what I back up.

Why I wanted this on my server

I already had movies, shows, and home video on disk. I wanted a browser and TV client without a proprietary media account. Jellyfin is the open-source server: libraries, users, watched state, optional transcode.

It does not invent storage. Permissions, bandwidth, and backups of irreplaceable files are still my problem.

What I actually installed

jellyfin/jellyfin:10 under /opt/jellyfin-compose, config/cache at /opt/jellyfin, media bind-mounted read-only from /srv/media. HTTP only on 127.0.0.1:8096. Nginx HTTPS on jellyfin.example.com. Direct-play is happy on 2 vCPU / 2 GB; transcode wants more CPU or a GPU.

UDP 7359 is local discovery. I do not publish it on a public VPS.

Where it broke

On a fresh Ubuntu 24.04 box this install is famous for libraries that scan and stay empty.

The container was healthy. curl -I http://127.0.0.1:8096 worked. I added Movies pointed at /media/movies. Scan finished. Zero items.

The Compose file runs as the jellyfin system user. I had created /srv/media and chown’d it to my login. Mode u=rwX,g=rX,o= meant jellyfin could not read the files.

Logs mentioned permission errors once I looked. Fix: give the jellyfin user read on the media tree (group jellyfin, or ACLs), keep the mount read-only, rescan.

sudo chgrp -R jellyfin /srv/media
sudo chmod -R g+rX /srv/media
sudo find /srv/media -type d -exec chmod g+s {} \;

Paths inside the container still have to match: host /srv/media/movies/media/movies in the library wizard.

 Jellyfin Docker Compose stack

Caption: Config and cache writable. Media read-only. Localhost HTTP. Nginx on 443.

Prerequisites

Domain if this leaves the LAN. Prefer VPN if only family needs it. SSD for config/cache. Do not expose discovery ports to the internet.

sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg openssl ufw nginx certbot python3-certbot-nginx jq rsync

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status

The working install

1. Install Docker Engine

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
newgrp docker

docker --version
docker compose version

2. Create the Jellyfin user and directories

sudo useradd --system --home /opt/jellyfin --shell /usr/sbin/nologin jellyfin || true

sudo mkdir -p /opt/jellyfin/config /opt/jellyfin/cache /opt/jellyfin/fonts
sudo mkdir -p /srv/media/movies /srv/media/tv /srv/media/music /srv/media/home-videos

sudo chown -R jellyfin:jellyfin /opt/jellyfin
sudo chown -R "$USER":"$USER" /srv/media
sudo chmod 750 /opt/jellyfin
sudo chmod -R u=rwX,g=rX,o= /srv/media

After copy-in, apply the jellyfin group read from “Where it broke” if the library stays empty. Mount the real disk before Docker starts if /srv/media is a separate volume.

3. Create the Compose project

sudo mkdir -p /opt/jellyfin-compose
sudo chown "$USER":"$USER" /opt/jellyfin-compose
chmod 700 /opt/jellyfin-compose
cd /opt/jellyfin-compose

cat > .env <<EOF
JELLYFIN_UID=$(id -u jellyfin)
JELLYFIN_GID=$(id -g jellyfin)
TZ=Etc/UTC
JELLYFIN_URL=https://jellyfin.example.com
EOF

chmod 600 .env
services:
  jellyfin:
    image: jellyfin/jellyfin:10
    container_name: jellyfin
    user: "${JELLYFIN_UID}:${JELLYFIN_GID}"
    ports:
      - "127.0.0.1:8096:8096/tcp"
    volumes:
      - /opt/jellyfin/config:/config
      - /opt/jellyfin/cache:/cache
      - type: bind
        source: /srv/media
        target: /media
        read_only: true
      - type: bind
        source: /opt/jellyfin/fonts
        target: /usr/local/share/fonts/custom
        read_only: true
    environment:
      - TZ=${TZ}
      - JELLYFIN_PublishedServerUrl=${JELLYFIN_URL}
    restart: unless-stopped

Tag 10 follows the current major. After a stable week I pin a patch version.

4. Start Jellyfin

cd /opt/jellyfin-compose

docker compose config
docker compose pull
docker compose up -d
docker compose ps
curl -I http://127.0.0.1:8096
docker logs --since=10m jellyfin

If the container exits, logs first. Nginx 502 is usually “container is down,” not Certbot.

5. Configure Nginx and HTTPS

Create /etc/nginx/sites-available/jellyfin.example.com:

server {
    listen 80;
    listen [::]:80;
    server_name jellyfin.example.com;

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name jellyfin.example.com;

    client_max_body_size 20M;
    ssl_protocols TLSv1.3 TLSv1.2;

    ssl_certificate /etc/letsencrypt/live/jellyfin.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/jellyfin.example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    add_header X-Content-Type-Options "nosniff";

    location / {
        proxy_pass http://127.0.0.1:8096;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Protocol $scheme;
        proxy_set_header X-Forwarded-Host $http_host;
        proxy_buffering off;
    }

    location /socket {
        proxy_pass http://127.0.0.1:8096;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Protocol $scheme;
        proxy_set_header X-Forwarded-Host $http_host;
    }
}
sudo certbot certonly --nginx -d jellyfin.example.com
sudo ln -s /etc/nginx/sites-available/jellyfin.example.com /etc/nginx/sites-enabled/jellyfin.example.com
sudo nginx -t
sudo systemctl reload nginx
sudo certbot renew --dry-run

VPN-only access: skip public Nginx and use the private address.

6. Complete first-run setup

Language, unique admin password, libraries at /media/movies, /media/tv, /media/music, /media/home-videos. A few test files before a 4 TB scan. Metadata language. Remote access on or off on purpose. Daily watching as a non-admin user.

Dashboard > Networking: known proxies and LAN ranges if remote rules depend on them. A base URL works; a dedicated subdomain fights fewer clients.

Very important to know

CPU at 100% during play is usually transcode, not “the VPS is too small in general.” Check whether the client direct-plays. Subtitle burn-in forces transcode. Hardware acceleration needs /dev/dri (or NVIDIA) in Compose and the matching option in Dashboard > Playback. The official image already ships jellyfin-ffmpeg.

lspci -nn | grep -Ei "3d|display|vga" || true
ls -l /dev/dri || true
    devices:
      - /dev/dri:/dev/dri

First playback I actually ran

cd /opt/jellyfin-compose

docker compose ps
docker logs --since=30m jellyfin
curl -I https://jellyfin.example.com
du -sh /opt/jellyfin/config /opt/jellyfin/cache /srv/media

 Jellyfin media workflow

Caption: Files on disk → scan → metadata → user → direct-play or transcode. Empty library is almost always path or unix perms.

Scan, play in browser, play on a TV or phone, subtitles, remote policy, a locked-down user, active devices while something is playing.

Backup, expose, next step

/opt/jellyfin/config is users, metadata, watched state, plugins. Cache can rebuild. Media that cannot be ripped again gets its own backup tool.

sudo mkdir -p /var/backups/jellyfin
sudo chmod 700 /var/backups/jellyfin
sudo tar -C /opt -czf "/var/backups/jellyfin/jellyfin-config-$(date +%F).tar.gz" jellyfin
sudo tar -C /opt -czf "/var/backups/jellyfin/jellyfin-compose-$(date +%F).tar.gz" jellyfin-compose
sudo rsync -a --delete /var/backups/jellyfin/ backup-user@backup.example.com:/srv/backups/jellyfin/

Restore test: other machine, same config path, same media mount path, confirm users and watched state.

 Jellyfin backup and restore plan

Caption: Config tarball, Compose, irreplaceable media, restore on a second host. Cache is optional.

What I have running now is Jellyfin on HTTPS, media read-only, jellyfin group able to read /srv/media, a handful of test files playing. Next I add real libraries, decide VPN vs public remote, and copy config off-box before I care about watched status.

Did you hit the same wall?

I got stuck on libraries scanning empty because the jellyfin container user could not read /srv/media after I chowned it to my login. Did you hit the same thing, or a different one? Tell me in the comments. I read them.

Need this done on your server?

I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan

References

Share:

Get new posts in your inbox

No spam. One short email per new article — practical PHP, Laravel, devops, and AI-assisted workflows.

Comments

Powered by GitHub Discussions via Giscus. A free GitHub account is required.