Everything from the basic setup, plus transcoding, quality profiles, metadata automation, download watchdog, VPN failover, and automated backups.
Time to complete: About 30 minutes
If you already have OrbStack (or Docker Desktop) and Plex installed, you can run a single command that handles the core setup:
curl -fsSL https://raw.githubusercontent.com/liamvibecodes/mac-media-stack-advanced/main/bootstrap.sh | bashIt will prompt you for VPN keys, configure all core services, auto-wire Recyclarr + Unpackerr API keys, install native Tdarr on macOS, preload the quality-first Tdarr flow, and install automation jobs. You'll still need Step 7 for Kometa and final Tdarr library mapping.
To run from a local clone with custom paths:
bash bootstrap.sh --media-dir /Volumes/T9/Media --install-dir ~/mac-media-stack-advancedAlready on an older clone and want the newest release tag:
bash <(curl -fsSL https://raw.githubusercontent.com/liamvibecodes/mac-media-stack-advanced/main/scripts/update-to-latest-release.sh)Already running the basic stack and want an in-place migration? Use UPGRADE.md.
- A Mac (any recent macOS)
- At least 50GB free disk space (media libraries will need more)
- OrbStack (recommended) or Docker Desktop installed and running
- Plex installed and signed in, OR use Jellyfin (runs in Docker, no install needed)
- ProtonVPN WireGuard credentials
- A free TMDB API key (https://www.themoviedb.org/settings/api) (Plex/Kometa only)
Why OrbStack? It starts in ~2 seconds (vs 30s for Docker Desktop), uses ~1GB RAM (vs 4GB), and has 2-10x faster file I/O. It's a drop-in replacement that runs the same Docker commands. Docker Desktop works fine too.
You need a container runtime to run the behind-the-scenes services. Pick one:
OrbStack is faster and lighter than Docker Desktop (~2s startup, ~1GB RAM).
brew install --cask orbstackOr download from https://orbstack.dev. Open it once after installing.
- Go to https://www.docker.com/products/docker-desktop/
- Click "Download for Mac"
- If you have an M-series Mac (M1, M2, M3, M4): choose "Apple Silicon"
- If you're not sure, click the Apple icon top-left of your screen > "About This Mac" and check the chip
- Open the downloaded
.dmgfile - Drag Docker to your Applications folder
- Open Docker Desktop from Applications
- It will ask for your password to install components. Enter it.
- Wait for it to finish starting (the whale icon in your menu bar will stop animating)
- In Docker Desktop settings (gear icon), go to "General" and make sure "Start Docker Desktop when you sign in" is checked
Both options use the same docker and docker compose commands. Everything in this guide works identically with either one.
If you use a custom media location (MEDIA_DIR in .env), replace any ~/Media path below with that value.
This stack supports two media servers. Choose one:
Plex (default): Runs natively on macOS. Requires the Plex app installed. Supports Kometa metadata automation and franchise sorting.
Jellyfin: Free and open-source. Runs entirely in Docker. No app install needed. Kometa and franchise-sort are skipped automatically (Jellyfin has built-in collection management).
To use Jellyfin, pass --jellyfin to the bootstrap command, or set MEDIA_SERVER=jellyfin in your .env file.
TDARR_MODE=native is the default and recommended on macOS (more reliable for hardware decode/encode behavior and avoids Docker VM overhead).
If you prefer containerized Tdarr, set TDARR_MODE=docker in .env or use --tdarr-docker with bootstrap.sh.
cd ~
git clone https://github.com/liamvibecodes/mac-media-stack-advanced.git
cd mac-media-stack-advanced
bash scripts/setup.sh
# or:
# bash scripts/setup.sh --media-dir /Volumes/T9/Mediaopen -a TextEdit .envFill in WIREGUARD_PRIVATE_KEY and WIREGUARD_ADDRESSES from your ProtonVPN account.
Get your WireGuard private key from https://account.protonvpn.com/downloads#wireguard-configuration
Run preflight checks before first startup:
bash scripts/doctor.shThen start the stack:
docker compose up -d
bash scripts/health-check.shIf using Jellyfin, start with the profile enabled:
docker compose --profile jellyfin up -dIf using Docker Tdarr (TDARR_MODE=docker), also enable the Tdarr profile:
docker compose --profile tdarr-docker up -dIf using native Tdarr (TDARR_MODE=native, default), run:
bash scripts/setup-tdarr-native.shWait for all containers to show OK. First pull takes 3-5 GB.
Optional: enable automatic container updates (Watchtower):
docker compose --profile autoupdate up -d watchtowerOnce the stack is running, confirm your real IP is never exposed through the VPN tunnel:
# 1. Check the VPN's IP (should be your VPN provider, not your ISP)
docker exec gluetun sh -c 'wget -qO- https://ipinfo.io/ip'
# 2. Check your real IP (run outside Docker)
curl -s https://ipinfo.io/ip
# 3. Confirm they're differentTo test the kill switch (traffic should be blocked when the VPN drops):
# Stop the VPN container
docker stop gluetun
# Try reaching the internet from qBittorrent — should fail/timeout
# (qBittorrent is routed through gluetun and should have no network when gluetun is down)
docker exec qbittorrent wget -qO- --timeout=5 https://ipinfo.io/ip 2>&1 || echo "Kill switch works: qBittorrent has no network"
# Restore the VPN
docker start gluetunIf the second wget returns an IP instead of timing out, your kill switch isn't working. Check your Gluetun configuration.
macOS note: On macOS, Docker runs inside a Linux VM (OrbStack or Docker Desktop). The kill switch blocks traffic at the container/VM level, not at the macOS network layer. This means containers routed through Gluetun are protected, but apps running directly on macOS are not affected. This is normal and expected.
bash scripts/configure.shThis configures qBittorrent, Prowlarr (indexers), Radarr, Sonarr, and Seerr. It will print your API keys at the end. Save them.
It also writes credentials/API keys to <MEDIA_DIR>/state/first-run-credentials.txt (mode 600, default path ~/Media/state/first-run-credentials.txt).
- Open http://localhost:32400/web
- Settings > Libraries > Add Library
- Add Movies (your home folder > Media > Movies)
- Add TV Shows (your home folder > Media > TV Shows)
- Open http://localhost:8096
- Complete the setup wizard
- Add libraries: Movies =
/data/movies, TV Shows =/data/tvshows - Generate an API key (Administration > API Keys) for Jellystat and
archive-media.sh --only-watched
Plugins: configure.sh auto-installs Intro Skipper (adds "Skip Intro" button on TV shows) and TMDb Box Sets (auto-creates franchise collections). A Jellyfin restart is required after plugin install. If auto-install fails, install them manually via Administration > Plugins > Catalog.
Jellystat (analytics): Open http://localhost:3000, create an account, and connect to http://jellyfin:8096 with your API key. Tracks watch history, user activity, and library stats.
Jellyfin users: Skip the Kometa section below. Kometa is Plex-only and is automatically skipped when
MEDIA_SERVER=jellyfin. Franchise sorting is also Plex-only; Jellyfin has built-in collection management via the Collections plugin.
scripts/configure.sh now auto-injects your Sonarr/Radarr API keys into <MEDIA_DIR>/config/recyclarr/recyclarr.yml (default: ~/Media/config/recyclarr/recyclarr.yml).
You only need to review it if you want to customize profile behavior.
Recyclarr runs automatically at 3am daily. To trigger a manual sync:
docker compose run --rm recyclarr syncOpen the Kometa config and add your Plex token and TMDB API key:
open -a TextEdit ~/Media/config/kometa/config.yml- Plex token: Follow https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/
- TMDB API key: Create a free account at https://www.themoviedb.org/settings/api
Replace YOUR_PLEX_TOKEN and YOUR_TMDB_API_KEY, then save.
- Open http://localhost:8265
- Add libraries:
- Native mode (
TDARR_MODE=native):- Movies source:
<MEDIA_DIR>/Movies - TV source:
<MEDIA_DIR>/TV Shows - Temp/cache:
<MEDIA_DIR>/tdarr-transcode-cache
- Movies source:
- Docker mode (
TDARR_MODE=docker):- Movies source:
/movies - TV source:
/tv - Temp/cache:
/temp
- Movies source:
- If
CLOUD_STORAGE_ENABLED=true, useTDARR_MODE=dockerso Tdarr can read merged cloud paths.
- Native mode (
- Assign the preloaded flow:
Quality-First HEVC (Resolution Preserving) - Keep the defaults in that flow for quality-first behavior:
- No resolution downscale
- No hard bitrate cap
- H.264 -> H.265 (CRF 19, preset slow)
- Replace only when output size ratio is 25-99%
scripts/configure.sh now auto-writes UN_SONARR_0_API_KEY and UN_RADARR_0_API_KEY in .env and restarts Unpackerr.
No manual edit is required unless you want to override defaults.
bash scripts/install-launchd-jobs.shThis installs:
- Auto-healer (hourly VPN/container health check + restart)
- Nightly backup (configs + databases, 14-day retention)
- Log prune (daily cleanup, removes logs older than 30 days)
- Download watchdog (stalled torrent auto-fix every 15 min)
- Kometa scheduler (metadata refresh every 4 hours)
- Native Tdarr launchd services (when
TDARR_MODE=native)
Automation logs go to <MEDIA_DIR>/logs/ and launchd stdout/stderr logs go to <MEDIA_DIR>/logs/launchd/ (default ~/Media/...).
If you have a NordVPN account as backup:
- Copy
.env.nord.exampleto.env.nord - Add your NordVPN WireGuard private key
- Install the failover watcher:
bash scripts/install-vpn-failover.shThis checks every 2 minutes and auto-switches between Proton and Nord after 3 consecutive failures.
Use docker-compose.nord-fallback.yml only for Nord mode; Proton remains the default compose path.
Check current provider anytime:
bash scripts/vpn-mode.sh status| What | Where |
|---|---|
| Browse and request | http://localhost:5055 |
| Watch (Plex) | http://localhost:32400/web |
| Watch (Jellyfin) | http://localhost:8096 |
| Jellyfin analytics (Jellystat) | http://localhost:3000 |
| Check downloads | http://localhost:8080 |
| Transcode status | http://localhost:8265 |
Everything else is fully automated.
If your local disk is too small for your full library, or you have a NAS with plenty of space, remote storage lets you extend your library transparently. rclone mounts your cloud provider or NAS (via SFTP) inside Docker, and mergerfs overlays it with your local storage so all services see one unified library.
- Cloud-backed playback requires
MEDIA_SERVER=jellyfin. - If using Tdarr with cloud storage, use
TDARR_MODE=docker. - Native macOS apps (Plex app, native Tdarr) cannot directly read Docker FUSE merged mounts.
bash scripts/setup-cloud-storage.shThe setup wizard will:
- Create the required directories
- Help you configure an rclone remote (interactive Docker wizard, copy existing conf, or skip)
- Write
CLOUD_STORAGE_ENABLED=trueandRCLONE_REMOTEto your.env - Create
MoviesandTV Showsfolders on your remote
docker compose -f docker-compose.yml -f docker-compose.cloud-storage.yml --profile cloud-storage --profile jellyfin --profile tdarr-docker up -dOr pass --jellyfin --cloud-storage to bootstrap.sh for a full install.
bash scripts/setup-cloud-storage.sh --storage-type nasThe wizard will:
- Ask for your NAS hostname/IP and SSH username
- Offer to generate an SSH key pair (recommended), use an existing key, or use password auth
- Ask for your media path with platform-specific examples
- Auto-detect Synology paths and add
path_override(same effect as--sftp-path-override) for SFTP chroot compatibility - Test connectivity before writing the config
- Write NAS-optimized VFS cache settings to
.env
SSH key setup: If you generate a new key, the wizard prints the public key. Add it to your NAS:
- TrueNAS: Accounts > Users > [user] > SSH Public Key
- Synology: DSM > Control Panel > User > [user] > Edit > SSH Key
- Unraid: Tools > User SSH Keys, or append to
~/.ssh/authorized_keys
Start with NAS storage:
docker compose -f docker-compose.yml -f docker-compose.cloud-storage.yml --profile cloud-storage --profile jellyfin --profile tdarr-docker up -dOr use bootstrap: bash bootstrap.sh --jellyfin --nas-storage
- Write path: Downloads land on local disk (fast).
cloud-upload.shperiodically moves stable files to remote storage (cloud default: every 6h; NAS default: every 2h). - Read path: Docker services (Jellyfin, Arr apps, Tdarr Docker) read from the mergerfs overlay, which transparently serves files from local or remote storage.
- VFS cache: rclone caches recently accessed remote files locally. Cloud defaults are larger (50GB/72h), NAS defaults are smaller/faster (10GB/1h).
- Jellyfin (Docker): keep default container paths
/data/moviesand/data/tvshows(mapped to merged paths by compose override). - Tdarr Docker mode: keep
/moviesand/tv(mapped to merged paths by compose override).
# Check rclone and mergerfs health
bash scripts/health-check.sh
# View cloud upload logs
tail -50 ~/Media/logs/cloud-upload.log
# Test rclone connectivity
docker exec rclone-mount rclone ls myremote: --max-depth 1
# Restart cloud storage (order matters: rclone first, then mergerfs)
docker restart rclone-mount && sleep 15 && docker restart mergerfs# Verify SSH key permissions
ls -la ~/Media/config/rclone/nas_key.pem
# Should show -rw------- (600)
# Test NAS connectivity directly
docker run --rm -v ~/Media/config/rclone:/config/rclone rclone/rclone lsd mynas: --contimeout 10s
# Check SFTP connection speed
docker run --rm -v ~/Media/config/rclone:/config/rclone rclone/rclone test bandwidth mynas: --duration 10s
# Synology: if paths don't resolve, check sftp_path_override in rclone.confCheck overall health:
bash scripts/health-check.shView automation logs:
tail -50 ~/Media/logs/auto-heal.log
tail -50 ~/Media/logs/download-watchdog.log
tail -50 ~/Media/logs/vpn-failover.log
tail -50 ~/Media/logs/log-prune.logManual VPN switch:
bash scripts/vpn-mode.sh status # check current provider
bash scripts/vpn-mode.sh proton # switch to Proton
bash scripts/vpn-mode.sh nord # switch to NordRestart everything:
docker compose down && docker compose up -d
# If using Jellyfin:
docker compose down && docker compose --profile jellyfin up -dUninstall automation jobs:
for f in ~/Library/LaunchAgents/com.media-stack.*.plist; do launchctl unload "$f" && rm "$f"; done