I got a NAS and then decided to use it as a media server. In the process, I ended up orchestrating a rather cool stack of docker containers. So here it is. I'm told it's "Very NAS"
---- The readme beyond this point is written by AI ----
Clone this repo onto a fresh NAS, follow the steps in order, and end up with the same working setup.
- Routes download traffic through ProtonVPN using
gluetun+ WireGuard - Uses both torrents (qBittorrent) and Usenet (SABnzbd) for downloads
- Automates TV + Movies with Sonarr/Radarr, indexers managed by Prowlarr
- Recyclarr auto-syncs quality profiles from TRaSH guides
- Request UI with Jellyseerr driving Sonarr/Radarr
- Streams via Jellyfin and Plex
- Self-hosted password manager (Vaultwarden) via Cloudflare tunnel
- Self-hosted photos (Immich) with ML-powered face recognition and smart search
- Portainer for container management UI
- Watchtower for automatic container updates (gluetun excluded — see below)
- Gitea for self-hosted Git (LAN only)
- Observability stack: Dozzle (logs), Prometheus + Grafana + cAdvisor + node-exporter (metrics)
| Network | Purpose | Containers |
|---|---|---|
downloads |
VPN-tunneled traffic | gluetun, qbittorrent, sabnzbd, prowlarr, radarr, sonarr, recyclarr |
media |
Media streaming & management | jellyfin, jellyseerr, plex, recyclarr |
proxy |
Cloudflare tunnel access | cloudflared, jellyfin, jellyseerr, plex, vaultwarden, immich-server |
immich |
Photo stack (DB/Redis isolated) | immich-server, immich-machine-learning, immich-postgres, immich-redis |
management |
Container management | portainer, watchtower, gitea |
monitoring |
Observability stack | prometheus, grafana, cadvisor, node-exporter, dozzle |
Homepage (dashboard) spans multiple networks to reach all services for its widgets.
- ✅ All containers have
security_opt: no-new-privileges - ✅ All containers have health checks
- ✅ VPN kill switch (gluetun firewall)
- ✅ Docker socket mounted read-only where needed
- ✅ Services depend on healthy upstream containers
Services using network_mode: "service:gluetun" share gluetun's network namespace. This means:
- They cannot publish their own ports — ports must be exposed via gluetun's
ports:section - If gluetun is recreated, all dependent containers get orphaned with a stale network namespace
- Watchtower cannot safely update gluetun — use
make update-gluetuninstead (see Makefile section)
In this repo (committed):
docker-compose.yaml— the full stackexample.env— template with all variables (no secrets)prometheus.yml— Prometheus scrape configrecyclarr.yml— Recyclarr quality profiles (API key placeholders on public repo)grafana/— provisioning configs and dashboard JSONsMakefile— operational targets (see below)README.md
On the NAS (NOT committed):
.env— real secrets (copy fromexample.env)${CONFIG_ROOT}/*— service databases, tokens, settings- Media directories (downloads, movies, tv, photos)
The Makefile reads CONFIG_ROOT from .env — no hardcoded paths.
| Target | What it does |
|---|---|
make sync-configs |
Copies prometheus.yml + recyclarr.yml to CONFIG_ROOT, fixes permissions, restarts both services |
make sync-prometheus |
Syncs just prometheus.yml and restarts Prometheus |
make sync-recyclarr |
Syncs just recyclarr.yml and restarts Recyclarr |
make update-gluetun |
Pulls latest gluetun image, stops all 5 dependents, recreates gluetun, waits for healthy, restarts dependents |
make help |
Shows available targets |
After editing any config file in the repo: git pull && make sync-configs
Gluetun updates: make update-gluetun (never use Watchtower for this)
This guide assumes:
- Compose project folder:
/volume1/docker/mediaserver - Config root:
/volume1/media/config - Media root:
/volume1/media
mkdir -p /volume1/docker/mediaserver
mkdir -p /volume1/media/{downloads,downloads/incomplete,movies,tv,photos}
mkdir -p /volume1/media/config/{gluetun,qbittorrent,sabnzbd,prowlarr,sonarr,radarr,jellyfin,jellyseerr,plex,vaultwarden,portainer,recyclarr,prometheus,gitea}
mkdir -p /volume1/media/config/immich/{postgres,redis,model-cache}# Find your UID/GID
id
# Apply ownership (adjust UID:GID if yours differ from 1000:100)
chown -R 1000:100 /volume1/mediacp example.env .env
nano .env
# Fill in all values — VPN creds, API keys, passwordsGenerate secure passwords:
openssl rand -base64 32 # Immich DB/Redis passwords
docker run --rm -it vaultwarden/server /vaultwarden hash # Vaultwarden admin tokencd /volume1/docker/mediaserver
docker compose up -d
docker compose psmake sync-configsThis copies prometheus.yml and recyclarr.yml to the right places under CONFIG_ROOT and restarts the services.
docker logs qbittorrent 2>&1 | grep "temporary password"Log in at http://NAS_IP:8888 → Tools → Options → Web UI → set username/password → Save.
SABnzbd listens on internal port 8080. Since it uses network_mode: service:gluetun, its UI port is published via gluetun's ports section.
Prowlarr syncs indexers only to Sonarr/Radarr. You must add download clients (qBit + SABnzbd) in Sonarr/Radarr manually.
In Sonarr/Radarr → Settings → Download Clients:
- SABnzbd: Priority 1 (preferred — faster, no seeding)
- qBittorrent: Priority 50 (fallback)
Point at Docker host gateway for Sonarr/Radarr URLs:
- Radarr:
http://172.17.0.1:7878 - Sonarr:
http://172.17.0.1:8989
Disable "Tag Requests" in Jellyseerr's Radarr settings if requests aren't arriving.
Accessible via Cloudflare tunnel at your configured domain. Use http://vaultwarden:7777 as the tunnel target (HTTP, not HTTPS — TLS terminates at Cloudflare edge).
- Set
VAULTWARDEN_SIGNUPS_ALLOWED=true→ create your account → set tofalse→ redeploy - Enable 2FA in vault settings
- Optionally disable admin panel: set
VAULTWARDEN_ADMIN_TOKEN=(empty)
Accessible via Cloudflare tunnel and on LAN at http://NAS_IP:2283.
External libraries: Configure up to 5 via IMMICH_EXTERNAL_1 through IMMICH_EXTERNAL_5 in .env. Unset variables default to /dev/null. After adding, scan via Administration → External Libraries in Immich.
ML container: Has explicit DNS (8.8.8.8, 1.1.1.1) configured to download models from huggingface.co on first run.
Mobile app: Use your tunnel URL as server, or http://NAS_IP:2283 for faster LAN uploads.
Edit recyclarr.yml with your Sonarr/Radarr API keys and run:
make sync-recyclarr
docker exec recyclarr recyclarr syncUses v8+ schema — assign_scores_to (not the old quality_profiles). Reaches Sonarr/Radarr via http://gluetun:8989 / http://gluetun:7878.
http://NAS_IP:9999 — real-time log viewer for all containers.
http://NAS_IP:9090 — scrapes cAdvisor (container metrics), node-exporter (host metrics), itself, and Oracle trading system (if connected).
- Config:
prometheus.ymlin repo → synced toCONFIG_ROOT/prometheus/viamake sync-prometheus - Retention: 30 days or 5 GB (whichever is hit first)
⚠️ Prometheus runs as usernobody— config file must be world-readable (chmod 644)
http://NAS_IP:3333 — auto-provisions dashboards from grafana/dashboards/ and datasource from grafana/provisioning/.
Included dashboards:
- mediaserver.json — container resources, host metrics, disk usage
- oracle.json — IB Gateway health, P&L, trades, API performance, guardrails
chmod 755 grafana grafana/provisioning grafana/provisioning/dashboards grafana/provisioning/datasources grafana/dashboards
chmod 644 grafana/dashboards/* grafana/provisioning/dashboards/* grafana/provisioning/datasources/*
docker compose restart grafanaTo scrape metrics from containers in other compose projects (e.g., Oracle trading system), connect their network-parent container to the monitoring network:
docker network connect mediaserver_monitoring <container_name>Then add a scrape job to prometheus.yml targeting that container.
Auto-updates containers at 4 AM daily. Gluetun is excluded (watchtower.enable=false) because recreating gluetun orphans all network_mode: service:gluetun containers.
Update gluetun manually:
make update-gluetun# VPN is working
docker exec qbittorrent curl ifconfig.me
# Should show a ProtonVPN IP, not your ISP
# All containers healthy
docker compose ps
# Logs
docker compose logs --tail=200LAN-only Git server at http://NAS_IP:41234. Set GITEA_DISABLE_REGISTRATION=true after creating your admin account.
git remote add nas http://NAS_IP:41234/username/repo.git
git push nas main