Notes navigation

Media Server

My Media Server; Jellyfin stack, passed-through storage, IPTV setup, and how I manage a growing media library.

Media Server
Photo by Kvistholt Photography / Unsplash

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

Start typing to search notes and pages.