Skip to content
 
 

Latest commit

 

History

920 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ultimate-arr-stack

A Docker Compose media stack for a NAS: request a film or a series, it downloads through a VPN, and it appears in Jellyfin. Ships the *arr apps, two download clients, encrypted local DNS with .lan names, HTTPS with auth, monitoring, and tiered backups.

License: CC BY-NC 4.0

This is a fork, and a substantially modified one. Upstream is Pharkie/ultimate-arr-stack, itself forked from TheRealCodeVoyage/arr-stack-setup-with-pihole. This tree runs on a Ugreen NAS on a segmented home network, and everything below describes this tree — commands are checked against the scripts they ship in, not copied from upstream's README. What changed, and why it matters if you deploy this: Differences from upstream.

Contents: What's in the stack · Architecture · Requirements · Quick start · Access · Common operations · Testing · Backups and restore · Troubleshooting · Documentation · Repository layout · Differences from upstream · Development and verification · License and attribution

What's in the stack

Group Services Role
Requests Seerr The front door: users ask for a title, it routes to the right app
Library Sonarr, Radarr Series and film management, quality profiles, imports
Indexers Prowlarr Indexer manager; keeps Sonarr/Radarr in sync
Subtitles Bazarr Subtitle search and sync
Downloads Decypharr (torrents via TorBox debrid), usenet-blackhole (Usenet via TorBox) Both let TorBox fetch the release and pull the finished file back over HTTPS; only indexer scraping goes through the VPN
Playback Jellyfin Media server and apps
VPN Gluetun, vpn-socks5 WireGuard tunnel for the download clients; SOCKS proxy
DNS Pi-hole, dnscrypt-proxy Network-wide DNS + ad blocking, upstream encrypted
Edge Traefik, Tailscale, cloudflared (opt-in) Reverse proxy, mesh VPN, Cloudflare tunnel
Add-ons Magnetio (addon, scraper, redis), stremio-jellyfin Optional extras
Operations Uptime Kuma, Homepage, Beszel + agent, diun, duc, configarr, deunhealth, gluetun-recover, gluetun-rotator, docker-socket-proxy Monitoring, dashboards, disk usage, config-as-code, self-healing

Architecture

   browser ──▶ Traefik ──────────────┐   HTTPS + basic auth, *.lan names
              (arr-core bridge,      │
               172.20.0.0/24)        │
                                     ▼
   Seerr ──▶ Sonarr / Radarr ──┬─▶ Decypharr ───────▶ TorBox (debrid, HTTPS)
                               └─▶ usenet-blackhole ─▶ TorBox (usenet API, HTTPS)
                    │                                          ▲
                    ▼                                          │
                Jellyfin ◀── media volume (hardlinked, no copies)
                    ▲
   LAN clients ──▶ Pi-hole ──▶ dnscrypt-proxy ──▶ encrypted upstream

Three invariants hold the design together:

  • Downloads share Gluetun's network namespace. A service that should be tunneled is joined to network_mode: "service:gluetun", so a VPN drop stops its traffic rather than leaking it. Recreating Gluetun orphans its dependents — ./scripts/detect-vpn-zombies.sh exists because those containers keep reporting healthy while having no network at all.
  • One volume, hardlinks. Media and downloads are siblings on the same filesystem, so an import is a link, not a copy: instant, and no second copy of every file.
  • Pi-hole is a single point of failure by design. The router hands its address to every DHCP pool, so a Pi-hole that is down is a house with no DNS. That is why restarts never use docker compose down, and why the boot reconcile exists.

Requirements

Host Any Docker host — Ugreen, Synology, QNAP, a Linux box, or a Raspberry Pi 4+. The reference deployment is a Ugreen NAS (aarch64).
Docker Engine + Compose v2, with the ability to run network_mode: service:* and macvlan networks
Static IP Required. Pi-hole binds ${NAS_IP}:53; if the address arrives by DHCP after Docker starts, Pi-hole never binds and the network loses DNS
Storage One volume holding media/ plus torbox/ and/or usenet/ as siblings, for hardlinks
Secrets A .env (gitignored) built from .env.example — VPN credentials, app API keys, LAN addresses
Optional Intel/AMD iGPU for hardware transcoding; a Cloudflare account for the tunnel; a Tailscale account; a domain for remote access

Quick start

# 1. Clone
git clone https://github.com/leonardoazeredo/ultimate-arr-stack.git
cd ultimate-arr-stack

# 2. Configure. .env is gitignored and holds every secret and every LAN address.
cp .env.example .env
$EDITOR .env     # NAS_IP, LAN_SUBNET, PUID/PGID, VPN credentials, app API keys

# 3. Bring up the core stack (it creates the arr-core network and DNS first)
docker compose -f docker-compose.arr-stack.yml up -d

# 4. Verify
docker compose -f docker-compose.arr-stack.yml ps      # every service healthy?
dig @<NAS_IP> jellyfin.lan                             # DNS answering?
curl -sI http://<NAS_IP>:8096/System/Info/Public       # Jellyfin responding?

Then add the layers you want — each is a separate compose file so one can never take another down:

Layer Bring it up Guide
Edge: Traefik, *.lan DNS, HTTPS with auth docker compose -f docker-compose.traefik.yml up -d LOCAL-DNS.md, HTTPS-LOCAL.md
Monitoring, dashboards, configarr, recovery helpers docker compose -f docker-compose.utilities.yml up -d UTILITIES.md
Remote access via Tailscale docker compose -f docker-compose.tailscale.yml up -d TAILSCALE.md
Remote access via Cloudflare tunnel copy cloudflared/config.yml.example to config.yml, then docker compose -f docker-compose.cloudflared.yml --profile tunnel up -d REMOTE-ACCESS.md
Magnetio add-on docker compose -f docker-compose.magnetio.yml up -d UTILITIES.md

The cloudflared stack is opt-in: it sits behind a tunnel profile precisely so a up -d over every compose file cannot start a tunnel that has no config to read.

The full walkthrough — directories on the host, app configuration, DNS, HTTPS, remote access — is docs/SETUP.md. App setup is either script-assisted or manual.

Access

Service LAN .lan (with the edge layer) Remote
Jellyfin NAS_IP:8096 https://jellyfin.lan yes, if exposed
Seerr NAS_IP:5055 https://seerr.lan yes, if exposed
Sonarr / Radarr / Prowlarr / Bazarr NAS_IP:8989 / :7878 / :9696 / :6767 https://sonarr.lan, https://radarr.lan, https://prowlarr.lan, https://bazarr.lan LAN only
SABnzbd NAS_IP:8082 https://sabnzbd.lan LAN only
Decypharr NAS_IP:8282 — (no .lan name) LAN only
AdGuard Home (DNS) — (on the router) http://192.168.8.1:3000 maintenance VLAN only
Uptime Kuma NAS_IP:3001 https://uptime.lan LAN only
Traefik dashboard — (reached through Traefik) https://traefik.lan LAN only
Homepage / Beszel / duc / usenet-status — (no published port; reached through Traefik) https://homepage.lan, https://beszel.lan, https://duc.lan, https://usenet.lan LAN only

The .lan names need the edge layer, and the https URLs need its auth middleware; Jellyfin and Seerr keep their own app-level login on top. The complete matrix, including which services are deliberately unpublished, is docs/REFERENCE.md.

Common operations

Task Command
Health at a glance docker compose -f docker-compose.arr-stack.yml ps
Restart safely — never down ./scripts/restart-stack.sh [all|arr|traefik|utilities|magnetio|cloudflared]
Reconcile every stack after a reboot ./scripts/boot-compose-up.sh
Prove traffic is going through the VPN ./scripts/check-vpn.sh
Find containers stranded on a dead VPN namespace ./scripts/detect-vpn-zombies.sh
Detect the credential-propagation drift class ./scripts/detect-credential-drift.sh
Configure the apps through their APIs ./scripts/configure-apps.sh
Clear stuck download-queue items ./scripts/queue-cleanup.sh
Turn a Stremio library addition into a download ./scripts/stremio-library-sync.sh --apply
Repair Sonarr folder names / Radarr paths ./scripts/fix-sonarr-folders.sh, ./scripts/fix-radarr-paths.sh
Back up config volumes ./scripts/arr-backup.sh
Apply tiered retention to backups ./scripts/backup-prune.sh
Deploy the current branch to the host ./scripts/sync-nas.sh
Update images docs/UPGRADING.md

⚠️ Two commands this repo never runs, and you should not either. docker compose down takes Pi-hole with it and therefore kills DNS for the whole network; and --remove-orphans deletes every container from the other compose files, because this stack's services share a project name across files. Both have caused real outages — see docs/TROUBLESHOOTING.md.

Testing

Three layers, and none of them needs a cloud CI account:

Layer Run it with What it proves
Static + behavioural (bats) ./tests/run-tests.sh Compose validity, duplicate ports and IPs, pinned images, secret hygiene, .env documentation, script behaviour, hook wiring. Needs no Docker and no host access. It ends with a census — executed / skipped / failed, the reasons for the skips, and a warning when the git-gated tests produced no verdict on this host. Read that line; "ok" is not the same as "covered".
Python toolkit ./tests/toolkit/pytest.sh The scripts/lib/*.py modules. Exits 77, never 0, when Docker is unavailable — an absent oracle must not read as a passing one.
End-to-end (Playwright) npm run test:e2e Real HTTP, UI, VPN-egress and DNS behaviour against a live stack. Sixteen of its tests need the Docker socket, so the full run happens on the host itself. A floor reporter fails any full-suite run that executed fewer than 30 tests: a missing or rotted .env.e2e used to produce a green run that had executed almost nothing.

CI runs the deterministic layers for you on every push and pull request: the bats suite above (its census is written into the job summary), the mutation corpus entries that guard the files a change touches, actionlint over the workflow files, and terraform fmt -check. The expensive halves run nightly or on dispatch — the full corpus, the universalmutator discovery sweep, and the kcov coverage diagnostic. Nothing that needs the live host is in that workflow: the end-to-end suite, the deploy pipeline and the router-segmentation assertion stay out by design.

Guards here are proved able to fail, not assumed to work. tests/mutation/ holds a corpus of defects this repo actually shipped, which the suite must kill; a generated sweep hunts for code no test guards; and tests/mutation-corpus.bats re-checks in seconds that every corpus pattern still changes its target. After touching a guard, run ./tests/mutation/run-mutations.sh — and the manual deploy workflow does exactly that for the guards a change touches. The rules that keep this honest are written down in tests/mutation/README.md and docs/TEST-HARDENING-LOG.md.

Backups and restore

./scripts/arr-backup.sh archives the config volumes that cannot be regenerated — app databases, credentials, hand-edited resolver and DNS config — to a single tarball; ./scripts/backup-prune.sh then applies grandfather-father-son tiers so the directory does not grow without bound. Coverage and caveats: docs/BACKUP.md. Rebuilding a host from a backup: docs/RESTORE.md.

Troubleshooting

docs/TROUBLESHOOTING.md is the full list. The four that cost the most time here:

  • DNS is dead but Pi-hole looks fine. The container is running, healthy, and serving nobody, because its published ports vanished. The tell is an empty PORTS column in docker ps and nothing listening on NAS_IP:53.
  • Downloads stopped, everything is green. A VPN recreate orphaned its dependents; containers that only check localhost keep reporting healthy with no network. Assert on egress, not on health status.
  • A reboot left services unreachable. Docker restores restart: always containers itself, and address-pinned bindings can silently fail. ./scripts/boot-compose-up.sh reconciles every stack; it is worth having scheduled at boot.
  • The stack came up half-missing. Almost always --remove-orphans, or a service recreated through the wrong compose file.

Documentation

Doc Purpose
SETUP.md The full install walkthrough
ARCHITECTURE.md How the pieces fit together
REFERENCE.md Cheat sheet: URLs, ports, IPs, commands
APP-CONFIG-QUICK.md / APP-CONFIG.md / APP-CONFIG-ADVANCED.md Configuring each app, script-assisted or manual
LOCAL-DNS.md / HTTPS-LOCAL.md .lan names, TLS and auth
REMOTE-ACCESS.md / TAILSCALE.md Reaching the stack from outside
UTILITIES.md Monitoring, dashboards, configarr, disk usage
UPGRADING.md / MAINTENANCE.md Image bumps and routine upkeep
BACKUP.md / RESTORE.md Protecting and rebuilding config
TROUBLESHOOTING.md Symptom-first fixes
TORBOX-API.md The provider API this stack depends on, and how it is used
DECYPHARR-PATCH.md Why Decypharr is built from source, and how the patch pipeline works
HOME-ASSISTANT.md Completion notifications
MIGRATION-arr-off-vpn.md Why Sonarr and Radarr are deliberately outside the tunnel
EXIT-NODE-PROJECT-LOG.md / TEST-HARDENING-LOG.md Audited project logs: what shipped, what was wrong, what is still open
LEGAL.md Intended use and disclaimer

Repository layout

docker-compose.arr-stack.yml     core: VPN, apps, downloads, DNS, media
docker-compose.traefik.yml       reverse proxy + macvlan for *.lan
docker-compose.utilities.yml     monitoring, dashboards, configarr, recover/rotate helpers
docker-compose.tailscale.yml     Tailscale subnet router
docker-compose.cloudflared.yml   optional Cloudflare tunnel (opt-in profile)
docker-compose.magnetio.yml      Magnetio add-on, scraper and redis
scripts/                         operations, one concern per script
tests/                           bats suite, Python toolkit, Playwright e2e, mutation framework
docs/                            every guide, plus the audited project logs
pihole/dnsmasq.d/                local DNS records
traefik/dynamic/                 routers and middleware
terraform/                       app configuration as code

Differences from upstream

Deploying this fork is not the same as deploying upstream's. The load-bearing differences:

  • The Docker network is arr-core (upstream: arr-stack), and compose project/volume names are pinned explicitly so a rename cannot orphan data. Anything referencing the old network name needs updating.
  • DNS is encrypted end to end. Pi-hole forwards to the in-stack dnscrypt-proxy rather than to a public resolver in the clear.
  • The Cloudflare tunnel is opt-in behind a compose profile, so an unconditional up -d cannot start a tunnel with no configuration and crash-loop it.
  • The Tailscale exit-node role runs on the router, not in this stack; the NAS-side implementation was built, measured, and decommissioned. The history and the numbers are in docs/EXIT-NODE-PROJECT-LOG.md.
  • A much heavier verification harness: a mutation corpus with a triage ledger, a containerised Python and coverage toolkit, an executed-count floor on the end-to-end suite, and a skip census in the bats runner.
  • Additional operations scripts: boot reconciliation, VPN zombie detection, credential-drift detection, branch deployment, Sonarr/Radarr repair.

Development and verification

LLM-generated, human-reviewed. This code was written with Claude Code (Anthropic), planned, directed and verified by the human author, whose review extends past the generated output to the tests and the deployments. It has still had limited manual review — make your own checks before trusting it with your library.

Work lands the same way every time: a feature branch, tested against the real deployment before it can reach main, and main deployed from git rather than by copying files. The full rule, including why testing happens on the host rather than in CI, is in CLAUDE.md.

License and attribution

Documentation, configuration files and examples are licensed CC BY-NC 4.0 (Attribution-NonCommercial). The bundled applications (Sonarr, Radarr, Jellyfin, and the rest) keep their own licenses.

Forked from Pharkie/ultimate-arr-stack, itself forked from TheRealCodeVoyage/arr-stack-setup-with-pihole.

This project provides configuration for legal, open-source software for managing a personal media library. See docs/LEGAL.md for intended use, your responsibilities, and the disclaimer.

About

A Docker Compose stack for automated media management. Request a show or movie, it downloads and appears in Jellyfin—ready to watch, VPN-protected. Works on Ugreen, Synology, QNAP, or any Docker host.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages