the media stack on my nas. how it fits together and where it breaks, not how to set it up. compose file is at the bottom.
one line: you want something, an arr finds it, a download client grabs it behind a vpn, the arr imports it into the library, jellyfin plays it.
in order: sonarr (tv), radarr (movies), and lazylibrarian (books) watch for wanted media. they ask
prowlarr, which holds all the indexer logins in one place, for a release. the chosen release goes to
qbittorrent (torrents) or sabnzbd (usenet). when a download finishes it lands in /data,
the arr hardlinks and renames it into the library, bazarr pulls subtitles, and jellyfin serves the
result. books have their own side with lazylibrarian feeding the calibre-web pair.
shared /data. every arr and both download clients mount the whole storage dir as
/data. downloads and the finished library sit on one filesystem under one mount, so the
arr can hardlink (instant, no extra disk) and move atomically instead of copying. mount downloads and
media as two separate volumes and this quietly turns into slow copies at double the disk. this is the
mistake everyone makes.
vpn namespace. qbittorrent and sabnzbd use network_mode: service:gluetun, so they have no
network of their own, they run inside the vpn container. if the vpn drops they have no internet, which
is the kill switch, on purpose. their web uis are published on gluetun's ports, not their own.
gluetun down means downloads dead, by design. when downloads stall, check gluetun first.
changing qbit or sab's port means editing gluetun's ports block, not the client's.
hardlinks need one /data mount on one filesystem. separate download and media mounts
break it silently and you just start burning double the disk.
PUID, PGID, and the shared group (10) have to match across containers or imports fail with permission
denied.
prowlarr is the single source of indexers. it goes down, nothing can search.
jellyfin hardware transcode needs /dev/dri and the right render group. wrong group falls
back to cpu and pegs it.
image tags are mixed (latest, release, testing). unpinned images update on pull and can break without
you touching anything.
secrets live in a sibling .env, so everything here is a ${VARIABLE} and
there are no keys in the file itself. i swapped my nas ip for a placeholder.
services:
gluetun:
image: qmcgaw/gluetun:latest
container_name: gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
- VPN_TYPE=${VPN_TYPE}
- WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
- SERVER_CITIES=${SERVER_CITIES}
- DOT=off
- DNS_ADDRESS=1.1.1.1
volumes:
- ${DOCKERCONFDIR}/gluetun:/gluetun
ports:
- ${QBITTORRENT_PORT}:${QBITTORRENT_PORT}
- ${SABNZBD_PORT}:${SABNZBD_PORT}
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
network_mode: service:gluetun
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- WEBUI_PORT=${QBITTORRENT_PORT}
volumes:
- ${DOCKERCONFDIR}/qbittorrent:/config
- ${DOCKERSTORAGEDIR}/torrents:/data/torrents
group_add:
- "10"
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped
sabnzbd:
image: lscr.io/linuxserver/sabnzbd:latest
container_name: sabnzbd
network_mode: service:gluetun
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- ${DOCKERCONFDIR}/sabnzbd/config:/config
- ${DOCKERSTORAGEDIR}/usenet:/data/usenet
group_add:
- "10"
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped
radarr:
container_name: radarr
image: ghcr.io/hotio/radarr:latest
restart: unless-stopped
logging:
driver: json-file
options:
max-file: ${DOCKERLOGGING_MAXFILE}
max-size: ${DOCKERLOGGING_MAXSIZE}
ports:
- 7878:7878
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- UMASK=002
volumes:
- /etc/localtime:/etc/localtime:ro
- ${DOCKERCONFDIR}/radarr:/config
- ${DOCKERSTORAGEDIR}:/data
group_add:
- "10"
sonarr:
container_name: sonarr
image: ghcr.io/hotio/sonarr:release
restart: unless-stopped
logging:
driver: json-file
options:
max-file: ${DOCKERLOGGING_MAXFILE}
max-size: ${DOCKERLOGGING_MAXSIZE}
ports:
- 8989:8989
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- UMASK=002
volumes:
- /etc/localtime:/etc/localtime:ro
- ${DOCKERCONFDIR}/sonarr:/config
- ${DOCKERSTORAGEDIR}:/data
group_add:
- "10"
prowlarr:
container_name: prowlarr
image: ghcr.io/hotio/prowlarr:testing
restart: unless-stopped
logging:
driver: json-file
options:
max-file: ${DOCKERLOGGING_MAXFILE}
max-size: ${DOCKERLOGGING_MAXSIZE}
ports:
- 9696:9696
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- UMASK=002
- ARGS=
volumes:
- ${DOCKERCONFDIR}/prowlarr:/config:rw
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
ports:
- 8096:8096
environment:
- PUID=${PUID}
- GUID=${PGID}
- TZ=${TZ}
volumes:
- ${DOCKERCONFDIR}/jellyfin/config:/config:rw
- ${DOCKERCONFDIR}/jellyfin/cache:/cache:rw
- ${DOCKERSTORAGEDIR}/media/tv:/tv:rw
- ${DOCKERSTORAGEDIR}/media/movies:/movies:rw
- ${DOCKERSTORAGEDIR}/media/youtube:/youtube:rw
restart: unless-stopped
devices:
- /dev/dri/renderD128:/dev/dri/renderD128
group_add:
- "105"
- "10"
bazarr:
container_name: bazarr
image: ghcr.io/hotio/bazarr:latest
restart: unless-stopped
logging:
driver: json-file
options:
max-file: ${DOCKERLOGGING_MAXFILE}
max-size: ${DOCKERLOGGING_MAXSIZE}
ports:
- 6767:6767
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- /etc/localtime:/etc/localtime:ro
- ${DOCKERCONFDIR}/bazarr:/config
- ${DOCKERSTORAGEDIR}:/data
group_add:
- "10"
lazylibrarian:
container_name: lazylibrarian
image: lscr.io/linuxserver/lazylibrarian:latest
restart: unless-stopped
logging:
driver: json-file
options:
max-file: ${DOCKERLOGGING_MAXFILE}
max-size: ${DOCKERLOGGING_MAXSIZE}
ports:
- 5299:5299
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- UMASK=002
- DOCKER_MODS=linuxserver/mods:universal-calibre
volumes:
- /etc/localtime:/etc/localtime:ro
- ${DOCKERCONFDIR}/lazylibrarian:/config
- ${DOCKERSTORAGEDIR}:/data
group_add:
- "10"
calibre-web:
container_name: calibre-web
image: crocodilestick/calibre-web-automated:latest
restart: unless-stopped
logging:
driver: json-file
options:
max-file: ${DOCKERLOGGING_MAXFILE}
max-size: ${DOCKERLOGGING_MAXSIZE}
ports:
- 8083:8083
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- UMASK=002
- NETWORK_SHARE_MODE=false
- CWA_PORT_OVERRIDE=8083
volumes:
- /etc/localtime:/etc/localtime:ro
- ${DOCKERCONFDIR}/calibre-web:/config
- ${DOCKERSTORAGEDIR}/media/books:/calibre-library
- ${DOCKERCONFDIR}/cwa-book-ingest:/cwa-book-ingest
group_add:
- "10"
cwa-book-downloader:
container_name: cwa-book-downloader
image: ghcr.io/calibrain/calibre-web-automated-book-downloader:latest
restart: unless-stopped
logging:
driver: json-file
options:
max-file: ${DOCKERLOGGING_MAXFILE}
max-size: ${DOCKERLOGGING_MAXSIZE}
ports:
- 8084:8084
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- FLASK_PORT=8084
- INGEST_DIR=/cwa-book-ingest
- FLARESOLVERR_URL=http://<nas-ip>:45287
- SEARCH_MODE=universal
- USING_TOR=false
volumes:
- ${DOCKERCONFDIR}/cwa-book-downloader/config:/config
- ${DOCKERCONFDIR}/cwa-book-ingest:/cwa-book-ingest
- ${DOCKERSTORAGEDIR}:/data
group_add:
- "10"
last updated 07/08/26, e. kruger