A self-hosted torrent automation stack for Docker Compose: a BitTorrent
client routed through a VPN kill switch, with automatic search, download,
import and subtitle fetching. One docker compose up -d, no secrets in the
repository, identical on macOS and Linux β laptop, homelab server, NAS or VPS.
| Service | Role |
|---|---|
| gluetun | VPN client (OpenVPN or WireGuard) acting as the network gateway and kill switch |
| Transmission | BitTorrent client, running entirely inside the VPN tunnel |
| Prowlarr | Indexer/tracker manager, syncs indexers to the apps below |
| Sonarr | TV series: monitors, searches and imports episodes |
| Radarr | Movies: monitors, searches and imports releases |
| Bazarr | Subtitles for whatever Sonarr and Radarr import |
π«π· Version franΓ§aise
- Transmission has no route to the Internet except the VPN. It shares
gluetun's network namespace (
network_mode: service:gluetun), so if the tunnel drops, Transmission goes offline. The kill switch is structural: it holds even when nothing is configured to enforce it. - Everything is bound to
127.0.0.1by default. No web UI is exposed to your LAN or the Internet until you deliberately changeBIND_ADDRESS. - One shared mount, so hardlinks work. Downloads and the media library live
under the same host directory mounted at
/data, which lets Sonarr and Radarr import by hardlink: no copy, no doubled disk usage, seeding continues. - No credentials in the repo.
config/and.envare git-ignored; the repository ships only.env.example.
- Docker Engine (or Docker Desktop) with Compose v2.24+
- A VPN subscription supported by gluetun β the defaults here target CyberGhost/OpenVPN, but any provider works
- Enough disk space under the directory you point
DOWNLOADS_DIRat
git clone https://github.com/maax6/media-stack.git
cd media-stack
cp .env.example .envEdit .env:
| Variable | Notes |
|---|---|
PUID / PGID |
id -u / id -g. Linux: usually 1000/1000. macOS: usually 501/20. |
TZ |
e.g. Europe/Paris |
DOWNLOADS_DIR |
The single host directory every service shares |
VPN_SERVICE_PROVIDER, OPENVPN_USER, OPENVPN_PASSWORD |
From your VPN dashboard |
TRANSMISSION_USER / TRANSMISSION_PASSWORD |
Required β do not leave the defaults |
Create the media directories and, for providers that require a client certificate (CyberGhost among them), drop the certificate pair in place β gluetun will not start without it:
mkdir -p "$DOWNLOADS_DIR"/{torrents,media/movies,media/tv}
mkdir -p config/gluetun
cp /path/to/client.crt /path/to/client.key config/gluetun/Start it:
docker compose up -d
docker compose psConfirm the tunnel is actually up and that Transmission is behind it β this must return the VPN's IP, not yours:
docker compose logs gluetun | grep -i "public ip"All bound to 127.0.0.1 by default; ports are configurable in .env.
| Application | URL |
|---|---|
| Transmission | http://127.0.0.1:19091 |
| Sonarr | http://127.0.0.1:18989 |
| Radarr | http://127.0.0.1:17878 |
| Prowlarr | http://127.0.0.1:19696 |
| Bazarr | http://127.0.0.1:16767 |
- Transmission β set the download directory to
/data/torrents. - Sonarr β root folder
/data/media/tv. Add the Transmission download client with hosttransmission, port9091. Leave Remote Path Mapping empty: every container sees the identical/datapath. Enable hardlinks in Media Management. - Radarr β same, with root folder
/data/media/movies. - Prowlarr β add your indexers, then under Settings β Apps add Sonarr
(
http://sonarr:8989) and Radarr (http://radarr:7878) so indexers sync automatically. - Bazarr β connect it to Sonarr (
http://sonarr:8989) and Radarr (http://radarr:7878), then add subtitle providers.
What is protected: all peer-to-peer traffic. Transmission cannot reach the Internet outside the tunnel, by design.
What is not:
- Sonarr, Radarr, Prowlarr and Bazarr use your real IP. They talk to metadata services and indexer APIs directly. Torrent traffic β the part that matters for peer visibility β stays inside the VPN. Routing Prowlarr through gluetun too is possible but deliberately not done here: shared VPN exit IPs get captcha'd and blocked by many indexers, Docker service names stop resolving inside gluetun's network namespace, and the gain is narrow since no peer traffic ever leaves that container.
- These apps have no meaningful authentication hardening. Keep
BIND_ADDRESS=127.0.0.1. If you need remote access, use a VPN back into your network (Tailscale, WireGuard) or a reverse proxy that terminates TLS and enforces authentication. Do not port-forward these to the Internet. config/holds live secrets once you have run the stack: API keys, the Transmission password hash, and your VPN private key. It is git-ignored β if you back it up, treat that backup as sensitive.
No incoming peer port. Transmission's peer port is deliberately not published, so the client is outbound-only. Ratios on private trackers will suffer. If your VPN provider supports port forwarding, gluetun can be configured for it β see the gluetun wiki.
docker compose logs -f # follow all logs
docker compose logs -f gluetun # tunnel state, public IP, reconnects
docker compose pull && docker compose up -d # update images
docker compose down # stop; config and media are untouchedCopy .env and config/ over by a secure channel (both contain secrets), then
git clone and docker compose up -d. Adjust PUID/PGID and
DOWNLOADS_DIR for the new host. Alternatively, start fresh: the stack builds
its own config/ on first run, and you reconfigure the apps by hand.
Yes, because it is not a feature that has to run. Transmission has no network
interface of its own: it shares gluetun's network namespace. When the tunnel
goes down, the only route out disappears with it. Verify with
docker compose logs gluetun | grep -i "public ip".
Every provider gluetun supports β around 60, including NordVPN, Mullvad,
ProtonVPN, Surfshark, Private Internet Access, CyberGhost and any custom
OpenVPN or WireGuard configuration. Set VPN_SERVICE_PROVIDER in .env; see
the gluetun provider list.
Yes. Set VPN_TYPE=wireguard and fill WIREGUARD_PRIVATE_KEY and
WIREGUARD_ADDRESSES in .env. It is faster than OpenVPN on most hardware.
Yes. Replace the transmission service image with
lscr.io/linuxserver/qbittorrent or lscr.io/linuxserver/deluge, keep
network_mode: "service:gluetun", and update the published port and
FIREWALL_INPUT_PORTS to match the new web UI port.
Hardlinks only work inside a single filesystem. Downloads and the media library
must both live under DOWNLOADS_DIR, and every container must see them at the
same /data path β which is why this stack uses one shared mount and no Remote
Path Mapping. Also enable hardlinks in Media Management in Sonarr and Radarr.
Yes. All images are multi-arch (amd64 and arm64). On Linux set PUID/PGID to
id -u / id -g, usually 1000/1000. /dev/net/tun must be available, which
rules out some restricted container hosts.
Keep BIND_ADDRESS=127.0.0.1 and connect back into your network with
Tailscale or WireGuard. Exposing these apps directly is unsafe: they were never
designed to face the Internet.
Only to accept incoming peers. This stack keeps the peer port unpublished, so Transmission is outbound-only and upload ratios stay low. If your provider supports port forwarding, gluetun can request one β see its wiki.
BitTorrent is a distribution protocol, and plenty of what it distributes is free to share: Linux and BSD images (Debian, Ubuntu, Arch, Fedora), the Internet Archive collections, public-domain films, Creative Commons music and video, open datasets, game and demoscene archives, and Blender Foundation open movies. This stack automates exactly that kind of library.
This stack is plumbing. It ships with no indexers, no trackers, and no content. Downloading copyrighted material without authorisation is illegal in most jurisdictions β what you use it for is on you.