A VPN kill-switch that actually kills

Gluetun + qBittorrent with `network_mode: container:gluetun` — the one-line pattern that makes a leak impossible, not just unlikely.


If your “VPN-only torrent client” is configured by binding qBittorrent to a VPN network interface, you’ve built a kill-switch that usually works. There’s a better pattern: put qBittorrent inside the VPN container’s network namespace, so it can only see the VPN’s networking stack. If the tunnel drops, qBittorrent has no other route. It doesn’t fall back to the host network. It doesn’t try to be helpful. It just stops. This is the one-line docker-compose directive that does it, and why it beats every “configure the client to bind to interface tun0” approach you’ll find on Reddit.

The promise people misunderstand

“VPN kill-switch” usually means “if the VPN drops, stop sending traffic outside it.” The usual implementation is one of three:

  1. Bind the client to the VPN interface (tun0, wg0). If the interface goes away, the client errors out.
  2. Firewall rules that drop any packet from the client’s UID that isn’t going through the VPN.
  3. External monitoring that detects “VPN down” and kills the client.

Each one works. Each one also has a gap. Interface binding has a race window during reconnection. Firewall rules require the firewall to be exactly correct, every reboot, forever. External monitoring is by definition delayed.

The pattern I use closes the gap entirely. Not “the client is configured to use the VPN.” The client cannot see any other network. This isn’t a configuration choice the client could get wrong. It’s a property of the network namespace the client lives in.

The one-line trick

In docker-compose.yml:

services:
  gluetun:
    image: qmcgaw/gluetun
    container_name: gluetun
    cap_add:
      - NET_ADMIN
    ports:
      - 8080:8080        # qBittorrent WebUI, exposed via gluetun
      - 47148:47148      # torrent port
      - 47148:47148/udp
    environment:
      - VPN_SERVICE_PROVIDER=airvpn
      - VPN_TYPE=wireguard
      - WIREGUARD_PRIVATE_KEY=${WG_PRIVATE_KEY}
      - WIREGUARD_PRESHARED_KEY=${WG_PRESHARED_KEY}
      - WIREGUARD_ADDRESSES=10.191.158.14/32
      - SERVER_NAMES=${VPN_SERVERS}
      - FIREWALL_VPN_INPUT_PORTS=47148
    restart: unless-stopped

  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    container_name: qbittorrent
    environment:
      - PUID=1027
      - PGID=100
      - WEBUI_PORT=8080
      - TORRENTING_PORT=47148
    network_mode: "container:gluetun"     # <-- the line
    depends_on:
      gluetun:
        condition: service_healthy
    restart: unless-stopped

The line is network_mode: "container:gluetun". It tells Docker: don’t give qBittorrent its own network stack. Make it share gluetun’s. Same interfaces, same routing table, same firewall rules, same DNS.

Inside that namespace, qBittorrent has access to:

It has access to:

There is no eth0 from qBittorrent’s perspective. The host’s normal network does not exist inside this namespace. There’s no “if the VPN drops, fall back to” because there’s nowhere to fall back to.

Why this is structurally stronger

Consider what happens in three failure scenarios.

┌────────────────────────────────────────────────────────────────┐
│  Scenario A: VPN endpoint disconnects                          │
├────────────────────────────────────────────────────────────────┤
│  With interface binding:                                       │
│   - wg0 interface vanishes                                     │
│   - qBittorrent tries default route → goes through host eth0   │
│   - LEAK until client notices and stops                        │
│                                                                │
│  With container:gluetun:                                       │
│   - wg0 vanishes inside the namespace                          │
│   - qBittorrent has NO default route                           │
│   - All traffic fails immediately                              │
│   - NO LEAK possible                                           │
└────────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────────┐
│  Scenario B: gluetun container crashes/restarts                │
├────────────────────────────────────────────────────────────────┤
│  With interface binding:                                       │
│   - qBittorrent keeps running on host network                  │
│   - LEAK until client detects interface gone                   │
│                                                                │
│  With container:gluetun:                                       │
│   - qBittorrent's network namespace is gluetun's               │
│   - When gluetun dies, qBittorrent loses ALL networking        │
│   - Docker restarts both per depends_on                        │
│   - NO LEAK possible                                           │
└────────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────────┐
│  Scenario C: misconfiguration                                  │
├────────────────────────────────────────────────────────────────┤
│  With interface binding:                                       │
│   - User selects wrong interface in client UI                  │
│   - LEAK                                                       │
│                                                                │
│  With container:gluetun:                                       │
│   - qBittorrent has nothing else to choose                     │
│   - There IS NO wrong interface to select                      │
│   - NO LEAK possible                                           │
└────────────────────────────────────────────────────────────────┘

The pattern doesn’t make leaks unlikely. It makes them impossible at the protocol level. A leak would require qBittorrent to somehow open a socket on a network interface that, from its process’s perspective, does not exist.

Two things that bite when you first set this up

The WebUI is on the gluetun container’s ports. Because qBittorrent shares gluetun’s network namespace, you don’t ports: from the qBittorrent container — you ports: from gluetun. The mapping 8080:8080 on gluetun is what exposes qBittorrent’s WebUI to the host. Putting that mapping on qBittorrent will silently do nothing. Took me ten minutes to figure out the first time.

Torrent ports go on gluetun too. Same reason. The forwarded port your VPN provider gave you (mine is 47148) needs to be exposed on the gluetun container, and listed in FIREWALL_VPN_INPUT_PORTS so gluetun’s internal firewall lets the inbound peer connections through. Both have to be right. Either one wrong and you get no incoming connections, which manifests as torrent speeds inexplicably stuck at 5 KB/s.

Health check timing. qBittorrent’s container has depends_on: { gluetun: { condition: service_healthy } }. This means qBittorrent waits until gluetun reports healthy before starting. If the VPN is slow to handshake (rural connections, weird DNS), qBittorrent can take 60+ seconds to come up after a host reboot. This is correct behaviour. Don’t override it with shorter timeouts. Letting qBittorrent start before the tunnel is up was the entire failure mode this pattern was designed to prevent.

The full traffic diagram

┌────────────────────────────────────────────────────────────────┐
│  HOST                                                          │
│                                                                │
│  ┌──────────────────────────────────────────────────────────┐ │
│  │  NAMESPACE: gluetun                                       │ │
│  │                                                           │ │
│  │  ┌──────────────┐         ┌──────────────────────┐       │ │
│  │  │  gluetun     │────────►│  WireGuard tunnel    │───────┼─┼──► VPN endpoint
│  │  │  (proc)      │         │  wg0                 │       │ │      ──► internet
│  │  └──────────────┘         └─────────▲────────────┘       │ │
│  │                                     │                    │ │
│  │  ┌──────────────┐                   │                    │ │
│  │  │  qBittorrent │───────────────────┘                    │ │
│  │  │  (proc)      │     ALL traffic uses wg0               │ │
│  │  └──────────────┘     no other route exists              │ │
│  │                                                           │ │
│  └──────────────────────────────────────────────────────────┘ │
│                                                                │
│  eth0  ◄── host's normal networking, NOT visible inside        │
│              the gluetun namespace                             │
└────────────────────────────────────────────────────────────────┘

The qBittorrent process has no concept of eth0. The host could be on Wi-Fi, on Ethernet, on cellular hotspot — qBittorrent does not see any of that. It sees gluetun’s namespace, which sees wg0, which goes to the VPN endpoint, which goes to the internet. One path. No alternates.

What’s not covered

Two leak surfaces this pattern doesn’t address; mention them so nobody mistakes the pattern for a complete privacy story:

DNS leaks. If qBittorrent makes a DNS query and the DNS query goes to your ISP’s resolver, your ISP knows you asked. Gluetun handles this by default — it intercepts DNS and routes through the VPN — but it’s worth verifying with docker exec qbittorrent cat /etc/resolv.conf after setup. The resolver should be gluetun’s, not the host’s.

Trackers and peer exchanges. The torrent client sends content through the VPN, but the tracker URLs, peer lists, and DHT messages are all still TCP/UDP through the same tunnel. That’s fine — same VPN, same anonymity layer — but if you somehow add another client to this namespace (a web scraper, say) you should think about what that one is sending. Anything in the namespace gets the same anonymity, no more, no less.

Application-layer fingerprinting. Gluetun doesn’t anonymise what qBittorrent says. Client name in the BitTorrent handshake, user-agent strings, browser fingerprint of the WebUI if you expose it publicly. The VPN hides the IP. It doesn’t hide the protocol.

Why I prefer this to “use a VPN client and configure your apps to bind to it”

Three reasons, in increasing order of how much I care about them:

  1. It survives a reboot. No host-level VPN client to start, no systemd ordering to get right, no “did the VPN come up before the torrent client?” race. The compose file describes the right state and Docker enforces it.
  2. It survives a maintainer (me) being tired. I can’t accidentally bind qBittorrent to the wrong interface. There’s no interface to choose. The configuration cannot express the misconfigured state.
  3. It survives a compromised qBittorrent process. If a future qBittorrent vulnerability lets an attacker run code as the qBittorrent user, the attacker still can’t see the host network. They’re in gluetun’s namespace, which has exactly the same connectivity qBittorrent had — i.e., one VPN tunnel. They can do bad things; they can’t do bad things that reveal my home IP.

The third one is the reason I sleep better. Most leaks I’ve read about in the torrenting world weren’t VPN failures. They were misconfigurations or user errors that bypassed a working VPN. This pattern doesn’t fix VPN failures — gluetun does that — but it makes the user-error category impossible to enter, because the user (me) doesn’t have a way to choose the wrong interface.

The whole thing is one compose file. About 40 lines. Five minutes to set up. The leak surface is structurally closed. There are a small number of self-hosting patterns that I would describe as correct in a way that other patterns are not; this is one of them.


Adjacent: Cloudflare Tunnel as a homelab front door — the same homelab, exposed (carefully) outward.