Skip to content

Media Server

This is all the media services running on my NAS. The OS/physical setup for that can be found at Server Setup: NAS.

I just run it all via a single Docker Compose file. Compose file found below.

Covers the streaming server, the Arr stack for automated media management, and a VPN-routed download client. Further configuration for most services is documented at TRaSH Guides.

  • Plex Plex


    Media server and streaming. Host networking, Intel QuickSync hardware transcoding, read-only media.

  • qbittorrent qBittorrent


    Torrent client. Runs inside Gluetun's network stack, so all its traffic goes through the VPN.

  • Gluetun


    VPN gateway container (ProtonVPN over WireGuard) with automatic port forwarding into qBittorrent.

  • radarr Radarr


    Automated movie downloads, library management, and quality upgrades.

  • sonarr Sonarr


    Automated TV show downloads and library management.

  • Prowlarr


    Indexer manager. Syncs one set of indexers to Radarr and Sonarr.

  • Seerr


    Media request interface. Users can request titles for automatic download.

  • qbit_manage


    Automated torrent management: categories by tracker, tags, tracker-error tagging.

  • Autoheal


    Restarts labelled containers when their healthcheck fails. Watches qBittorrent and qbit_manage.

How it fits together

Service Network Data access Why
Plex host media read-only Plex needs several ports plus local discovery. It only reads media.
Radarr / Sonarr default bridge whole /data read-write One mount for downloads and library, so imports are hardlinks / atomic moves.
Prowlarr default bridge config only It only talks to indexers and the *arr apps.
Seerr default bridge /data Talks to Plex, Radarr and Sonarr over their APIs.
qBittorrent service:gluetun whole /data read-write Every packet goes through the VPN. Its web UI port is published on Gluetun.
qbit_manage default bridge /data + qBittorrent config read-only Reaches qBittorrent's API at http://gluetun:8080 over the stack's network.
Gluetun default bridge + /dev/net/tun config only VPN tunnel, killswitch firewall, port forwarding.
Autoheal default bridge Docker socket Restarts unhealthy containers that carry the autoheal=true label.

Turn authentication on everywhere

Every web UI in this stack has its own login. Use it:

  • Radarr / Sonarr / Prowlarr: Settings → General → Security, set Authentication to Forms and Authentication Required to Enabled. "Disabled for local addresses" is risky behind a reverse proxy, because proxied requests arrive from a private address and count as "local".
  • qBittorrent: set a Web UI username and password. Only allow the localhost bypass Gluetun needs (see Gluetun), keep the subnet whitelist empty, and leave CSRF protection and host header validation on.
  • qbit_manage: turn on its web UI authentication (see qbit_manage).
  • If any of these are reachable from outside the LAN, put them behind your reverse proxy with SSO or an access list as well.

Docker Compose

Note

No configuration change should be needed to the compose file. All configuration should be able to be accomplished via the .env file. Adjust those settings as needed and then run docker compose up -d to start the media server stack.

compose.yaml

Pinned image tags

Every image is pinned to an exact version (LinuxServer.io tags look like <version>-ls<build>, Seerr, qbit_manage and Gluetun use vX.Y.Z). Upgrades and rollbacks are deliberate: Renovate opens a pull request for each new tag. Autoheal only publishes latest, so it is pinned by digest (latest@sha256:...). The tags below are the ones running when this page was written; use the current ones.

Ports

Ports are published on all interfaces so the web UIs are reachable on the LAN. If you put these services behind a reverse proxy, bind them to 127.0.0.1 (e.g. 127.0.0.1:7878:7878) or drop the ports: and use a shared Docker network instead.

services:

### ---------------------------------- ###
### ---------- Streaming ------------- ###
### ---------------------------------- ###

### --- Plex Media Server --- ###
  plex:
    image: lscr.io/linuxserver/plex:1.43.4.10903-e5521bd8c-ls328
    container_name: plex
    network_mode: host
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
      - VERSION=docker
      - PLEX_CLAIM=${PLEX_CLAIM}
    # No `ports:`: it does nothing under network_mode: host.
    devices:
     - /dev/dri:/dev/dri #Required for plex HW transcoding / QuickSync (Intel 8th gen or newer)
    # Read-only media: Plex only reads it. Radarr/Sonarr/qBittorrent keep their own rw mounts.
    # Side effect: deleting media from inside Plex fails.
    volumes:
      - ${APPDATA_PATH}/plex/config:/config  # config data - NVMe
      - /mnt/appdata/plex/transcode:/transcode # transcode temp data - appdata SSD
    # - /dev/shm:/transcode # transcode to RAM (if available)
      - ${MEDIA_DATA}/media:/data/media:ro # media access only - mergerfs pool
    # linuxserver's s6 init only drops privileges, so no-new-privileges is safe; no cap_drop (init needs chown/setuid).
    security_opt:
      - no-new-privileges:true
    restart: always
    # /identity is the standard unauthenticated Plex health endpoint
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:32400/identity"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s

 ### -------------------------------- ###
 ### ----------- Arr Stack ---------- ###
 ### -------------------------------- ###

  #Radarr - used to find movies automatically
  radarr:
    image: lscr.io/linuxserver/radarr:6.4.4.10685-ls322
    container_name: radarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ${APPDATA_PATH}/radarr/config:/config
      - ${MEDIA_DATA}:/data #Access to the entire data
    ports:
      - 7878:7878
    restart: always
    # Servarr apps expose /ping specifically for this
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:7878/ping"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

  #Sonarr - used to find tv shows automatically
  sonarr:
    image: lscr.io/linuxserver/sonarr:4.0.20.3014-ls329
    container_name: sonarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ${APPDATA_PATH}/sonarr/config:/config
      - ${MEDIA_DATA}:/data #Access to the entire data
    ports:
      - 8989:8989
    restart: always
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8989/ping"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

  #Prowlarr - manages your Sonarr, Radarr and download client
  prowlarr:
    image: lscr.io/linuxserver/prowlarr:2.6.5.5623-ls164
    container_name: prowlarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ${APPDATA_PATH}/prowlarr/config:/config
    ports:
      - 9696:9696
    restart: always
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9696/ping"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

  # Seerr - allows users to request media on their own [Supercedes Overseerr]
  seerr:
    image: ghcr.io/seerr-team/seerr:v3.5.0
    init: true
    container_name: seerr
    environment:
      # Not a LinuxServer.io image: it runs as the `node` user (UID 1000) and ignores PUID/PGID.
      # chown the config folder to 1000:1000 (see the Seerr page).
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
      - LOG_LEVEL=debug
      - PORT=5055 # Optional
    volumes:
      - ${APPDATA_PATH}/seerr/config:/app/config
      - ${MEDIA_DATA}:/data #Access to the entire data
    healthcheck:
      test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
      start_period: 20s
      timeout: 3s
      interval: 15s
      retries: 3
    ports:
      - 5055:5055
    restart: always


### -------------------------------------- ###
### ----------- Download W/ VPN ---------- ###
### -------------------------------------- ###

### --- Qbittorrent: Torrent Client --- ###
  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:5.2.4_v2.0.15-ls479
    container_name: qbittorrent
    depends_on:
      gluetun:
        condition: service_healthy
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
      - WEBUI_PORT=8080
      # This torrenting port is enabled by default. Gluetun handles this here, so it is disabled.
      # - TORRENTING_PORT=8694
    volumes:
      - ${MEDIA_DATA}:/data
      - ${APPDATA_PATH}/qbittorrent/config:/config
    restart: always
    network_mode: "service:gluetun" # all traffic goes through the VPN; the web UI port is published on gluetun
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:8080 >/dev/null 2>&1 || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
    labels:
      - autoheal=true

  qbit_manage:
    container_name: qbit_manage
    image: ghcr.io/stuffanthings/qbit_manage:v4.14.0
    volumes:
      - ${APPDATA_PATH}/qbit_manage/:/config:rw
      - ${MEDIA_DATA}:/data:rw
      - ${APPDATA_PATH}/qbittorrent/config:/qbittorrent/:ro
    ports:
      - "8084:8080"  # Web API/UI - host port 8084 because qBittorrent's web UI already has 8080
    environment:
      # Web API Configuration
      - QBT_WEB_SERVER=true     # enable the web API and web UI
      - QBT_PORT=8080           # web port inside the container (upstream default: 8181)
      # Scheduler Configuration
      - QBT_RUN=false           # false = keep running on the schedule
      - QBT_SCHEDULE=2          # minutes between runs
      - QBT_CONFIG_DIR=/config
      - QBT_LOGFILE=qbit_manage.log
      # Command Flags (all false here: the commands come from config.yml)
      - QBT_RECHECK=false
      - QBT_CAT_UPDATE=false
      - QBT_TAG_UPDATE=false
      - QBT_REM_UNREGISTERED=false
      - QBT_REM_ORPHANED=false
      - QBT_TAG_TRACKER_ERROR=false
      - QBT_TAG_NOHARDLINKS=false
      - QBT_SHARE_LIMITS=false
      - QBT_SKIP_CLEANUP=false
      - QBT_DRY_RUN=false
      - QBT_STARTUP_DELAY=0
      - QBT_SKIP_QB_VERSION_CHECK=false
      - QBT_DEBUG=false
      - QBT_TRACE=false
      # Logging Configuration
      - QBT_LOG_LEVEL=INFO
      - QBT_LOG_SIZE=10
      - QBT_LOG_COUNT=5
      - QBT_DIVIDER==
      - QBT_WIDTH=100
    restart: on-failure:2
    labels:
      - autoheal=true
    # No official healthcheck - its own web UI (enabled via QBT_WEB_SERVER above) responds on QBT_PORT
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s


### --- Gluetun: VPN for Downloading --- ###
# https://www.reddit.com/r/gluetun/comments/1kpbfs2/the_definitive_howto_for_setting_up_protonvpn/
  gluetun:
    image: qmcgaw/gluetun:v3.41.3
    container_name: gluetun
    cap_add:
      - NET_ADMIN # required to create the tunnel and firewall rules
    devices:
      - /dev/net/tun:/dev/net/tun
    ports:
      - 8080:8080/tcp # qbittorrent
    environment:
      - TZ=${TZ}
      - UPDATER_PERIOD=168h # weekly server-list refresh (see the Gluetun page)
      - VPN_SERVICE_PROVIDER=protonvpn
      - VPN_TYPE=${VPN_TYPE}
      - BLOCK_MALICIOUS=off
      - OPENVPN_USER=${OPENVPN_USER}
      - OPENVPN_PASSWORD=${OPENVPN_PASSWORD}
      - OPENVPN_CIPHERS=AES-256-GCM
      - WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
      - PORT_FORWARD_ONLY=on
      - VPN_PORT_FORWARDING=on
      # Push the forwarded port into qBittorrent and bind it to ANY interface ("", not tun0).
      # Why: see "Port forwarding into qBittorrent" on the Gluetun page.
      - VPN_PORT_FORWARDING_UP_COMMAND=/bin/sh -c 'sleep 90 && wget -O- --retry-connrefused --post-data "json={\"listen_port\":{{PORTS}},\"current_network_interface\":\"\"}" http://127.0.0.1:8080/api/v2/app/setPreferences 2>&1'
      - SERVER_COUNTRIES=${SERVER_COUNTRIES}
      # Health target by IP, so a DNS hiccup doesn't fail the health check and restart the tunnel
      - HEALTH_TARGET_ADDRESSES=1.1.1.1:443,8.8.8.8:443
      - HEALTH_VPN_DURATION_INITIAL=30s # obsolete since v3.40; Gluetun ignores it
    volumes:
      - ${APPDATA_PATH}/gluetun/config:/gluetun
    healthcheck:
      # Same probe as the image default, with a 120s start_period (image default: 10s) so a slow
      # first connection doesn't mark gluetun unhealthy and abort qbittorrent's depends_on.
      test: ["CMD", "/gluetun-entrypoint", "healthcheck"]
      interval: 5s
      timeout: 5s
      start_period: 120s
      start_interval: 5s
      retries: 3
    restart: always

### --- Autoheal: restarts containers labelled autoheal=true when they turn unhealthy --- ###
  autoheal:
    image: willfarrell/autoheal:latest@sha256:5faf6731257cd57316c2863e40b56bbd148760e867ac689311c08924a266312c
    container_name: autoheal
    restart: always
    environment:
      - AUTOHEAL_CONTAINER_LABEL=autoheal
      - AUTOHEAL_INTERVAL=30
      - AUTOHEAL_START_PERIOD=60
    volumes:
      # Docker socket access is root-equivalent on the host. Mount it only into containers you trust.
      - /var/run/docker.sock:/var/run/docker.sock

.env.example

Copy this to .env next to compose.yaml and fill it in. Never commit the real .env.

.env.example
APPDATA_PATH=/path/to/appdata
MEDIA_DATA=/mnt/storage/data # root of the media pool; contains media/ and torrents/

PUID=<PUID> # e.g. 1000 - run `id` on the host
PGID=<PGID> # e.g. 1000

TZ=<TZ> # e.g. Etc/UTC

PLEX_CLAIM=<YOUR_TOKEN> # Optional - Get this from https://www.plex.tv/claim/ to link your plex server to your account automatically on first run. Expires within 4 minutes; blank it after the first run.

### GLUETUN VPN SETTINGS ###
# Fill in either the OpenVPN or WireGuard section. VPN_TYPE picks one ('wireguard' or 'openvpn'); the other is ignored.
# Run 'docker run --rm -v eraseme:/gluetun qmcgaw/gluetun format-servers -protonvpn' to list server countries.

VPN_TYPE=wireguard # 'wireguard' or 'openvpn'

SERVER_COUNTRIES=Netherlands # comma-separated list

# OpenVPN config (the +pmp suffix turns on port forwarding)
OPENVPN_USER=<YOUR_USER>+pmp
OPENVPN_PASSWORD=<YOUR_PASSWORD>

# WireGuard config
WIREGUARD_PRIVATE_KEY=<YOUR_VPN_PRIVATE_KEY>

Sources & further reading