Everything you need to go from zero to streaming. Works on any NAS or Docker host.
- Choose Your Setup
- Requirements
- Stack Overview
- Step 1: Create Directories and Clone/Fork Repository
- Step 2: Edit Your Settings
- Step 3: Start the Stack
- Step 4: Configure Each App
- Step 5: Check It Works
- + local DNS (.lan domains)
- + remote access
- Backup
- Optional Utilities
Decide how you'll access your media stack:
| Setup | How you access | What to configure | Good for |
|---|---|---|---|
| Core | 192.168.1.50:8096 |
Just .env + VPN credentials |
Testing, single user |
| + local DNS | jellyfin.lan |
Configure Pi-hole + add Traefik | Home/family use |
| + remote access | jellyfin.yourdomain.com |
Add Cloudflare Tunnel | Watch/request from anywhere |
You can start simple and add features later. The guide has checkpoints so you can stop at any level.
- NAS (Ugreen, Synology, QNAP, etc.) or any Linux server/Raspberry Pi 4+
- Minimum 4GB RAM (8GB+ recommended)
- Storage for media files
-
Docker - Preinstalled on UGOS; one-click install from app store on Synology/QNAP
New to Docker?
Docker runs applications in isolated "containers" - like lightweight virtual machines. Each service (Jellyfin, Sonarr, etc.) runs in its own container.
Docker Compose lets you define multiple containers in a single file (
docker-compose.yml) and start them all with one command. Instead of typing out dozens of options for each container, you just rundocker compose up -d.This stack uses Docker Compose because it has 10+ services that need to work together. The compose file defines how they're connected, what ports they use, and where they store data.
-
SSH access to your NAS (enable in NAS settings)
-
VPN Subscription - Any provider supported by Gluetun (Surfshark, NordVPN, PIA, Mullvad, ProtonVPN, etc.)
-
Usenet Provider (optional, ~$4-6/month) - Frugal Usenet, Newshosting, Eweka, etc.
-
Usenet Indexer (optional) - NZBGeek (~$12/year) or DrunkenSlug (free tier)
Why Usenet? More reliable than public torrents (no fakes), faster downloads, SSL-encrypted (no VPN needed). See SABnzbd setup.
For + remote access:
- Domain name (~$10/year) - Cloudflare Registrar recommended
- Cloudflare account (free tier)
| Component | What it does | Which setup? |
|---|---|---|
| Seerr | Request portal - users request shows/movies here | Core |
| Jellyfin | Media player - like Netflix but for your own content | Core |
| Sonarr | TV show manager - searches for episodes, sends to download client | Core |
| Radarr | Movie manager - searches for movies, sends to download client | Core |
| Prowlarr | Indexer manager - finds download sources for Sonarr/Radarr | Core |
| qBittorrent | Torrent client - downloads files (through VPN) | Core |
| SABnzbd | Usenet client - downloads files via SSL (optional, for Usenet users) | Core |
| Bazarr | Subtitle manager - finds and syncs subtitles for your library | Core |
| Gluetun | VPN container - routes download traffic through VPN so your ISP can't see what you download | Core |
| Pi-hole | DNS server - blocks ads, provides Docker DNS | Core |
| Traefik | Reverse proxy - enables .lan domains |
+ local DNS |
| Cloudflared | Tunnel to Cloudflare - secure remote access without port forwarding | + remote access |
Core:
.env- Media path, timezone, PUID/PGID, VPN credentials
+ local DNS:
.env- Add NAS IP, Pi-hole password, Traefik macvlan settings
+ remote access:
.env- Add domain, Traefik dashboard authtraefik/dynamic/vpn-services.yml- Replaceyourdomain.com
Files you DON'T edit:
docker-compose.*.yml- Work as-is, configured via.envpihole/dnsmasq.d/02-local-dns.conf- Generated from example via sed commandtraefik/dynamic/tls.yml- Security defaultstraefik/dynamic/local-services.yml- Auto-generates from.env
| File | Purpose | Which setup? |
|---|---|---|
docker-compose.arr-stack.yml |
Core media stack (Jellyfin, *arr apps, downloads, VPN) | Core |
docker-compose.traefik.yml |
Reverse proxy for .lan domains and external access | + local DNS |
docker-compose.cloudflared.yml |
Secure tunnel to Cloudflare (no port forwarding) | + remote access |
docker-compose.utilities.yml |
Monitoring, auto-recovery, disk usage | Utilities (optional) |
See Quick Reference for full service lists, .lan URLs, and network details.
Want to use Plex?
This stack uses Jellyfin by default, but Plex works too — either as a replacement or alongside it. Seerr supports both natively. For reference, there's an old Plex compose file in the git history.
Add this to docker-compose.arr-stack.yml (add plex-config to the volumes: section too):
plex:
image: lscr.io/linuxserver/plex:latest
container_name: plex
ports:
- "32400:32400"
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- VERSION=docker
- PLEX_CLAIM=${PLEX_CLAIM} # Get from https://plex.tv/claim (expires in 4 mins)
# Hardware transcoding (Intel Quick Sync) - remove if no Intel GPU
devices:
- /dev/dri:/dev/dri
volumes:
- plex-config:/config
- ${MEDIA_ROOT}/movies:/media/movies:ro
- ${MEDIA_ROOT}/tv:/media/tv:ro
networks:
arr-stack:
ipv4_address: 172.20.0.11
restart: always
logging: *default-logging
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:32400/identity"]
interval: 1m
timeout: 30s
retries: 2
start_period: 30sYou'll also need to:
- Add
PLEX_CLAIMto your.envfile (only needed on first run) - Add a Traefik route for
plex.lan→ port32400 - Add a Pi-hole DNS entry for
plex.laninpihole/dnsmasq.d/02-local-dns.conf - Enable hardware transcoding in Plex Settings → Transcoder → "Use hardware acceleration when available" (requires Plex Pass). Jellyfin and Plex can share the iGPU
If you're replacing Jellyfin rather than running both, also remove the Jellyfin service, its volumes (jellyfin-config/jellyfin-cache), and rename its Traefik routes to Plex. If running both, add Plex as a media server in Seerr settings alongside Jellyfin.
Plex support remains untested.
First, set up the folder structure for your media and get the files from this GitHub repo onto your NAS.
Fork first (recommended): Click "Fork" on GitHub, then clone your fork. This lets you add your own services, customise configs, and pull upstream updates when you want them.
Just want to try it? You can clone this repo directly instead of forking. You'll still get updates via
git pull, but can't push your own changes.
Ugreen NAS (UGOS)
Docker comes preinstalled on UGOS - no installation needed! Folders created via SSH don't appear in UGOS Files app, so create top-level folders via GUI.
- Open UGOS web interface → Files app
- Create shared folders: data, docker
- Inside data, create subfolder: media, then inside media create tv and movies
- Enable SSH: Control Panel → Terminal → toggle SSH on
- SSH into your NAS and create download directories + install git:
ssh your-username@nas-ip
# Install git (Ugreen NAS uses Debian)
sudo apt-get update && sudo apt-get install -y git
# Create media and download directories
sudo mkdir -p /volume1/data/media/{tv,movies}
sudo mkdir -p /volume1/data/torrents/{tv,movies}
sudo mkdir -p /volume1/data/usenet/{incomplete,complete/{tv,movies}}
sudo chown -R 1000:1000 /volume1/data/media /volume1/data/torrents /volume1/data/usenet
# Set where the stack lives. Default is volume1; change to /volume2/docker/arr-stack
# if you want the stack on an SSD or second volume. You'll also set this in .env later.
NAS_STACK_DIR=/volume1/docker/arr-stack
# Clone the repo
sudo mkdir -p "$(dirname "$NAS_STACK_DIR")"
cd "$(dirname "$NAS_STACK_DIR")"
sudo git clone https://github.com/Pharkie/ultimate-arr-stack.git "$(basename "$NAS_STACK_DIR")" # or your fork
sudo chown -R 1000:1000 "$NAS_STACK_DIR"Note: Use sudo for Docker commands on Ugreen NAS. Service configs are stored in Docker named volumes (auto-created on first run).
Note on UGOS Antivirus
UGOS has a built-in antivirus scanner that runs scheduled scans. The default settings can scan your entire data folder, taking 40-50+ hours and causing system slowdowns. To fix:
- Open Security app → Scheduled Scan
- Remove
/volume1/datafrom the scan targets - Change frequency from daily to weekly
- Under "Scan file types", select Specific and uncheck Multimedia Data
Scanning media files for viruses is unnecessary - video/audio files can't contain executable malware.
Synology / QNAP
Use File Station to create:
- data shared folder with subfolder: media (containing tv and movies)
- docker shared folder
Then via SSH:
ssh your-username@nas-ip
# Install git if not present (Synology)
sudo synopkg install Git
# Create media and download directories
sudo mkdir -p /volume1/data/media/{tv,movies}
sudo mkdir -p /volume1/data/torrents/{tv,movies}
sudo mkdir -p /volume1/data/usenet/{incomplete,complete/{tv,movies}}
sudo chown -R 1000:1000 /volume1/data/media /volume1/data/torrents /volume1/data/usenet
# Set where the stack lives. Default is volume1; change to /volume2/docker/arr-stack
# if you want the stack on an SSD or second volume. You'll also set this in .env later.
NAS_STACK_DIR=/volume1/docker/arr-stack
# Clone the repo
sudo mkdir -p "$(dirname "$NAS_STACK_DIR")"
cd "$(dirname "$NAS_STACK_DIR")"
sudo git clone https://github.com/Pharkie/ultimate-arr-stack.git "$(basename "$NAS_STACK_DIR")" # or your fork
sudo chown -R 1000:1000 "$NAS_STACK_DIR"Linux Server / Generic
# Install git if needed
sudo apt-get update && sudo apt-get install -y git
# Create media and download directories
sudo mkdir -p /srv/data/media/{tv,movies}
sudo mkdir -p /srv/data/torrents/{tv,movies}
sudo mkdir -p /srv/data/usenet/{incomplete,complete/{tv,movies}}
sudo chown -R 1000:1000 /srv/data
# Clone the repo
cd /srv/docker
sudo git clone https://github.com/Pharkie/ultimate-arr-stack.git arr-stack # or your fork
sudo chown -R 1000:1000 /srv/docker/arr-stackNote: Adjust paths in docker-compose files if using different locations. Service configs are stored in Docker named volumes (auto-created on first run).
/volume1/ (or /srv/)
├── data/
│ ├── media/ # Library files (TRaSH recommended)
│ │ ├── movies/ # Movie library (Radarr → Jellyfin)
│ │ └── tv/ # TV show library (Sonarr → Jellyfin)
│ ├── torrents/ # qBittorrent downloads
│ │ ├── tv/ # Sonarr category
│ │ └── movies/ # Radarr category
│ └── usenet/ # SABnzbd downloads
│ ├── incomplete/ # In-progress downloads
│ └── complete/ # Completed downloads
│ ├── tv/ # Sonarr category
│ └── movies/ # Radarr category
└── docker/
└── arr-stack/
├── traefik/ # + local DNS / + remote access only
│ ├── traefik.yml
│ └── dynamic/
│ └── vpn-services.yml
└── cloudflared/ # + remote access only
└── config.yml
Only
traefik/andcloudflared/appear as folders on your NAS. Everything else is managed by Docker internally.Multi-volume NAS? You can keep your Docker install (the arr-stack files, set via
NAS_STACK_DIR) on one volume and your media library (set viaMEDIA_ROOT) on another. For example: Docker on/volume1/docker/arr-stackwith media on/volume2/data, or vice versa. Both are set in.env(see Step 2.2 forMEDIA_ROOT).Why this structure? All media directories live under one
MEDIA_ROOT, mounted as a single/datavolume in containers that need both downloads and library access (qBittorrent, SABnzbd, Sonarr, Radarr). This enables hardlinks: when Sonarr/Radarr import a file, they create a hardlink instead of copying, making imports instant and using zero extra disk space. See TRaSH Guides: Hardlinks.
The stack needs your media path, timezone, VPN credentials, and a few passwords. Everything goes in one .env file.
Note: From this point forward, all commands run on your NAS via SSH. If you closed your terminal, reconnect with
ssh your-username@nas-ipandcd $NAS_STACK_DIR(or your clone location). UGOS users: SSH may time out—re-enable in Control Panel → Terminal if needed.
cp .env.example .envHow to edit .env: The next sections show lines to find and edit inside the file — they're not commands to paste into the shell. Pick whichever editor you're comfortable with:
- In the SSH terminal:
nano .envis the friendliest option (shortcuts shown at the bottom of the screen). Save withCtrl+O,Enter, then exit withCtrl+X. Pre-installed on Ugreen, Synology, and QNAP. - GUI editor: Edit via your NAS's web file manager, or use VS Code with the Remote-SSH extension if you'd rather have syntax highlighting and a familiar interface.
Keep .env open as you work through the rest of Step 2.
Set MEDIA_ROOT in .env to match your media folder location:
# Examples:
MEDIA_ROOT=/volume1/data # Ugreen, Synology
MEDIA_ROOT=/share/data # QNAP
MEDIA_ROOT=/srv/data # Linux serverContainers run as the user specified by PUID/PGID. This must match who owns your media folders:
# SSH to NAS, then run:
ls -ln /volume1/ # Shows folder owners as numbers (UID/GID)
id # Shows YOUR user's UID/GID - these should matchIf wrong, you'll see errors like "Folder '/tv/' is not writable by user 'abc'" in Sonarr/Radarr.
Do I need a separate non-admin NAS user for the stack? No. The containers already run as a non-root UID via
PUID/PGID, and the Docker daemon itself runs as root regardless of which NAS login invokeddocker compose— so a dedicated non-admin account wouldn't shrink the blast radius of a container compromise. Skip it.
Set your timezone (used for scheduling, logs, and UI times):
TZ=Europe/London # Find yours: https://en.wikipedia.org/wiki/List_of_tz_database_time_zonesAdd your VPN credentials to .env. Gluetun supports 30+ providers—find yours below:
Surfshark (WireGuard)
| Step | Screenshot |
|---|---|
| 1. Go to my.surfshark.com → VPN → Manual Setup → Router → WireGuard | ![]() |
| 2. Select "I don't have a key pair" | ![]() |
3. Under Credentials, enter a name (e.g., ugreen-nas) |
![]() |
| 4. Click "Generate a new key pair" and copy both keys to your notes | ![]() |
| 5. Click "Choose location" and select a server (e.g., United Kingdom) | ![]() |
6. Click the Download arrow to get the .conf file |
![]() |
-
Open the downloaded
.conffile and note theAddressandPrivateKeyvalues:[Interface] Address = 10.14.0.2/16 PrivateKey = aBcDeFgHiJkLmNoPqRsTuVwXyZ...
-
Edit
.env:VPN_SERVICE_PROVIDER=surfshark VPN_TYPE=wireguard WIREGUARD_PRIVATE_KEY=your_private_key_here WIREGUARD_ADDRESSES=10.14.0.2/16 VPN_COUNTRIES=United Kingdom
Note:
VPN_COUNTRIESin your.envmaps to Gluetun'sSERVER_COUNTRIESenv var.
Other Providers (NordVPN, PIA, Mullvad, etc.)
See the Gluetun wiki for your provider:
Update .env with your provider's required variables.
Don't want Pi-hole? Change
DNS_ADDRESS=172.20.0.5to your preferred public DNS (e.g.,1.1.1.1,8.8.8.8) indocker-compose.arr-stack.yml.
Pi-hole Password:
Static IP required: Pi-hole binds its DNS listener to
NAS_IPat boot. Your NAS must have a static IP that matchesNAS_IPin.env. If the IP comes from DHCP, Docker may start before it's assigned and Pi-hole will fail. Check withip addr show eth0— if you seedynamic, configure a static IP first. See Troubleshooting if Pi-hole fails after reboot.
Invent a password. Or, to generate a random one:
openssl rand -base64 24Edit .env: PIHOLE_UI_PASS=your_password
For + remote access: Traefik Dashboard Auth
Invent a password for the Traefik dashboard and note it down, then generate the auth string:
docker run --rm httpd:alpine htpasswd -nbB admin 'your_chosen_password'Copy the output to .env, wrapping in single quotes to protect the $ characters:
TRAEFIK_DASHBOARD_AUTH='admin:$2y$05$...'
Time to launch your containers and verify everything connects properly.
# Create dnsmasq config directory (+ local DNS users will add DNS entries later)
mkdir -p pihole/dnsmasq.d
docker compose -f docker-compose.arr-stack.yml up -dPort 1900 conflict? If you get "address already in use" for port 1900, your NAS's built-in media server is using it. Comment out
- "1900:1900/udp"in the Jellyfin section of the compose file. Jellyfin works fine without it (only affects smart TV auto-discovery).
# Check all containers are running
docker ps
# Check VPN connection (should show a VPN IP and location)
docker logs gluetun 2>&1 | grep "Public IP address" | tail -1
Your stack is running! Now configure each app to work together.
Choose your path:
- Script-Assisted: Script-Assisted Setup (~5 min — quicker, but the script is LLM-generated and human-reviewed so check it for security first)
- Manual: Full Manual Setup (~30 min — do it yourself without trusting a script)
Both guides walk you through creating accounts, connecting services, and adding your indexers — step by step.
Time to verify everything is connected and protected before you start adding content.
⚠️ Do this before downloading anything. If your VPN isn't working, your real IP will be exposed to trackers.
Run on NAS via SSH:
docker exec gluetun wget -qO- https://ipinfo.io/ip # Should show VPN IP, not your home IP
docker exec qbittorrent wget -qO- https://ipinfo.io/ip # Same - confirms qBit uses VPNThorough test: Visit ipleak.net from your browser, then run the same test from inside qBittorrent:
docker exec qbittorrent wget -qO- https://ipleak.net/jsonCompare the IPs — qBittorrent should show your VPN's IP, not your home IP.
- Sonarr/Radarr: Settings → Download Clients → Test
- Add a TV show or movie (noting legal restrictions) → verify it appears in qBittorrent
- After download completes → verify it moves to library
- Jellyfin → verify media appears in library
Your media stack is fully configured. The two services you'll use most:
- Seerr —
http://NAS_IP:5055— Request new shows and movies - Jellyfin —
http://NAS_IP:8096— Watch your media library
Replace
NAS_IPwith your NAS's IP address (e.g.,192.168.1.50). For all service URLs, ports, and network details, see Quick Reference.
Try it out: Open Seerr, request a show or movie, then watch it download in Sonarr/Radarr and appear in Jellyfin.
What's next?
- Stop here if IP:port access is fine for you
- Continue to + local DNS for friendly
.lanURLs (e.g.,http://jellyfin.lan) and remote access
Access services by name (http://sonarr.lan) instead of port numbers. Requires Pi-hole + Traefik.
Watch and request media from anywhere via jellyfin.yourdomain.com. Requires a domain + Cloudflare Tunnel.
Service configs are stored in Docker named volumes. Run periodic backups:
./scripts/arr-backup.sh --tarCreates a ~13MB tarball of essential configs (VPN settings, indexers, request history, etc.).
See Backup & Restore for full details on what's backed up, restore procedures, and automation.
Deploy monitoring, auto-recovery, and disk usage tools.
Other *arr apps you can add to your Core stack:
- Lidarr - Music (port 8686)
- Readarr - Ebooks (port 8787)
Example: Adding Lidarr
-
Add to
docker-compose.arr-stack.ymlvolumes section:lidarr-config: -
Add port to gluetun:
- "8686:8686" # Lidarr
-
Add the service:
lidarr: image: lscr.io/linuxserver/lidarr:latest container_name: lidarr network_mode: "service:gluetun" depends_on: gluetun: condition: service_healthy environment: - PUID=${PUID} - PGID=${PGID} - TZ=${TZ} volumes: - lidarr-config:/config - ${MEDIA_ROOT}:/data restart: unless-stopped
-
Redeploy:
docker compose -f docker-compose.arr-stack.yml up -d -
(+ local DNS) Add
.landomain:# Add to pihole/dnsmasq.d/02-local-dns.conf echo "address=/lidarr.lan/TRAEFIK_LAN_IP" >> pihole/dnsmasq.d/02-local-dns.conf # Add Traefik route to traefik/dynamic/local-services.yml # (router + service, see existing entries as template) # Restart Pi-hole to pick up bind-mount changes (reloaddns alone is NOT enough) docker restart pihole
- TRaSH Guides — Quality profiles, naming conventions, and best practices for Sonarr, Radarr, and more
Issues? Report on GitHub or chat on Reddit.





