Multi-stack Docker Compose setup for a self-hosted media server.
/opt/stacks/
vpn/ → gluetun (all download traffic routes through here)
downloads/ → qbittorrent, nzbget, deunhealth
media/ → sonarr, radarr, lidarr, bazarr, prowlarr, flaresolverr, ytdl-sub
requests/ → seerr
infra/ → ntfy, uptime-kuma, watchtower, portainer, scraparr, cadvisor
.env → shared environment variables (never commit this)
git clone <repo> ~/docker/homelab
cd ~/docker/homelab
cp .env.example .env
nano .env # fill in your valuesSymlink .env into each stack directory so Docker Compose can resolve ${VAR} substitutions when run from within a stack directory:
for stack in vpn downloads media requests infra; do
ln -s ~/docker/homelab/.env ~/docker/homelab/$stack/.env
doneRun once on the host. All stacks reference this network as external.
docker network create \
--driver bridge \
--subnet 172.39.0.0/24 \
servarrnetworkVerify:
docker network inspect servarrnetworkRun once on the host. The /data layout prevents file copies — everything stays on the same filesystem.
mkdir -p /data/downloads/{torrents,usenet}
mkdir -p /data/{movies,tv,music,youtube}Set ownership to match PUID/PGID in your .env:
chown -R 1000:1000 /dataThe VPN must be healthy before downloads can start.
# 1. Start VPN first
cd /opt/stacks/vpn && docker compose up -d
# 2. Wait for gluetun to be healthy, then start downloads
docker inspect --format='{{.State.Health.Status}}' gluetun
# (repeat until output is "healthy")
cd /opt/stacks/downloads && docker compose up -d
# 3. Start remaining stacks (order doesn't matter)
cd /opt/stacks/media && docker compose up -d
cd /opt/stacks/requests && docker compose up -d
cd /opt/stacks/infra && docker compose up -dcd /opt/stacks/media
docker compose pull
docker compose up -ddocker restart sonarrdocker logs -f sonarr
docker logs -f gluetunfor stack in downloads media requests infra vpn; do
cd /opt/stacks/$stack && docker compose down
done| Container | IP | Port | Stack |
|---|---|---|---|
| gluetun | 172.39.0.2 | — | vpn |
| sonarr | DHCP | 8989 | media |
| radarr | DHCP | 7878 | media |
| lidarr | DHCP | 8686 | media |
| bazarr | DHCP | 6767 | media |
| ytdl-sub | DHCP | — | media |
| seerr | DHCP | 5055 | requests |
| flaresolverr | DHCP | — | media |
| prowlarr | DHCP | 9696 | media |
| cadvisor | DHCP | 8090 | infra |
| scraparr | DHCP | 7100 | infra |
| ntfy | DHCP | 8085 | infra |
| uptime-kuma | DHCP | 3001 | infra |
| portainer | DHCP | 9000 | infra |
| watchtower | DHCP | — | infra |
qbittorrent and nzbget share gluetun's network stack — reach them at 172.39.0.2:8080 and 172.39.0.2:6789.
Use container names for inter-container communication (e.g. http://prowlarr:9696, http://sonarr:8989). Docker DNS resolves these automatically within servarrnetwork.
Access at http://<host-ip>:9000. On first launch you must set an admin password within 5 minutes or Portainer locks itself — it times out for security. If you miss it:
docker restart portainerUpdates all containers automatically at 4am daily and removes old images. To exclude a container from auto-updates, add this label:
labels:
- com.centurylinklabs.watchtower.enable=falseTo trigger an immediate update manually:
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower --run-onceAccess at http://<host-ip>:3001. After first login, add monitors for:
http://sonarr:8989— Sonarrhttp://radarr:7878— Radarrhttp://prowlarr:9696— Prowlarrhttp://seerr:5055— Seerrhttp://qbittorrent:8080— qBittorrent (via gluetun IP172.39.0.2:8080)http://ntfy:80/v1/health— ntfy- Proxmox and router via ICMP ping
Set notification channel to ntfy for push alerts.
Prometheus exporter for the *arr suite. Metrics endpoint: http://<host-ip>:7100/metrics
Config lives at ./infra/scraparr/config.yaml. You must fill in API keys after the *arr apps are running:
- Sonarr → Settings → General → API Key
- Radarr → Settings → General → API Key
- Lidarr → Settings → General → API Key
- Bazarr → Settings → General → Security → API Key
- Prowlarr → Settings → General → API Key
Then restart scraparr to pick up the config:
docker restart scraparrScraparr works on the servarrnetwork so it can reach all *arr containers by name directly.
Config lives at ./infra/ntfy/config/server.yml. Minimal example:
base-url: http://<host-ip>:8085
cache-file: /var/cache/ntfy/cache.db
auth-file: /var/cache/ntfy/auth.db
auth-default-access: deny-allVerify qbittorrent is actually going through the VPN:
docker exec -it qbittorrent curl -s https://ipinfo.ioThe IP should match your VPN exit node, not your home IP.
docker logs gluetunCommon causes: wrong WireGuard keys, port not forwarded, FIREWALL_VPN_INPUT_PORTS mismatch.
deunhealth watches qbittorrent's healthcheck and restarts it automatically. Check:
docker logs deunhealthdocker stop qbittorrent
rm -rf /opt/stacks/downloads/qbittorrent
docker start qbittorrentnetwork_mode: container:gluetun (used in downloads) differs from network_mode: service:gluetun:
service:gluetun— only works within the same compose projectcontainer:gluetun— works across compose projects, references by container name
The VPN stack must be running before the downloads stack starts.