Media Server
My Media Server; Jellyfin stack, passed-through storage, IPTV setup, and how I manage a growing media library.
Jellyfin, Storage, and Requests
I wanted one place to watch my media, request new titles, and manage a library that keeps growing. This is the setup I’m running now: a Proxmox VM, Docker Compose, Jellyfin, and the supporting services around it. It’s a field note from my homelab, not a universal hardware prescription.
The host
The media server runs in a VM on Proxmox. I pass the disks through to the VM and mount them there for Docker to use. The VM currently sees four CPUs and about 9.7 GiB of RAM; Linux reports the CPU model as a Ryzen 5 3600.
Media is split across two local filesystems. The 916 GB filesystem is mounted at /mnt/wd1tb; the 3.6 TB filesystem is mounted at /mnt/wd4tb. Movies and the vulcan library are on the first disk. TV, music, and downloads are on the second. The containers bind-mount those directories into their own paths, so Jellyfin sees /data/movies, /data/tvshows, and /data/music, while the management tools use shared /movies, /tv, and /downloads paths.
At the time of writing, the 916 GB filesystem is around 90% used and the 3.6 TB filesystem is around 99% used. An NFS share is mounted as well, but available storage doesn’t remove the need to decide what the library should keep.
The stack
Jellyfin is the media server. Jellyseerr gives users a request interface. Radarr, Sonarr, and Lidarr manage movies, series, and music; Prowlarr centralizes indexer configuration; qBittorrent is the download client; and Bazarr handles subtitles. Spotiarr and Redis are also in the Compose project. Jellystat, with PostgreSQL, runs separately and provides usage statistics.
The containers share an external Docker network named proxy. Traefik uses the Compose labels to route to the apps. Configuration directories are persisted with bind mounts, and media/download directories are mounted into the services that need them. The example below keeps the real storage paths but replaces my internal hostnames with example.invalid; adjust those labels, mounts, and the external network for your own environment.
IPTV
I use a Jellyfin plugin with Xtream Codes and M3U support to watch free-to-air channels. Playlist URLs and provider credentials are deliberately not included here. This describes my setup; it isn’t a guide to obtaining IPTV sources.
Managing a library that outgrows its disks
Jellystat is a little like a Spotify Wrapped for Jellyfin: it helps me see what users are watching and what they’ve finished. I review that activity periodically and make retention decisions manually. I’m the owner and admin, so I make the final call on what stays. When storage is tight, I sometimes have to reject a download request.
I’m considering a six-month review rule: flag files that have been on the server longer than six months, review them, then decide whether to keep, delete, or archive them. That’s a possible improvement, not an automated rule I’ve deployed.
Compose file
This is the current media-stack Compose file, with internal Traefik hostnames anonymized. It references Spotify credentials through environment variables; put real values in a private .env file on the server and don’t commit that file. The proxy network must already exist.
services:
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Dublin
volumes:
- ./radarr_config:/config
- /mnt/media-server/movies:/movies
- /mnt/media-server/downloads:/downloads
- /mnt/media-server/vulcan:/vulcan
ports:
- "7878:7878"
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.radarr.rule=Host(`radarr.example.invalid`)"
- "traefik.http.routers.radarr.entrypoints=https"
- "traefik.http.routers.radarr.tls=true"
- "traefik.http.routers.radarr.tls.certresolver=cloudflare"
- "traefik.http.services.radarr.loadbalancer.server.port=7878"
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Dublin
volumes:
- ./sonarr_config:/config
- /mnt/media-server/shows:/tv
- /mnt/media-server/downloads:/downloads
ports:
- "8989:8989"
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.sonarr.rule=Host(`sonarr.example.invalid`)"
- "traefik.http.routers.sonarr.entrypoints=https"
- "traefik.http.routers.sonarr.tls=true"
- "traefik.http.routers.sonarr.tls.certresolver=cloudflare"
- "traefik.http.services.sonarr.loadbalancer.server.port=8989"
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Dublin
volumes:
- ./prowlarr_config:/config
ports:
- "9696:9696"
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.prowlarr.rule=Host(`prowlarr.example.invalid`)"
- "traefik.http.routers.prowlarr.entrypoints=https"
- "traefik.http.routers.prowlarr.tls=true"
- "traefik.http.routers.prowlarr.tls.certresolver=cloudflare"
- "traefik.http.services.prowlarr.loadbalancer.server.port=9696"
jellyfin:
image: lscr.io/linuxserver/jellyfin:latest
container_name: jellyfin
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Dublin
volumes:
- ./jellyfin_config:/config
- ./custom-jellyfin-web/web:/usr/share/jellyfin/web:ro
- /mnt/media-server/shows:/data/tvshows
- /mnt/media-server/movies:/data/movies
- /mnt/media-server/vulcan:/data/vulcan
- /mnt/media-server/music:/data/music
ports:
- "8096:8096"
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.jellyfin.rule=Host(`jellyfin.example.invalid`)"
- "traefik.http.routers.jellyfin.entrypoints=https"
- "traefik.http.routers.jellyfin.tls=true"
- "traefik.http.routers.jellyfin.tls.certresolver=cloudflare"
- "traefik.http.services.jellyfin.loadbalancer.server.port=8096"
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Dublin
- WEBUI_PORT=8072
- TORRENTING_PORT=6881
volumes:
- ./qbit_config:/config
- /mnt/media-server/downloads:/downloads
ports:
- "8072:8072"
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.qbittorrent.rule=Host(`qbit.example.invalid`)"
- "traefik.http.routers.qbittorrent.entrypoints=https"
- "traefik.http.routers.qbittorrent.tls=true"
- "traefik.http.routers.qbittorrent.tls.certresolver=cloudflare"
- "traefik.http.services.qbittorrent.loadbalancer.server.port=8072"
jellyseerr:
image: ghcr.io/seerr-team/seerr:latest
container_name: jellyseerr
environment:
- LOG_LEVEL=debug
- TZ=Europe/Dublin
- PORT=5055
volumes:
- ./jellyseerr_config:/app/config
ports:
- "5055:5055"
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.jellyseerr.rule=Host(`jellyseerr.example.invalid`)"
- "traefik.http.routers.jellyseerr.entrypoints=https"
- "traefik.http.routers.jellyseerr.tls=true"
- "traefik.http.routers.jellyseerr.tls.certresolver=cloudflare"
- "traefik.http.services.jellyseerr.loadbalancer.server.port=5055"
lidarr:
image: lscr.io/linuxserver/lidarr:latest
container_name: lidarr
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
volumes:
- ./lidarr_config:/config
- /mnt/media-server/music:/music
- /mnt/media-server/downloads:/downloads
ports:
- "8686:8686"
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.lidarr.rule=Host(`lidarr.example.invalid`)"
- "traefik.http.routers.lidarr.entrypoints=https"
- "traefik.http.routers.lidarr.tls=true"
- "traefik.http.routers.lidarr.tls.certresolver=cloudflare"
- "traefik.http.services.lidarr.loadbalancer.server.port=8686"
bazarr:
image: lscr.io/linuxserver/bazarr:latest
container_name: bazarr
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
volumes:
- ./bazarr_config:/config
- /mnt/media-server/shows:/tv
- /mnt/media-server/movies:/movies
ports:
- "6767:6767"
networks:
- proxy
restart: unless-stopped
spotiarr:
image: mralexandersaavedra/spotiarr:latest
container_name: spotiarr
environment:
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
- SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI}
- PUID=1000
- PGID=1000
volumes:
- /mnt/media-server/music:/downloads
ports:
- "3001:3000"
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.spotiarr.rule=Host(`spoti.example.invalid`)"
- "traefik.http.routers.spotiarr.entrypoints=https"
- "traefik.http.routers.spotiarr.tls=true"
- "traefik.http.routers.spotiarr.tls.certresolver=cloudflare"
- "traefik.http.services.spotiarr.loadbalancer.server.port=3000"
depends_on:
- redis
restart: unless-stopped
redis:
image: redis:alpine
container_name: spotiarr-redis
restart: unless-stopped
networks:
- proxy
networks:
proxy:
external: true