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.
-
Media server and streaming. Host networking, Intel QuickSync hardware transcoding, read-only media.
-
Torrent client. Runs inside Gluetun's network stack, so all its traffic goes through the VPN.
-
VPN gateway container (ProtonVPN over WireGuard) with automatic port forwarding into qBittorrent.
-
Automated movie downloads, library management, and quality upgrades.
-
Automated TV show downloads and library management.
-
Indexer manager. Syncs one set of indexers to Radarr and Sonarr.
-
Media request interface. Users can request titles for automatic download.
-
Automated torrent management: categories by tracker, tags, tracker-error tagging.
-
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.
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>