sabnzbd arr stack: how it works

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.

the flow

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.

the two things that actually matter

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.

where it breaks

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.

the compose

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