Skip to main content
  1. Posts/

Tunnelling Torrents 'Properly' Over a VPN With Port Forwarding

·4814 words·23 mins
muffn_
Author
muffn_
🐶
Table of Contents

📔 Intro
#

Anyone who’s tried torrenting over VPN knows it can be a bit of a pain. Sure, basic setups work - connect to VPN, start downloading, done. But if you want proper speeds and proper seeding ratios, there’s a bit more to it than that.

I’ve spent way too much time optimizing my torrent setup over the years, and I’ve finally got something that I’m pretty happy with. This post will go through how to set up a proper torrenting environment that’s both secure and fast, using Docker containers to keep everything neat and tidy.

🛡️ Why VPN for Torrents?
#

Besides the obvious privacy benefits, there are some real practical benefits to tunneling P2P:

  1. ISP Throttling/Blocking: Some ISPs these days will throttle/block torrent traffic like it’s their job (I guess it kind of is). A VPN prevents this by making your traffic unidentifiable to the ISP.
  2. Better Peering: Sometimes, VPN routes can actually give you better connections to peers than your ISP’s routes. I’ve seen this a lot with certain providers.
  3. Connection Privacy: Your IP isn’t exposed to the swarm, which is just good practice.
While the principles of port forwarding apply to all VPN providers that support it, this specific setup currently only works natively with ProtonVPN and Private Internet Access (PIA) due to being natively supported in Gluetun.

🔹 VPN Provider Notes:
#

  • ProtonVPN requires a Plus subscription for port forwarding and must use WireGuard
  • PIA needs specific server regions that support port forwarding
  • Both require VPN_PORT_FORWARDING=on in Gluetun to enable the magic

🔹 Private Trackers
#

If you use private trackers, port forwarding is an absolute must for maintaining a healthy ratio and ensuring you stay “connectable.” Private trackers typically require you to allow inbound connections so other peers can upload from you. If your client can’t accept connections—because your port isn’t forwarded through the VPN—you’ll struggle to seed and risk a poor ratio (or even account warnings/bans).

You might think, “I’m only on private trackers, so I don’t need a VPN.” That’s not the case. Private trackers aren’t synonymous with total anonymity; your IP will still appear in logs or be visible to tracker admins and peers—plus anyone else in the swarm. Unless you’ve personally vetted every single seeder and leecher, it’s best to always tunnel your P2P traffic, as outlined in this post.

🚀 Port Forwarding
#

From what I’ve seen most people understand the need for the above, tunnel their bittorrent traffic via a VPN and call it a day, but that is far from optimal.

When you’re torrenting without a proper port forwarded, you’re basically in a “can’t connect to me, I’ll connect to you” situation.

%%{init: {'theme':'dark', 'themeVariables': { 'fontSize': '20px', 'lineColor': '#88ccff', 'mainBkg': '#1a2b42', 'textColor': '#88ccff' }}}%% sequenceDiagram participant You as Your Client participant Swarm as Torrent Swarm Note over You,Swarm: Without Port Forwarding You->>Swarm: Can initiate connections Swarm-->>You: Direct connections blocked Note over You,Swarm: Limited to outbound only

%%{init: {'theme':'dark', 'themeVariables': { 'fontSize': '20px', 'lineColor': '#88ccff', 'mainBkg': '#1a2b42', 'textColor': '#88ccff' }}}%% sequenceDiagram Note over You,Swarm: With Port Forwarding You->>Swarm: Can initiate connections Swarm->>You: Can receive connections Note over You,Swarm: Full bi-directional flow

Here’s what happens in a typical torrent connection:

🔹 Without Port Forwarding
#

Your client sits behind NAT (network address translation). Incoming connections can’t be established, so you rely solely on outbound connections. Your “connectable” status often shows as red or false in clients, limiting your ability to seed effectively and sometimes even to download quickly.

🔹 With Port Forwarding
#

A single (or multiple) ports are mapped through the VPN so that external peers can initiate connections to you. Now you’re “connectable,” and you can participate fully in the swarm. Speeds can skyrocket, especially on well-seeded torrents.

%%{init: {'theme':'dark', 'themeVariables': { 'fontSize': '20px', 'lineColor': '#88ccff', 'mainBkg': 'transparent', 'textColor': '#88ccff' }}}%% graph TB subgraph Swarm Network direction LR C[Client via VPN] subgraph Active Peers P1[Peer A] P2[Peer B] P3[Peer C] end C -->|"TCP ➜ Port 6881"| P1 C -->|"TCP ➜ Port 6889"| P2 C -->|"TCP ➜ Port 6885"| P3 P1 & P2 & P3 -->|"TCP ⚡ Port 51820"| C P1 -->|"Port 6882"| P2 P2 -->|"Port 6890"| P3 P3 -->|"Port 6888"| P1 end classDef default stroke:none classDef client fill:none,stroke:#ff69b4,stroke-width:2px,color:#88ccff classDef peer fill:none,stroke:#00ffff,stroke-width:2px,color:#88ccff class C client class P1,P2,P3 peer

In my experience, the difference can be night and day. A properly configured client with port forwarding can mean the difference between a 20MB/s download and a 200MB/s download on well-seeded torrents.

🛠️ The Implementation
#

This is where things get interesting. Port forwarding with VPNs requires:

  • A VPN provider that supports port forwarding.
  • A way to detect which port you’ve been assigned and set that in qBittorrent automatically.
    • This isn’t required when providers statically assign you port(s).

So, Gluetun supports automatic port mapping. We will be using native integrations for this, which, when VPN_PORT_FORWARDING=on is set, Gluetun obtains the forwarded port from the VPN. This port is not always static; it can change periodically or whenever the tunnel reconnects. Hence, the need for qSticky-my container that queries Gluetun’s Control Server API to grab the port and pushes it to qBittorrent automatically.

This is exactly why I built qSticky - to automate this entire flow to make it “set and forget”. Let’s look at how the pieces fit together:

%%{init: {'theme':'dark', 'themeVariables': { 'fontSize': '20px', 'lineColor': '#88ccff', 'mainBkg': '#1a2b42', 'textColor': '#88ccff' }}}%% flowchart TD VPN[VPN Service] -->|Assigns Port| GW[Gluetun] GW -->|API Authentication| Auth[Gluetun Control Server] Auth -->|Forwarded Port Info| Client[qSticky] Client -->|Updates| Torrent[qBittorrent] style VPN fill:#0f1b2d,stroke:#00ff00,color:#88ccff style GW fill:#0f1b2d,stroke:#00ffff,color:#88ccff style Auth fill:#0f1b2d,stroke:#ff69b4,color:#88ccff style Client fill:#0f1b2d,stroke:#ff69b4,color:#88ccff style Torrent fill:#0f1b2d,stroke:#ff00ff,color:#88ccff

🧱 The Stack
#

Here’s a high-level overview of what we’ll spin up in Docker:

  1. Gluetun - Popular VPN container that handles the connection and port forwarding, now with a secure control server API
  2. qBittorrent - Our torrent client (fight me, Transmission users)
  3. qSticky - My tool to keep ports in sync using Gluetun’s API
qdm12/gluetun

VPN client in a thin Docker container for multiple VPN providers, written in Go, and using OpenVPN or Wireguard, DNS over TLS, with a few proxy servers built-in.

Go
9253
413
qbittorrent/qBittorrent

qBittorrent BitTorrent client

C++
30310
4137
monstermuffin/qSticky

🔄 Automated port forwarding manager that keeps qBittorrent in sync with Gluetun VPN’s forwarded ports. Monitors port changes in real-time and updates qBittorrent automatically.

Python
9
0

🔑 Setting Up Authentication
#

Before we dive into the containers, we need to set up secure authentication for Gluetun’s control server. This is a new requirement that makes everything more secure. Create one of the following config.toml files:

API key–based auth is recommended for simplicity. If you go with basic, you need to supply GLUETUN_AUTH_TYPE=basic + username/password in qSticky.
1
2
3
4
5
[[roles]]
name = "qSticky"
routes = ["GET /v1/openvpn/portforwarded"]
auth = "apikey"
apikey = "your_secure_api_key_here"

Or if you prefer basic auth:

1
2
3
4
5
6
[[roles]]
name = "qSticky"
routes = ["GET /v1/openvpn/portforwarded"]
auth = "basic"
username = "myusername"
password = "mypassword"
  • routes define which API endpoints a particular role can access
  • auth determines if you use apikey or basic credentials
  • For auth = "apikey", any request must include the header X-API-Key: your_secure_api_key_here

This config file defines what qSticky can access and how it authenticates. If you need help creating a key you copy-pasta one of the following in your terminal:

🔹 Generating a Secure API Key
#

  • OpenSSL
1
2
openssl rand -base64 16 | tr -dc '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' | head -c 22
echo
  • Python:
1
python3 -c "import secrets, string; alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; print(''.join(secrets.choice(alphabet) for _ in range(22)))"

🔧 Setting Up Gluetun
#

Below is a minimal Gluetun Docker Compose snippet using ProtonVPN + WireGuard as an example. If you’re using PIA, the variables differ slightly (see Gluetun’s Wiki for details).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
services:
  gluetun:
    image: qmcgaw/gluetun:latest
    container_name: gluetun
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    ports:
      - 8080:8080  # Will expose qBittorrent's WebUI externally
    volumes:
      - ./gluetun/config.toml:/gluetun/auth/config.toml
    environment:
      # core vpn settings
      VPN_SERVICE_PROVIDER: protonvpn
      VPN_TYPE: wireguard
      VPN_PORT_FORWARDING: on
      GLUETUN_HTTP_CONTROL_SERVER_ENABLE: on

      # ProtonVPN specifics: from your WG config
      WIREGUARD_PRIVATE_KEY: 'YOUR_PRIVATE_KEY'
      WIREGUARD_ADDRESSES: '10.2.0.2/32'
      VPN_CITIES: London

      # Alternatively, for PIA:
      # VPN_SERVICE_PROVIDER: 'private internet access'
      # PRIVATE_INTERNET_ACCESS_USER: 'your_user'
      # PRIVATE_INTERNET_ACCESS_PASSWORD: 'your_pass'
      # SERVER_REGIONS: "Switzerland"
    restart: unless-stopped

🔹 Generating WireGuard Keys (ProtonVPN)
#

For ProtonVPN’s WireGuard configuration, you’ll need to generate proper keys:

ProtonVPN Plus subscription is required for port forwarding. Check the ProtonVPN docs to confirm you’re set up on their end.
  1. Log in to ProtonVPN and download your WireGuard config.
  2. Extract PrivateKey and Address from [Interface].
  3. Put those into WIREGUARD_PRIVATE_KEY and WIREGUARD_ADDRESSES.
When creating the Wireguard config, you must enable port forwarding here to enable it for use!

The Wireguard config can be configured as above or by mounting a Wireguard configuration into the container. The above approach is better IMO as it allows you to easily change settings, like which country/city to connect to.

The key bits here are:

  • Enabling the control server with GLUETUN_HTTP_CONTROL_SERVER_ENABLE
  • Mounting our auth config file
  • Ensuring port forwarding is enabled

🌊 Adding qBittorrent
#

Next, we add qBittorrent. The big difference is we point network_mode to the service:gluetun. That ensures qBittorrent only sends traffic out via Gluetun (a “VPN kill switch”).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    container_name: qbittorrent
    network_mode: "service:gluetun"
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Etc/UTC
      - WEBUI_PORT=8080
    volumes:
      - ./config:/config
      - ./downloads:/downloads
    restart: unless-stopped
    depends_on:
      - gluetun
Ports are exposed on Gluetun, so you do not set ports: on qbittorrent itself. The 8080:8080 mapping in Gluetun is what actually exposes the qBittorrent WebUI outside of Docker.

🔄 qSticky
#

Finally, add qSticky. This is the small Python container that polls Gluetun’s Control Server for the forwarded port and updates qBittorrent as needed.

  • If you chose API key auth, set GLUETUN_AUTH_TYPE=apikey and GLUETUN_APIKEY=....
  • If you use basic auth, use GLUETUN_AUTH_TYPE=basic plus GLUETUN_USERNAME & GLUETUN_PASSWORD.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
  qsticky:
    image: ghcr.io/monstermuffin/qsticky:latest
    container_name: qsticky
    environment:
      # qBittorrent settings
      QBITTORRENT_HOST: gluetun
      QBITTORRENT_PORT: 8080
      QBITTORRENT_USER: admin
      QBITTORRENT_PASS: adminadmin

      # Gluetun control server
      GLUETUN_HOST: gluetun
      GLUETUN_PORT: 8000
      GLUETUN_AUTH_TYPE: apikey
      GLUETUN_APIKEY: your_secure_api_key_here

      # For basic auth, you'd do:
      # GLUETUN_AUTH_TYPE: basic
      # GLUETUN_USERNAME: myusername
      # GLUETUN_PASSWORD: mypassword

      # qSticky poll interval/logging
      CHECK_INTERVAL: 30
      LOG_LEVEL: INFO

    restart: unless-stopped

🔹 How qSticky Works
#

  • Periodically calls the Gluetun API endpoint: /v1/openvpn/portforwarded.
  • Authenticates using either API key or basic auth (based on your config).
  • Compares the returned port with the current port in qBittorrent via qBit’s Preferences API.
  • Updates qBittorrent if they don’t match.

This ensures your torrent client always has the correct port, even if your VPN reassigns a new one.

%%{init: {'theme':'dark', 'themeVariables': { 'fontSize': '20px', 'lineColor': '#88ccff', 'mainBkg': '#1a2b42', 'textColor': '#88ccff' }}}%% flowchart LR subgraph Internet VPN[ProtonVPN] Swarm[(Torrent Swarm)] end subgraph Docker Gluetun qSticky qBit[qBittorrent] end VPN -->|Port Assignment| Gluetun Swarm <-->|Torrent Traffic| Gluetun Gluetun -->|Port Info| qSticky qSticky -->|Updates Port| qBit Gluetun -.->|Network Mode| qBit style VPN fill:#0f1b2d,stroke:#00ff00,color:#88ccff style Swarm fill:#0f1b2d,stroke:#ff69b4,color:#88ccff style Gluetun fill:#0f1b2d,stroke:#00ffff,color:#88ccff style qBit fill:#0f1b2d,stroke:#ff00ff,color:#88ccff style qSticky fill:#0f1b2d,stroke:#00ff00,color:#88ccff

🔬 qSticky: A (Slightly) Technical Deep Dive
#

Feel free to skip to 🎮 The Full Stack if you’re not interested!

Now, I must preface this by stating I am not a developer, I am a system engineer/architect. I can be a code monkey when it’s required, but I don’t necessarily love it. qSticky, like all my other projects that involves code, is just me trying my best with the help of the internet and trying to not rely too heavily on AIs.

🔹 Secure API Communication
#

qSticky v2 moves away from file monitoring to API-based port detection, significantly improving security and reliability. Here’s a look at the authentication flow:

1
2
3
headers = {}
if self.settings.gluetun_auth_type == "apikey":
    headers["X-API-Key"] = self.settings.gluetun_apikey

This simple approach allows for both API key and basic auth methods, with API keys being the recommended approach and set by default.

🔹 Health Monitoring
#

qSticky continuously writes out JSON health info, letting you see at a glance whether it can contact Gluetun and qBittorrent:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
  "healthy": true,
  "services": {
    "gluetun": {
      "connected": true,
      "port": 36552
    },
    "qbittorrent": {
      "connected": true,
      "port_synced": true
    }
  },
  "uptime": "4:39:15.985799",
  "last_check": "2025-01-27T10:56:43.807048",
  "last_port_change": "2025-01-27T10:58:14.123075",
  "timestamp": "2025-01-27T15:35:59.792844"
}

This health data will mark the container as unhealthy if any of the services are not reporting as they should to qSticky.

🔹 Port Management Flow
#

The new API-based workflow looks like this:

%%{init: {'theme':'dark', 'themeVariables': { 'fontSize': '20px', 'lineColor': '#88ccff', 'mainBkg': '#1a2b42', 'textColor': '#88ccff' }}}%% sequenceDiagram participant G as Gluetun API participant Q as qSticky participant B as qBittorrent Q->>G: Authenticate activate G G-->>Q: Auth Success deactivate G loop Every CHECK_INTERVAL Q->>G: Get Port Status activate G G-->>Q: Return Port deactivate G Q->>B: Check Current Port activate B B-->>Q: Return Port deactivate B alt Ports Different Q->>B: Update Port activate B B-->>Q: Confirm Change deactivate B end end

🔹 Performance Considerations
#

Key optimizations include:

  • Async I/O (via aiohttp) to handle network calls without blocking.
  • Minimal resource overhead (~30–50 MB RAM).
  • Polled only once every CHECK_INTERVAL seconds (default 30s).

qSticky typically uses around ~30MB of RAM whilst using next to no CPU. Yes, this could probably be better but, again, I am not a code monkey.

🔌 Networking Considerations
#

When running these containers in Docker, it’s important to understand how networking works so that qSticky, Gluetun, and qBittorrent can talk to each other properly. Here’s a ’not as short as I wanted’ overview:

🔹 Docker Compose Default Network
#

By default, when you run docker compose up with multiple services, Docker creates a default network for all the containers. Each container on this default network can resolve the others by their service name as a DNS host. For example:

  • The gluetun service is reachable at the hostname gluetun.
  • The qbittorrent service is reachable at the hostname qbittorrent.
  • The qsticky service is reachable at the hostname qsticky, etc.

In the compose file shown, qSticky is configured with:

1
2
3
4
5
6
environment:
  QBITTORRENT_HOST: gluetun
  ...
  GLUETUN_HOST: gluetun
  GLUETUN_PORT: 8000
  ...
  • Gluetun is just the DNS name on the Docker network; qSticky sends requests there on port 8000 to reach gluetun’s control server API.
  • We are not exposing port 8000 on the Docker host (no 8000:8000 in the ports: section), because qSticky only needs to talk to Gluetun internally over the Docker network.
%%{init: {'theme':'dark', 'themeVariables': { 'fontSize': '20px', 'lineColor': '#88ccff', 'mainBkg': '#1a2b42', 'textColor': '#88ccff' }}}%% sequenceDiagram participant QS as qSticky participant DN as Docker Network participant G as Gluetun Note over QS,G: DNS Resolution Flow QS->>DN: Resolve 'gluetun' DN-->>QS: IP Address (e.g., 172.17.0.2) QS->>G: API Call to gluetun:8000 G-->>QS: API Response Note over QS,G: Each container gets unique IP

🔹 Container:gluetun Networking Mode
#

In the example Compose, qBittorrent has:

1
2
3
qbittorrent:
  network_mode: "service:gluetun"
  ...

This means qBittorrent runs inside the same network stack/namespace as gluetun. Essentially:

  • qBittorrent does not get its own IP address from Docker’s default bridge; instead, it shares Gluetun’s network interface and routes all traffic through Gluetun.
  • Because qBittorrent “lives” inside gluetun’s container network, we do not use the normal Docker bridging to map ports. Instead, we expose qBittorrent’s 8080 externally by doing ports: 8080:8080 on Gluetun.

This is critical for a “kill-switch” approach:

  • If gluetun goes down, the qBittorrent container’s networking goes with it-thus preventing accidental leaks.
  • It ensures qBittorrent is always forced through the VPN.
%%{init: {'theme':'dark', 'themeVariables': { 'fontSize': '20px', 'lineColor': '#88ccff', 'mainBkg': '#1a2b42', 'textColor': '#88ccff' }}}%% sequenceDiagram participant I as Internet participant G as Gluetun participant QB as qBittorrent Note over G,QB: Shared Network Namespace I->>G: Incoming Traffic G->>QB: Direct Access (localhost) QB->>G: Outbound Traffic G->>I: VPN Tunnel Note over G,QB: Kill-switch: QB can't bypass Gluetun

🔹 Why qBittorrent Host Is gluetun
#

You’ll notice that in qSticky’s environment variables, we also set QBITTORRENT_HOST=gluetun. Why not qbittorrent?

From the perspective of qSticky (which is on the default Compose network), qBittorrent doesn’t directly have its own IP in that network, it’s “piggybacking” on gluetun.

The qBittorrent service is effectively inside the Gluetun container’s network namespace, so qSticky must call qBittorrent via the Gluetun container’s IP and port. In short, qBittorrent is listening on localhost:8080 inside Gluetun, and Gluetun itself is accessible as gluetun:8080 across the Docker network.

This is exactly why qBittorrent’s ports are exposed in the gluetun stack.

If we’d used a “bridge” mode for qBittorrent, we would typically do:

1
2
3
network_mode: bridge
ports:
  - 8080:8080

and then set QBITTORRENT_HOST=qbittorrent. But that would bypass the VPN for qBittorrent unless you do more advanced Docker routing. That’s why container:gluetun is used instead-all traffic is forced through the VPN.

🔹 Using localhost vs. Container Names
#

A common question: “Why not just set GLUETUN_HOST=localhost in qSticky since they share the same Docker network?”

If both containers are truly sharing the same network namespace (like container:gluetun would imply), then localhost might work for that scenario. But in reality, qSticky is running in its own container with an isolated environment and an IP assigned on the Docker default network. Meanwhile, qBittorrent is living inside Gluetun’s container. So, from qSticky’s perspective, localhost is qSticky’s own container, not Gluetun.

Also, Docker resolves the name gluetun to the IP address of the Gluetun container on the shared Docker network, which is the correct address for qSticky to talk to Gluetun’s control server.

🔹 When Gluetun Restarts
#

One caveat to be aware of is that if Gluetun restarts, containers that depend on it (like qBittorrent via network_mode: service:gluetun) lose their network connectivity. You might need to restart qBittorrent or other containers that are attached to Gluetun’s network, this is why the recommended deployment is to not put qSticky into gluetun’s container network.

Sometimes, Docker Compose’s depends_on feature helps ensure startup order, but for restarts or unplanned stops, you might need to run docker compose restart qbittorrent qsticky after gluetun is back up. This can also be automated with external scripts or Docker Healthchecks to watch gluetun’s status.

%%{init: {'theme':'dark', 'themeVariables': { 'fontSize': '20px', 'lineColor': '#88ccff', 'mainBkg': '#1a2b42', 'textColor': '#88ccff' }}}%% sequenceDiagram participant G as Gluetun participant QB as qBittorrent participant QS as qSticky Note over G,QS: Normal Operation activate G G->>QB: Network Available QS->>G: Port Query deactivate G Note over G,QS: Gluetun Restart activate G G-)QB: Network Lost QS-)G: Connection Lost deactivate G Note over G,QS: Recovery activate G G->>QB: Network Restored QS->>G: Port Query Resumes deactivate G

🔹 Exposing the Control Server (Optional)
#

By default, you don’t need to expose Gluetun’s port 8000 to the host-that is, you don’t need a 8000:8000 in the ports: section-because qSticky is on the same internal Docker network.

However, if you do want or need external access to that Control Server, you could:

1
2
ports:
  - "8000:8000"

Then you’d set GLUETUN_HOST=<your-docker-host-ip-or-localhost> and GLUETUN_PORT=8000 in qSticky if it runs elsewhere. But this also means the control server is accessible outside your Docker network, so be aware of security implications.

🔹 Separate/Custom Docker Networks
#

In some advanced setups, you might want a dedicated Docker network for just Gluetun, qSticky, and qBittorrent, separate from other containers. For example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
networks:
  vpn_net:
    driver: bridge

services:
  gluetun:
    networks:
      - vpn_net
    ...

  qbittorrent:
    network_mode: "service:gluetun"
    ...

  qsticky:
    networks:
      - vpn_net
    ...

This can help keep containers organised, but the same principles still apply: qBittorrent “lives” in Gluetun’s network namespace, and qSticky communicates over a Docker network to the gluetun hostname on port 8000.

If you do use a custom network, just ensure all relevant containers are attached to it so DNS resolution and traffic routing works as expected.

%%{init: {'theme':'dark', 'themeVariables': { 'fontSize': '20px', 'lineColor': '#88ccff', 'mainBkg': '#1a2b42', 'textColor': '#88ccff' }}}%% sequenceDiagram participant QS as qSticky participant VN as vpn_net participant G as Gluetun participant ON as other_net Note over VN: Isolated VPN Network QS->>VN: Join vpn_net G->>VN: Join vpn_net Note over QS,G: Communication within vpn_net QS->>G: Direct API Access G-->>QS: Response Note over ON: Separate Network ON-)G: ❌ Cannot Access

🔹 TLDR:
#

  • container:gluetun ensures qBittorrent is forced through the VPN (no leaks).
  • qSticky runs on the default (or custom) bridge network and communicates with the Gluetun container’s IP via the Docker DNS name gluetun.
  • No need to expose Gluetun’s API port externally unless you specifically want to interact with it from the host.
  • Be aware of restarts: if Gluetun goes down, dependent containers lose their connection. A simple restart usually fixes this.

🎮 The Full Stack
#

Below is a complete example Docker Compose file. It has all three services-Gluetun, qBittorrent, and qSticky. You can grab a starter version from the qSticky repo and modify it as needed.

1
wget https://raw.githubusercontent.com/monstermuffin/qSticky/refs/heads/main/docker-compose.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
services:
  gluetun:
    container_name: gluetun
    image: qmcgaw/gluetun:latest
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    environment:
      VPN_SERVICE_PROVIDER: protonvpn
      VPN_TYPE: wireguard
      VPN_PORT_FORWARDING: on
      WIREGUARD_PRIVATE_KEY: 'YOUR_PRIVATE_KEY'
      WIREGUARD_ADDRESSES: '10.2.0.2/32'
      SERVER_COUNTRIES: Netherlands
      GLUETUN_HTTP_CONTROL_SERVER_ENABLE: on
    volumes:
      - ./gluetun/config.toml:/gluetun/auth/config.toml
    ports:
      - 8080:8080  # qBittorrent WebUI pass-through
    restart: always

  qbittorrent:
    container_name: qbittorrent
    image: lscr.io/linuxserver/qbittorrent:latest
    network_mode: container:gluetun
    environment:
      PUID: 1000
      PGID: 1000
      TZ: Etc/UTC
      WEBUI_PORT: 8080
    volumes:
      - ./qbittorrent/config:/config
      - ./downloads:/downloads
    healthcheck:
      test: ["CMD-SHELL", "curl -sf https://api.ipify.org || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
    restart: always
    depends_on:
      - gluetun

  qsticky:
    container_name: qsticky
    image: ghcr.io/monstermuffin/qsticky:latest
    environment:
      # qBittorrent settings
      QBITTORRENT_HOST: gluetun
      QBITTORRENT_HTTPS: false
      QBITTORRENT_PORT: 8080
      QBITTORRENT_USER: admin
      QBITTORRENT_PASS: 'YOURPASS'

      # Gluetun
      GLUETUN_HOST: gluetun
      GLUETUN_PORT: 8000
      GLUETUN_AUTH_TYPE: apikey
      GLUETUN_APIKEY: 'YOURAPIKEY'

      # qSticky
      LOG_LEVEL: INFO

    healthcheck:
      # Basic check that qSticky itself thinks everything is healthy
      test: ["CMD", "python3", "-c", "import json; exit(0 if json.load(open('/app/health/status.json'))['healthy'] else 1)"]
      interval: 30s
      timeout: 10s
      retries: 3
    restart: always
The above example is using ls.io’s image for simplicity, however, I personally use hotio’s image as it includes vuetorrent and nightwalker built in. The image is updated frequently and is the easiest way to get a usable qBittorrent UI out of the box. To use it, swap out the image: lscr.io/linuxserver/qbittorrent:latest line with image: ghcr.io/hotio/qbittorrent:latest and adjust accordingly.

🔹 Deploying the Stack
#

  • Pull/Verify Images: docker compose pull

  • Spin Up the Services: docker compose up -d

  • Check Logs:

1
2
3
docker compose logs -f gluetun
docker compose logs -f qbittorrent
docker compose logs -f qsticky

Gluetun logs will show if it successfully connected to ProtonVPN/PIA and whether port forwarding is enabled.

🔹 First-Time Credentials & Initial WebUI Access
#

qBittorrent logs, in some images, show the default or randomly generated admin password on first run if you haven’t set the environment variables. For the LinuxServer image, the documented default is usually admin/adminadmin, but it can vary if you’re using another image (like Hotio’s qBittorrent, which might generate a random password).

Access qBittorrent via http://<your-docker-host-ip>:8080 and login using admin and the generated password shown in the logs.

qSticky logs will confirm if it can contact Gluetun’s Control Server and whether it can authenticate and set the port in qBittorrent. Navigate to Tools → Options → Web UI and change your password, ensure you update the password for qSticky to access qBittorrent in the compose file.

If you used hotio’s image as above, you should change the webui theme to either vuetorrent or nightwalker like so:

You should also map the download folders in your settings, if you used my compose file, downloads should be mapped to /downloads.

⚙️ Advanced Configuration
#

🔹 Using a .env File
#

If you’d like to avoid hardcoding your environment variables directly in docker-compose.yml, you can load them from a .env file. This helps keep sensitive or variable data out of version control.

  • Create a file named .env in the same directory as your docker-compose.yml.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# .env

# Example environment variables
WIREGUARD_PRIVATE_KEY=your_super_secret_key
WIREGUARD_ADDRESSES=10.2.0.2/32
VPN_COUNTRIES=Netherlands

QBITTORRENT_USER=admin
QBITTORRENT_PASS=adminadmin
GLUETUN_APIKEY=this_is_my_very_secure_api_key

Reference these variables in your Compose file using the syntax ${VARIABLE_NAME}, for example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
services:
  gluetun:
    environment:
      VPN_SERVICE_PROVIDER: protonvpn
      VPN_TYPE: wireguard
      VPN_PORT_FORWARDING: on
      WIREGUARD_PRIVATE_KEY: "${WIREGUARD_PRIVATE_KEY}"
      WIREGUARD_ADDRESSES: "${WIREGUARD_ADDRESSES}"
      SERVER_COUNTRIES: "${VPN_COUNTRIES}"
      GLUETUN_HTTP_CONTROL_SERVER_ENABLE: "on"

  qbittorrent:
    # ...
    environment:
      WEBUI_PORT: 8080
      # These might stay the same, but you could also reference variables:
      # PUID: "${PUID}"
      # PGID: "${PGID}"

  qsticky:
    environment:
      QBITTORRENT_USER: "${QBITTORRENT_USER}"
      QBITTORRENT_PASS: "${QBITTORRENT_PASS}"
      GLUETUN_APIKEY: "${GLUETUN_APIKEY}"
Don’t commit the .env file to a public repo. Use something like .gitignore to avoid leaking credentials.st.

🔹 Docker Secrets for Sensitive Credentials
#

For even better security, especially if you’re using Docker Swarm or advanced Compose features, you can store credentials (e.g., VPN username/password or private keys) as Docker secrets. This prevents them from appearing in plain text in your container’s environment variables.

  • Create a secrets file for each sensitive value in a dedicated folder (e.g., ./secrets):
1
2
3
4
mkdir secrets
echo "myProtonVPNPrivateKey" > secrets/wg_private_key
echo "10.2.0.2/32" > secrets/wg_addresses
echo "superSecureApiKey" > secrets/gluetun_api_key

Reference them in your docker-compose.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
version: "3.9"
services:
  gluetun:
    image: qmcgaw/gluetun:latest
    secrets:
      - wg_private_key
      - wg_addresses
      - gluetun_api_key
    environment:
      # Docker sets environment variables for secrets like:
      #   SOME_VARIABLE_FILE=/run/secrets/<secret_file_name>
      # So you can point Gluetun’s config to read from that file:
      WIREGUARD_PRIVATE_KEY_FILE: "/run/secrets/wg_private_key"
      WIREGUARD_ADDRESSES_FILE: "/run/secrets/wg_addresses"
      # For an API key, you might do the same in qSticky or place it here
      # e.g. GLUETUN_APIKEY_FILE=/run/secrets/gluetun_api_key
    # ...

secrets:
  wg_private_key:
    file: ./secrets/wg_private_key
  wg_addresses:
    file: ./secrets/wg_addresses
  gluetun_api_key:
    file: ./secrets/gluetun_api_key

Adjust your container logic so that Gluetun (or qSticky) reads from those file-based variables instead of normal environment variables. Many images, including Gluetun, have a _FILE variant for credentials.

%%{init: {'theme':'dark', 'themeVariables': { 'fontSize': '20px', 'lineColor': '#88ccff', 'mainBkg': '#1a2b42', 'textColor': '#88ccff' }}}%% flowchart TD subgraph Secrets Management S[Docker Secrets] E[Environment Files] C[Container Config] end subgraph Runtime G[Gluetun] Q[qBittorrent] QS[qSticky] end S -->|"WG Keys
API Keys"| G E -->|"VPN Settings
Ports"| G E -->|"WebUI Config"| Q E -->|"API Settings"| QS style S fill:#0f1b2d,stroke:#ff69b4 style E fill:#0f1b2d,stroke:#00ff00 style G fill:#0f1b2d,stroke:#00ffff style Q fill:#0f1b2d,stroke:#ff00ff style QS fill:#0f1b2d,stroke:#88ccff
Docker secrets are “ephemeral” in Docker Swarm Mode, meaning they’re loaded into an in-memory filesystem. In plain Docker Compose (non-Swarm), the secrets mechanism can still work, but it may function like standard file mounts.

🔍 Troubleshooting
#

If things aren’t working as you expect, check the following:

  • Check you can get the Gluetun forwarded port, from qSticky:
1
docker exec -it qSticky http GET http://gluetun:8000/v1/openvpn/portforwarded X-API-Key:<YOUR-KEY-HERE>
The above is using gluetun as the host. If you are using container networking, you will use localhost.

Should show something like this:

1
2
3
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
{ "port": 36552 }
  • Check qSticky’s logs & health file:
1
2
docker logs qsticky
docker exec qsticky cat /app/health/status.json

This will tell you the status of Gluetun and qBittorrent from qSticky’s prospective:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
  "healthy": true,
  "services": {
    "gluetun": {
      "connected": true,
      "port": 36552
    },
    "qbittorrent": {
      "connected": true,
      "port_synced": true
    }
  },
  "uptime": "4:39:15.985799",
  "last_check": "2025-01-27T10:56:43.807048",
  "last_port_change": "2025-01-27T10:58:14.123075",
  "timestamp": "2025-01-27T15:35:59.792844"
}
  • Check qBittorrent:
    • Ensure Tools → Options → Connection → Listening Port in qBittorrent shows the new port.
    • The qBittorrent status indicator should be green if you have active torrents/peers.

The “green light” in qBittorrent sometimes only shows up after active torrent traffic begins. Seeing yellow/red might not always mean something is broken. Best way to check is looking at actual seeding or a “port-check” torrent.

🎬 Final Thoughts
#

After years of tinkering with various VPN+torrent setups, I’m happy with how this stack has evolved, partly because some of it is using my own code. This setup is pretty much what I was aiming for - a setup that “just works” while delivering the best possible performance.

Whilst qSticky is only for a narrow subset of users, and I would imagine most people simply don’t care about port forwarding in their torrent client (hey, I didn’t for ages) I really can’t stress enough how much of an improvement it is for the individual, and the swarm.

Thanks for reading, and happy (safe) torrenting!

~Muffn

Rodoč, Bosnia and Herzegovina
Sony A7R III + Sigma 24-70mm f/2.8 Art @ 24mm, f/2.8, 1/45s, ISO 500