Build and Test a Plex, Prowlarr, Sonarr, Radarr, SABnzbd, and Tdarr Stack

Rights, lawful use, and scope: This article is for administering a private media server, organizing media you own or are authorized to use, preserving backups, and testing your own infrastructure. It is not legal advice and it is not a guide to finding, acquiring, sharing, or bypassing access controls for copyrighted works. Do not use Plex, Sonarr, Radarr, Prowlarr, SABnzbd, Tdarr, lists, indexers, or download clients to download, store, process, or distribute content unless you have the legal right and service permission to do so.

Quick Answer

Build the stack in gates: validate Ubuntu, storage, identity, and Compose; make Prowlarr, SABnzbd, Sonarr, and Radarr ready; prove one authorized TV and movie handoff; add Plex playback; then run a reversible Tdarr canary. Use one consistent /data tree for downloads and Arr imports, keep admin UIs on localhost or a VPN, and record backups plus image references. A running container is only the first gate, not end-to-end proof.

Safe default: Build this LAN-only or VPN-only first. Do not port-forward Sonarr, Radarr, Prowlarr, SABnzbd, Tdarr, NAS admin pages, or reverse-proxy admin pages. These are control-plane apps that can reveal secrets, change libraries, trigger jobs, delete files, or expose host paths.

Evidence status: This guide was checked against current Docker, Servarr, LinuxServer.io, Tdarr, and Plex documentation on August 24, 2026. TechGeeks did not build a fresh Ubuntu host, pull these images, run an authorized item through the workflow, measure performance, reboot the stack, or restore a database. Every output and success state below is explicitly an unperformed example or acceptance criterion.

What You Are Building

Prowlarr manages and tests lawful indexer definitions. Sonarr and Radarr decide what belongs in TV and movie libraries. SABnzbd handles authorized jobs and completion. Sonarr and Radarr import completed files into final folders. Plex serves those folders. Tdarr changes final files only after the basic workflow is stable. The reader's intent is not merely to launch six containers; it is to prove every handoff and know where to stop when one fails.

Interactive setup flow
Build the Stack in This Order

Click each step to see what must be true before moving on. The goal is one clean authorized import before broad automation.

  1. 1. FoundationUbuntu + Docker

    Patch the host, install Docker or native packages, and confirm the server can reboot cleanly.

  2. 2. Paths/data Layout

    Create shared download, media, and cache paths before apps start creating files with mismatched ownership.

  3. 3. SourcesProwlarr

    Add only lawful sources and sync indexers outward. Prowlarr finds candidates; it does not decide what belongs in the library.

  4. 4. DownloadsSABnzbd

    Use category folders for movies and TV. Keep incomplete work away from final Plex library roots.

  5. 5. DecisionsSonarr / Radarr

    Set roots, profiles, categories, and one controlled test item before enabling lists or broad searching.

  6. 6. PlaybackPlex

    Point Plex at final media only, then confirm it can see the imported test item without permission fixes.

  7. 7. Optimize LaterTdarr

    Add Tdarr only after imports work. Start with one test file, validate output, then scale carefully.

Rights gateUse these workflows only for media you own or are authorized to store, download, organize, or transcode.
One controlled item firstProve one authorized TV item and one authorized movie item before lists, GPU tuning, or library-wide Tdarr.
Private control planeAdmin UIs and app APIs should stay LAN, localhost, Docker network, or VPN by default.

Use Five Acceptance Gates

GateQuestionEvidence
RunningDid the container process start?docker compose ps and startup logs
ReadyCan the app answer its UI/API and finish startup?HTTP check, application health page, no migration or permission error
ConfiguredAre paths, credentials, profiles, categories, and app URLs correct?Sanitized settings ledger and each app's own Test action
FunctionalDoes one authorized item cross the handoff?Correlated queue, import, scan, and playback records
RecoverableCan the state survive restart, update, and restore?Reboot check, pinned image record, backup and isolated restore artifact

Docker Compose starts containers in dependency order when dependencies are declared, but the current Docker documentation is explicit that "running" is not the same as "ready." This stack therefore uses manual readiness gates. Do not add a copied healthcheck until you verify that the selected image contains the probe tool and that the endpoint represents application readiness for that version.

Prerequisites and Private Change Ledger

  • A currently supported 64-bit Ubuntu release for Docker Engine, with security updates applied.
  • A wired host with enough CPU, memory, and storage for the chosen workloads. No performance figure is assumed here.
  • Local storage or a mounted NAS for final media, plus local SSD/NVMe where practical for incomplete downloads and transcode cache.
  • A router reservation or stable address and working Secure Shell (SSH) access.
  • A non-public backup target with room for app configuration and database versions.
  • A small personal, public-domain, open-licensed, or otherwise authorized movie and series sample.
  • Legal accounts for any services you connect. This guide does not select providers or bypass terms.

Record the environment before installation. These commands are examples and were not run for this revision.

date -u
. /etc/os-release
printf 'OS=%s %s\n' "$NAME" "$VERSION_ID"
uname -r
id
findmnt -T /data 2>/dev/null || true
df -hT /opt /data 2>/dev/null || true

Example expected output shape (not observed): a UTC timestamp, Ubuntu name/version, kernel release, UID/GID/group list, and the filesystem/device backing each intended path. Record real output privately; do not substitute the example for your host.

Install Docker Engine from the Current Ubuntu Repository

The repository syntax was rechecked against Docker's Ubuntu instructions on October 2, 2026. This example is for a fresh, dedicated, supported Ubuntu host with no conflicting container packages or existing Docker repository configuration. Resolve those prerequisites using Docker's instructions before running it; do not remove packages used by other workloads blindly. Run each shell block in Bash, stop at any error, and do not continue into a later block after failure.

(
set -e
sudo test ! -e /etc/apt/sources.list.d/docker.sources
sudo apt update
sudo apt install -y ca-certificates curl acl jq tar sqlite3 \
  mediainfo cifs-utils nfs-common ufw

sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo docker run --rm hello-world
sudo docker compose version
)

Example expected state (not observed): both final commands exit with status 0; the first prints Docker's test confirmation and the second prints an installed Compose version. Do not copy a version from this article into a lab record. If you add an administrator to the docker group, treat that membership as root-equivalent access and limit it to trusted users.

Create One Storage and Identity Model

Downloads and final Arr libraries should live under one logical /data tree. Sonarr and Radarr receive that tree as one bind mount, which preserves a single in-container filesystem view for hardlinks or atomic moves when the host paths are also on one filesystem. Prowlarr needs no media mount. Plex and Tdarr need only final media plus their cache.

/opt/media-stack
  .env
  compose.yml
  plex/config
  prowlarr/config
  sabnzbd/config
  sonarr/config
  radarr/config
  tdarr/server
  tdarr/configs
  tdarr/logs

/data
  downloads/incomplete
  downloads/usenet/movies
  downloads/usenet/tv
  media/movies
  media/tv
  media/tdarr-canary
  cache/plex
  cache/tdarr
id
getent group media
findmnt --target /data --output SOURCE,FSTYPE,TARGET,OPTIONS
findmnt --target /data/media --output SOURCE,FSTYPE,TARGET,OPTIONS
stat -c '%U %G %a %n' /opt/media-stack /data /data/media

Storage prerequisite, not an automatic permission repair: the read-only commands above inventory existing paths; missing paths or a missing media group require preparation before proceeding. On the dedicated host, create the shown project/download/cache directories with the intended administrator and media-group access. For NAS media, first complete the mount gate below, then create movies, tv and tdarr-canary on that mounted share using its approved ACL policy. Do not recursively change an existing /data tree. For local media, verify the intended backing filesystem and UID/GID write access before creating /data/media/.media_mount_marker. A marker is only an operator checkpoint, not mount identity. Use the image-specific LinuxServer documentation and the NAS vendor's ACL instructions; no universal NAS ownership command is established here.

NAS Mount Gate

If final media is on a NAS, stop every application using that path before mounting. Prepare a root-owned, mode-600 credentials file at /root/.smb-media with the NAS account using a trusted editor; never truncate an existing credentials file. Confirm SMB 3.1.1 support and the share ACL/UID/GID mapping. The example below requires an existing empty, unmounted /data/media directory with no local marker or symlink. Replace //YOUR_NAS/media in both places. If any check fails, leave the applications stopped and resolve it; do not create a local substitute marker.

(
set -euo pipefail
test -d /data/media
test ! -L /data/media
test ! -e /data/media/.media_mount_marker
if findmnt --mountpoint /data/media >/dev/null; then
  echo 'Already mounted: verify it separately; do not mount over it.' >&2
  exit 1
fi
first_entry="$(find /data/media -mindepth 1 -maxdepth 1 -print -quit)"
test -z "$first_entry"
sudo test -f /root/.smb-media
test "$(sudo stat -c '%U:%a' /root/.smb-media)" = 'root:600'
media_gid="$(getent group media | cut -d: -f3)"
test -n "$media_gid"
sudo mount -t cifs //YOUR_NAS/media /data/media \
  -o "credentials=/root/.smb-media,uid=$(id -u),gid=$media_gid,dir_mode=0775,file_mode=0664,vers=3.1.1"
test "$(findmnt -rn --mountpoint /data/media -o SOURCE)" = '//YOUR_NAS/media'
test "$(findmnt -rn --mountpoint /data/media -o FSTYPE)" = 'cifs'
touch /data/media/.media_mount_marker
findmnt --mountpoint /data/media --output SOURCE,FSTYPE,TARGET,OPTIONS
)

Expected, not observed: the selected remote source and cifs type match before the marker is written. Failed mount or identity checks end this block. If it mounted an unexpected source, stop and correct the mount with all consumers stopped; do not proceed just because a directory is readable. Convert the approved mount to a reviewed persistent mount unit or fstab entry and verify it again before starting containers. NFS and local storage require their own approved source/type values; this CIFS example does not configure them.

Create the Environment and Compose Files

(
set -euo pipefail
set -C
umask 077
cd /opt/media-stack
MEDIA_UID="$(id -u)"
MEDIA_GID="$(getent group media | cut -d: -f3)"
test -n "$MEDIA_GID"
test ! -e .env
test ! -L .env

cat > .env <<EOF
PUID=$MEDIA_UID
PGID=$MEDIA_GID
TZ=America/Chicago
UMASK=002
PLEX_CLAIM=
EOF
chmod 600 .env
)

These file-creation examples refuse existing files. Use a new, private project directory; for an existing stack, back up and edit its configuration deliberately instead. Replace the timezone. A Plex claim token is temporary: obtain it only when needed and remove it after claiming. Do not put it in a shared environment file that every container receives; leave PLEX_CLAIM empty here and use a Plex-only secret-handling procedure. Keep API keys, NAS credentials, provider settings and passwords out of public repositories.

(
set -euo pipefail
set -C
umask 077
cd /opt/media-stack
test ! -e compose.yml
test ! -L compose.yml
cat > compose.yml <<'EOF'
services:
  plex:
    image: lscr.io/linuxserver/plex:latest
    container_name: plex
    network_mode: host
    env_file: .env
    environment:
      VERSION: docker
      PLEX_CLAIM: ${PLEX_CLAIM:-}
    volumes:
      - /opt/media-stack/plex/config:/config
      - /data/media/movies:/movies:ro
      - /data/media/tv:/tv:ro
      - /data/cache/plex:/transcode
    restart: unless-stopped

  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    container_name: prowlarr
    env_file: .env
    volumes:
      - /opt/media-stack/prowlarr/config:/config
    ports:
      - "127.0.0.1:9696:9696"
    networks: [media]
    restart: unless-stopped

  sabnzbd:
    image: lscr.io/linuxserver/sabnzbd:latest
    container_name: sabnzbd
    env_file: .env
    volumes:
      - /opt/media-stack/sabnzbd/config:/config
      - /data/downloads:/data/downloads
    ports:
      - "127.0.0.1:8080:8080"
    networks: [media]
    restart: unless-stopped

  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    env_file: .env
    volumes:
      - /opt/media-stack/sonarr/config:/config
      - /data:/data
    ports:
      - "127.0.0.1:8989:8989"
    networks: [media]
    restart: unless-stopped

  radarr:
    image: lscr.io/linuxserver/radarr:latest
    container_name: radarr
    env_file: .env
    volumes:
      - /opt/media-stack/radarr/config:/config
      - /data:/data
    ports:
      - "127.0.0.1:7878:7878"
    networks: [media]
    restart: unless-stopped

  tdarr:
    image: ghcr.io/haveagitgat/tdarr:latest
    container_name: tdarr
    env_file: .env
    environment:
      serverIP: 0.0.0.0
      serverPort: 8266
      webUIPort: 8265
      internalNode: "true"
      inContainer: "true"
      ffmpegVersion: "7"
      nodeName: MainNode
      auth: "false"
    volumes:
      - /opt/media-stack/tdarr/server:/app/server
      - /opt/media-stack/tdarr/configs:/app/configs
      - /opt/media-stack/tdarr/logs:/app/logs
      - /data/media/tdarr-canary:/media/tdarr-canary
      - /data/cache/tdarr:/temp
    ports:
      - "127.0.0.1:8265:8265"
      - "127.0.0.1:8266:8266"
    networks: [media]
    restart: unless-stopped

networks:
  media:
    name: media
EOF

sudo docker compose -f compose.yml config --quiet
printf 'compose_config_exit=%s\n' "$?"
sudo docker compose -f compose.yml pull
sudo docker compose -f compose.yml images
)

Example expected output (not observed):

compose_config_exit=0
CONTAINER   REPOSITORY                         TAG       IMAGE ID       SIZE
sonarr      lscr.io/linuxserver/sonarr         latest    <IMAGE_ID>     <SIZE>
...

The image rows are illustrative placeholders. latest makes the file easy to read but is not a rollback plan. Before production, record the actual image IDs and immutable repository digests, back up each app, then pin the references you tested. Recheck every selected image's current variables, architecture, devices, and migration notes. The guide retains sudo docker because a fresh official installation does not grant the current user Docker access; membership in the docker group is root-equivalent. Docker's unless-stopped policy also starts containers after the daemon restarts, so the dedicated-host boot gate later in this guide prevents the daemon from starting before final media is mounted. Tdarr's unauthenticated first setup is restricted to trusted users and containers: loopback publication does not block peers on the media Docker network. Enable supported authentication before broader use. Its media bind is limited to the canary; changing a Tdarr library setting cannot expand that bind to the production library.

Host-to-Container Mount Map

ServiceHost pathContainer pathReason
SABnzbd/data/downloads/data/downloadsIncomplete and completed staging only
Sonarr/Radarr/data/dataOne view of downloads and media for imports, hardlinks, or atomic moves
Plex/data/media/movies, /data/media/tv/movies, /tvRead final libraries, never download staging
Tdarr/data/media/tdarr-canary/media/tdarr-canaryOnly the isolated first-run canary; production media is not mounted
ProwlarrNoneNoneIndexer management does not require media access

Remote path mappings should normally be unnecessary here because SABnzbd and the Arr apps agree on /data/downloads. They do not fix inconsistent mounts or permissions. In every launch, backup and update example, replace REPLACE_WITH_EXPECTED_SOURCE and REPLACE_WITH_EXPECTED_FSTYPE with the approved storage identity from the preparation step, not whatever an unexpected mount happens to report. The unchanged placeholders stop execution. Keep the host and mount configuration under trusted administration; these checks are not a defense against a concurrent privileged mount change.

Gate 1: Start and Reach the Control Apps

(
set -euo pipefail
test "$(findmnt -rn --target /data/media -o SOURCE)" = 'REPLACE_WITH_EXPECTED_SOURCE'
test "$(findmnt -rn --target /data/media -o FSTYPE)" = 'REPLACE_WITH_EXPECTED_FSTYPE'
test -f /data/media/.media_mount_marker
cd /opt/media-stack
test -f /data/media/.media_mount_marker || \
  { echo 'media mount marker missing' >&2; exit 1; }

sudo docker compose -f compose.yml up -d prowlarr sabnzbd sonarr radarr
sudo docker compose -f compose.yml ps
sudo docker compose -f compose.yml logs --tail=80 \
  prowlarr sabnzbd sonarr radarr
)

Example expected state (not observed): the marker test exits 0, each container is running, and logs have no database migration, bind, DNS, or permission failure. Running still does not prove ready. Open an SSH tunnel from the trusted workstation:

ssh \
  -L 9696:127.0.0.1:9696 \
  -L 8080:127.0.0.1:8080 \
  -L 8989:127.0.0.1:8989 \
  -L 7878:127.0.0.1:7878 \
  -L 8265:127.0.0.1:8265 \
  YOUR_ADMIN_USER@YOUR_SERVER_IP

With that session open, visit localhost ports 9696, 8080, 8989, and 7878. Set application authentication before any wider access. Confirm each settings page loads, the app reports no unexplained health warning, and its config folder remains populated after a container restart.

Gate 2: Configure Every Internal Handoff

OwnerSettingValueAcceptance check
SABnzbdTemporary folder/data/downloads/incompleteWritable by the configured UID/GID
SABnzbdCompleted/category folderstv to /data/downloads/usenet/tv; movies to /data/downloads/usenet/moviesTest jobs remain outside final media
ProwlarrSonarr/Radarr app URLshttp://sonarr:8989, http://radarr:7878Each Prowlarr app Test passes
SonarrRoot folder/data/media/tvRoot folder is writable and not a download path
RadarrRoot folder/data/media/moviesRoot folder is writable and not a download path
Sonarr/RadarrSABnzbd URLhttp://sabnzbd:8080Download-client Test passes with matching category
Sonarr/RadarrCompleted download handlingEnabled after paths/categories passOne authorized item imports without manual move

Add one lawful indexer definition in Prowlarr, test it there, sync it to one app, and confirm its categories in the generated app entry. Do not add many sources to hide a failed category, URL base, API key, or profile. Use the dedicated Prowlarr troubleshooting guide when the indexer passes in Prowlarr but fails in Sonarr or Radarr.

Automation warning: Lists and indexers are intake controls, not permission controls. A list item, search result, or available file is not evidence that you have the right to obtain or process a work. Leave search-on-add disabled for new lists, tag list additions, review them before searching, and remove or exclude items you are not authorized to use.

Gate 3: Trace One Authorized Movie and Series

  1. Choose a small authorized movie and series sample whose filenames and titles will not expose household history.
  2. Run an interactive search in the owning Arr app and inspect accepted and rejected results.
  3. Send one authorized result to SABnzbd and confirm category movies or tv.
  4. Record the completed path and UTC time before import.
  5. Confirm Sonarr or Radarr imports into the final root without manual permission repair.
  6. Confirm the source was moved or retained exactly as the download-client policy requires.
  7. Record the final path, owner/group, mode, size, device, inode, and link count.
  8. Stop here if any path, ownership, or category differs from the ledger.
# Replace both placeholders with sanitized paths from your canary.
DOWNLOAD_FILE='/data/downloads/usenet/tv/AUTHORIZED_CANARY_FILE'
LIBRARY_FILE='/data/media/tv/AUTHORIZED_CANARY_FILE'

stat -c 'device=%d inode=%i links=%h owner=%U group=%G mode=%a name=%n' \
  "$DOWNLOAD_FILE"
# Run after import against the final path:
stat -c 'device=%d inode=%i links=%h owner=%U group=%G mode=%a name=%n' \
  "$LIBRARY_FILE"

Example expected output (not observed):

device=<DEVICE_ID> inode=<INODE> links=<COUNT> owner=<USER> group=media mode=664 name=<DOWNLOAD_PATH>
device=<DEVICE_ID> inode=<INODE> links=<COUNT> owner=<USER> group=media mode=664 name=<LIBRARY_PATH>

Matching device and inode values can support an atomic rename or hardlink conclusion when collected at the correct stages; link count helps distinguish hardlinks. The placeholders above prove nothing. A NAS protocol, separate mount, copy-on-write filesystem, reflink, or download-client retention policy can change the expected evidence. Record the actual filesystem and import behavior before making a storage-efficiency claim.

Gate 4: Add Plex and Prove Playback

(
set -euo pipefail
test "$(findmnt -rn --target /data/media -o SOURCE)" = 'REPLACE_WITH_EXPECTED_SOURCE'
test "$(findmnt -rn --target /data/media -o FSTYPE)" = 'REPLACE_WITH_EXPECTED_FSTYPE'
test -f /data/media/.media_mount_marker
cd /opt/media-stack
sudo docker compose -f compose.yml up -d plex
sudo docker compose -f compose.yml ps plex
sudo docker compose -f compose.yml logs --tail=80 plex
)

Example expected state (not observed): Plex starts without config or permission errors and is reachable from a trusted LAN client at http://YOUR_SERVER_IP:32400/web. Add only /movies and /tv as libraries. Do not add incomplete downloads, completed staging, Plex transcode cache, or Tdarr cache.

  • Scan the library and confirm the two authorized canary items appear once with expected metadata.
  • Play each from a normal client and open Plex's playback dashboard.
  • Record Direct Play, Direct Stream, or Transcode; do not infer playback mode from low CPU use.
  • Seek, change audio/subtitle selection, and play long enough to expose an immediate read or transcode failure.
  • If hardware-accelerated streaming is in scope, recheck Plex Pass entitlement and supported hardware/device mapping, force a controlled transcode, and look for Plex's documented (hw) indicator. A visible GPU alone is not proof that Plex used it.

Gate 5: Run a Reversible Tdarr Canary

Tdarr mutates files, so keep it last. Copy an authorized sample into /data/media/tdarr-canary, retain the original outside Tdarr's library, hash both inputs, and configure one worker. Do not point a first-run flow at the production movie or TV root.

(
set -euo pipefail
test "$(findmnt -rn --target /data/media -o SOURCE)" = 'REPLACE_WITH_EXPECTED_SOURCE'
test "$(findmnt -rn --target /data/media -o FSTYPE)" = 'REPLACE_WITH_EXPECTED_FSTYPE'
test -f /data/media/.media_mount_marker
cd /opt/media-stack
sudo docker compose -f compose.yml up -d tdarr
sudo docker compose -f compose.yml ps tdarr
sudo docker compose -f compose.yml logs --tail=100 tdarr

sha256sum /data/media/tdarr-canary/AUTHORIZED_CANARY_FILE
mediainfo /data/media/tdarr-canary/AUTHORIZED_CANARY_FILE
)

Example expected state (not observed): Tdarr's server and internal node are available through the localhost tunnel, the canary is readable under /media/tdarr-canary, and the commands print a real hash plus codec/container metadata. Save that output privately. Configure the flow to skip compliant files, transcode only when required, validate the output, and replace only the canary. Compare duration, streams, subtitles, playback, and post-job hash before any wider scope.

Firewall and Remote Access Boundary

Docker's current Ubuntu documentation warns that published container ports can bypass rules operators expect ufw or firewalld to enforce. Loopback bindings prevent the listed admin ports from listening on every host interface, but you must still inspect real sockets, Docker rules, cloud security groups, router forwards, and an off-LAN scan. Plex uses host networking in this example and needs its own host-firewall policy.

sudo ss -lntp | grep -E ':(7878|8080|8265|8266|8989|9696|32400)\b'
sudo docker compose -f /opt/media-stack/compose.yml ps
sudo ufw status verbose

Example expected state (not observed): Arr, Prowlarr, SABnzbd, and Tdarr listen only on 127.0.0.1; Plex listens according to its host-network configuration and is allowed only from intended LAN/VPN ranges. Validate from another LAN device and a genuinely off-LAN network. Do not present a host-only scan as proof of Internet exposure.

VPN note: In this article, VPN means a private administrative access path such as WireGuard, Tailscale, or OpenVPN for reaching your own services. A VPN does not create rights to access, copy, or distribute copyrighted material.

Restart, Backup, Update, and Rollback

Before leaving this dedicated host unattended, configure and test boot ordering. The drop-in below affects every Docker container, not just this project; do not use it on a shared host. First create a reviewed persistent NAS mount unit or fstab entry. For NAS storage, add ConditionPathIsMountPoint=/data/media alongside the shown marker condition; omit that extra condition only for a deliberately approved local directory. Verify the expected source/type and absence of an underlying local marker. These checks gate startup, not a mount outage after startup; ongoing outage detection and controlled shutdown still need testing.

(
set -e
sudo test ! -e /etc/systemd/system/docker.service.d/media-mount.conf
sudo install -d -m 0755 /etc/systemd/system/docker.service.d
sudo tee /etc/systemd/system/docker.service.d/media-mount.conf \
  >/dev/null <<'EOF'
[Unit]
RequiresMountsFor=/data/media
ConditionPathExists=/data/media/.media_mount_marker
EOF

sudo systemctl daemon-reload
sudo systemd-analyze verify docker.service
sudo systemctl enable docker.service
sudo systemctl status --no-pager docker.service
)

Example expected state (not observed): unit verification reports no error and Docker remains active before the test reboot. Reboot during a maintenance window, then verify findmnt --target /data/media, the marker, Docker status, and the five gates in that order. If the NAS mount fails, Docker should remain inactive rather than writing into the underlying directory; inspect systemctl status docker and journalctl -u docker -b, restore the mount, verify the marker, and start Docker manually. Stop if the marker exists on an empty local mountpoint.

Create a restricted backup only after verifying storage and recording which services should be running. Config directories contain secrets and databases. The example below leaves services stopped after an archive or restart failure; diagnose that failure and recheck storage before a deliberate restart. Use a pre-created root-owned, mode-700 backup destination with verified capacity and storage identity. It must not be a symlink or a path inside the project being archived.

(
set -Eeuo pipefail
test "$(findmnt -rn --target /data/media -o SOURCE)" = 'REPLACE_WITH_EXPECTED_SOURCE'
test "$(findmnt -rn --target /data/media -o FSTYPE)" = 'REPLACE_WITH_EXPECTED_FSTYPE'
test -f /data/media/.media_mount_marker
cd /opt/media-stack
BACKUP_DIR=/secure-backups
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
sudo test -d "$BACKUP_DIR"
sudo test ! -L "$BACKUP_DIR"
test "$(sudo stat -c '%U:%a' "$BACKUP_DIR")" = 'root:700'

sudo docker compose -f compose.yml stop
sudo tar -C /opt/media-stack -czf \
  "$BACKUP_DIR/media-stack-config-$STAMP.tgz" .
test "$(findmnt -rn --target /data/media -o SOURCE)" = 'REPLACE_WITH_EXPECTED_SOURCE'
test "$(findmnt -rn --target /data/media -o FSTYPE)" = 'REPLACE_WITH_EXPECTED_FSTYPE'
test -f /data/media/.media_mount_marker
sudo docker compose -f compose.yml start

sudo docker compose -f compose.yml ps
sudo ls -lh "$BACKUP_DIR/media-stack-config-$STAMP.tgz"
)

Expected, not observed: services stop before the archive is created, and restart occurs only after the archive command and repeated storage checks succeed. Failure leaves the workflow stopped for investigation; there is no automatic restart trap. Archive creation is not restore proof. Copy the protected archive off-host with the matching Compose configuration and image identities, then restore one app into an isolated target before relying on it.

Update one application at a time. Back up first, record the old image digest, pull only the chosen service, recreate it, inspect migrations and health, and run its dependent handoff. To roll back, return to the prior digest. If the new application migrated its database incompatibly, stop it and restore the pre-update config rather than starting an older binary against a newer database.

(
set -euo pipefail
test "$(findmnt -rn --target /data/media -o SOURCE)" = 'REPLACE_WITH_EXPECTED_SOURCE'
test "$(findmnt -rn --target /data/media -o FSTYPE)" = 'REPLACE_WITH_EXPECTED_FSTYPE'
test -f /data/media/.media_mount_marker
cd /opt/media-stack
container_id="$(sudo docker compose -f compose.yml ps -q sonarr)"
test -n "$container_id"
image_id="$(sudo docker inspect --format '{{.Image}}' "$container_id")"
sudo docker image inspect --format '{{.Id}} {{json .RepoDigests}}' "$image_id"
sudo docker compose -f compose.yml config --quiet
sudo docker compose -f compose.yml pull sonarr
test "$(findmnt -rn --target /data/media -o SOURCE)" = 'REPLACE_WITH_EXPECTED_SOURCE'
test "$(findmnt -rn --target /data/media -o FSTYPE)" = 'REPLACE_WITH_EXPECTED_FSTYPE'
test -f /data/media/.media_mount_marker
sudo docker compose -f compose.yml up -d sonarr
sudo docker compose -f compose.yml logs --tail=100 sonarr
sudo docker compose -f compose.yml ps sonarr
)

Expected, not observed: the inspect commands identify the image used by the running Sonarr container, which can differ from the local latest tag. A locally built or untagged image may have no repository digest; retain its identity and recoverable image before updating. Inspecting an image does not pin the Compose file, back up the database or prove rollback compatibility. Stop if the recovery image/configuration is not available.

Troubleshooting the First Failed Handoff

SymptomLikely boundaryCheckRollback
UI unreachable from workstationSSH tunnel, loopback binding, or app readinessTunnel process, ss, Compose status/logsKeep apps private; do not bind to all interfaces as a shortcut
Prowlarr app Test failsDocker DNS, URL base, API key, or TLSInternal service name and target app healthRestore previous app URL/key entry
SAB finishes but Arr does not importCategory, completed path, permissions, or mount viewArr Activity/History, SAB path, stat, container mountsPreserve queue and failed file; revert one changed path
Permission deniedUID/GID, umask, NAS ACL, or mount optionsid, stat, findmnt, app logsRestore mount options; do not run every container as root
Plex misses imported itemWrong library path, unreadable file, or scan delayPlex library path, file mode, scan/activity logsReturn to known library path; do not move files manually first
Tdarr output fails validationFlow, codec, cache, device, or resource limitCanary job report, MediaInfo, retained sourceDiscard canary output and restore retained source
Stack fails after rebootNAS mount/order or missing markerfindmnt, marker, boot journal, Compose logsStop apps before they write into an empty local mountpoint

Validation Ledger

  • Compose renders with exit status 0 and actual image digests are recorded privately.
  • Every service has a persistent config path and returns after a container restart.
  • The media mount marker, free-space check, and UID/GID write test pass before apps start.
  • Prowlarr tests one authorized indexer and synchronizes intended categories to Sonarr and Radarr.
  • SABnzbd categories match the Arr download-client entries and completed paths.
  • One authorized movie and series import without manual path or permission repair.
  • Filesystem evidence supports the claimed move/hardlink behavior; no timing or space claim is inferred without measurement.
  • Plex scans and plays both canaries with recorded playback decisions.
  • Tdarr processes only a retained canary and the output passes MediaInfo plus playback validation.
  • A host reboot preserves mounts, configuration, paths, and service readiness.
  • A protected off-host backup exists and one app restore is tested in isolation.
  • An off-LAN test confirms administrative ports are not publicly reachable.

What This Evidence Does Not Prove

  • A running container or HTTP response does not prove the application is configured or the end-to-end workflow works.
  • The Compose example does not prove compatibility with every CPU architecture, image release, GPU, filesystem, NAS, network, provider, or client.
  • One successful import and playback does not prove every codec, subtitle, quality profile, interrupted mount, concurrent job, or database migration.
  • Matching inode evidence on one filesystem does not prove all future imports use hardlinks or atomic moves.
  • A Plex (hw) indicator for one stream does not prove every transcode uses hardware or that Tdarr can share the same device safely.
  • A created archive does not prove restore compatibility, secret availability, or acceptable recovery time.
  • Private bindings and one scan do not prove the service is unreachable from every external path.

Related TechGeeks Resources

References

Final Checkpoint

The stack is ready for deeper tuning only when one authorized TV item and movie can move through the intended path without manual repairs, appear once in Plex, survive a host reboot, and leave a restore-ready configuration record. Tdarr remains a canary until its retained-source rollback and playback validation pass. That is the Day 0 baseline; lists, remote access, GPU tuning, and broad automation come later.

One thought on “Build and Test a Plex, Prowlarr, Sonarr, Radarr, SABnzbd, and Tdarr Stack”

Leave a Reply

Your email address will not be published. Required fields are marked *