VPN Kill Switches for a Self-Hosted Download Box

If the tunnel drops and your download client keeps going, you've leaked — a firewall kill switch makes that impossible

Contents

Picture the failure you’re actually guarding against. You’ve got a download box — a machine pulling large files, Linux distribution images and the like — and you route its traffic through a commercial VPN so the box reaches the wider internet under the VPN’s public IP in place of your home connection’s. This works beautifully right up until 3am, when the VPN endpoint hiccups, the tunnel drops for eleven seconds, and your download client, being a stubbornly optimistic piece of software, notices the connection died and simply reconnects — over the raw ISP link, because that’s the only route left. For those eleven seconds, and possibly for hours until you notice, every packet carries your real IP address in clear view. The whole point of the VPN evaporated silently in the middle of the night.

A kill switch is the mechanism that makes that impossible. Its job is a single, absolute guarantee: traffic leaves this box through the VPN tunnel or it does not leave at all. If the tunnel is up, packets flow. If the tunnel is down, packets are dropped on the floor, the download stalls, and nothing leaks. The download client can be as optimistic as it likes; the network underneath it will refuse to carry a single byte outside the tunnel. Getting that guarantee right is the difference between a VPN that protects you and a VPN that protects you most of the time, which for privacy purposes is close to useless.

Why app settings aren’t enough

Advertisement

The tempting first answer is the setting inside the download client itself. qBittorrent, for instance, has a “Network Interface” option: bind it to tun0 and it will only send traffic out of the VPN interface. This genuinely helps, and if it’s all you do you’re already ahead of most people. But it leaks in ways that matter, and understanding why is the key to doing this properly.

The problem is that “bind to tun0” is a request the application makes, and the application is the wrong layer to enforce a security boundary. When the tunnel drops, tun0 disappears. When it reconnects, tun0 might come back with a different index, or the client might have already fired off DNS lookups over the default route before the bind took effect, or an IPv6 route might carry traffic the client’s IPv4 interface binding never considered. Each of these is a real, documented leak path. The application did what you asked; the leak happened in the gaps around what you asked.

The lesson generalises: a security guarantee has to be enforced at a layer below the thing it’s constraining. You don’t ask the download client nicely to stay in the tunnel. You build a wall around the download client — at the firewall — that it cannot cross no matter how it behaves. The firewall doesn’t know or care that there’s a torrent client inside; it knows that packets to the wider internet are permitted out of tun0 and forbidden everywhere else, full stop. That’s a guarantee. The app setting is a preference.

The threat model, stated plainly

Three specific leaks are what a real kill switch has to close:

  • The reconnect leak — tunnel drops, client falls back to the default route. This is the headline one.
  • The DNS leak — even with data going through the tunnel, DNS queries can escape over the default route to your ISP’s resolver, revealing every domain the box looks up. This ties directly into how you handle encrypted DNS and sinkholing; a download box should resolve through the tunnel, full stop.
  • The IPv6 leak — many VPN setups tunnel only IPv4, leaving IPv6 to route natively over your ISP. If the box and the destination both speak IPv6, your traffic bypasses the tunnel entirely while every test you run over IPv4 looks perfect.

A kill switch worthy of the name closes all three. Block everything by default, permit egress only via the tunnel, force DNS through the tunnel, and either disable IPv6 on the box or block it at the firewall so it can’t become a silent bypass.

The clean approach: a gateway container

Advertisement

The tidiest way to build this today is to put the VPN client in its own container and route the download client through it, so the download client has no network of its own — it borrows the VPN container’s network namespace entirely. If the VPN container’s tunnel is down, the download client has no route to anywhere. Gluetun is purpose-built for this and ships a firewall-based kill switch on by default. The pattern uses Docker’s shared network namespace:

 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
# docker-compose.yml
services:
  gluetun:
    image: qmcgaw/gluetun:latest
    container_name: gluetun
    cap_add:
      - NET_ADMIN
    ports:
      - "8081:8081"          # qBittorrent WebUI, published on the GATEWAY
      - "6881:6881"          # torrent listen port
      - "6881:6881/udp"
    environment:
      - VPN_SERVICE_PROVIDER=your_provider
      - VPN_TYPE=wireguard
      - WIREGUARD_PRIVATE_KEY=REDACTED
      - WIREGUARD_ADDRESSES=10.64.0.2/32
      - SERVER_COUNTRIES=Netherlands
      # kill switch is ON by default; allow LAN so the WebUI stays reachable
      - FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24
      - DOT=on                # DNS over TLS through the tunnel
    restart: unless-stopped

  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    container_name: qbittorrent
    network_mode: "service:gluetun"   # <-- shares gluetun's network stack
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Europe/London
      - WEBUI_PORT=8081
    volumes:
      - ./config:/config
      - /mnt/downloads:/downloads
    depends_on:
      - gluetun
    restart: unless-stopped

The load-bearing line is network_mode: "service:gluetun". It means qBittorrent has no network interfaces of its own — it lives inside gluetun’s namespace and every packet it sends is subject to gluetun’s firewall. Note that the download client’s ports are published on the gluetun service — as far as Docker is concerned qBittorrent has no network of its own to publish them on. Get this backwards and you’ll stare at an unreachable WebUI wondering what broke.

When the tunnel is healthy, traffic flows out through WireGuard. When gluetun loses the tunnel, its kill switch drops outbound packets, qBittorrent’s connections stall, and nothing touches your ISP link. The FIREWALL_OUTBOUND_SUBNETS line is what keeps your LAN reachable through the wall — without it, the kill switch would also block the WebUI you’re trying to reach from your own network.

The firewall approach, for boxes without containers

If your download box is a plain VM or a bare-metal machine running the VPN directly, you build the same guarantee with nftables. The logic is identical: default-drop, allow loopback, allow the LAN, allow reaching the VPN server on the physical interface, and allow everything else only out of the tunnel.

 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
#!/usr/sbin/nft -f
# /etc/nftables.conf — VPN kill switch for a download box
flush ruleset

table inet killswitch {
  chain output {
    type filter hook output priority 0; policy drop;

    # always allow loopback
    oif "lo" accept

    # allow local network (WebUI access, NAS mounts)
    ip daddr 192.168.1.0/24 accept

    # allow reaching the VPN endpoint itself over the physical NIC
    oifname "eth0" ip daddr 203.0.113.10 udp dport 51820 accept

    # allow anything out of the tunnel interface
    oifname "tun0" accept
    oifname "wg0"  accept

    # block IPv6 entirely so it can't become a bypass
    meta nfproto ipv6 drop

    # everything else: dropped by the policy above
  }
}

Every rule here earns its place. Loopback keeps local services working. The LAN rule keeps the WebUI and your file shares reachable. The VPN-endpoint rule is the one people forget and then can’t work out why the tunnel won’t even establish: the box must be allowed to reach the VPN server’s real IP over the physical NIC, otherwise the drop policy blocks the very handshake that would bring the tunnel up. After that, tun0/wg0 egress is permitted and the default drop policy catches everything else. The explicit IPv6 drop closes the silent-bypass hole. Load it with nft -f /etc/nftables.conf and enable the service so it survives reboot.

Whichever route you take, this box is a natural candidate for its own VLAN so it’s isolated from the rest of the network; a machine whose whole job is to route through an external tunnel has no business having free rein of your LAN.

Test it, because an untested kill switch is a guess

A kill switch you haven’t verified is a comforting story, nothing more. Two tests prove it works.

First, confirm the box’s public IP is the VPN’s while healthy. From inside the network namespace:

1
2
$ docker exec gluetun wget -qO- https://ipinfo.io/ip
185.230.xx.xx        # a VPN exit address — good, this is what you want

Second, and this is the one that matters, break the tunnel on purpose and confirm the download client goes dark. Stop the VPN and re-check:

1
2
3
$ docker stop gluetun
$ docker exec qbittorrent wget -qO- --timeout=5 https://ipinfo.io/ip
wget: bad address 'ipinfo.io'      # no route, no leak — the switch held

That failure is the success. The download client, deprived of the tunnel, can reach nothing. If instead you got your home IP back, the kill switch is not working and you have a leak to fix before trusting the setup with anything.

Once you trust it, keep an eye on it. A kill switch that’s doing its job will silently stall every download the moment the tunnel dies, so a dropped tunnel can masquerade as “the internet is a bit slow tonight” for hours. Point a health check at the gateway — an Uptime Kuma monitor that pings the VPN’s public IP through the container, or watches the gluetun health endpoint — and you’ll get told the moment the exit changes or the tunnel drops, rather than discovering a week of stalled downloads.

Troubleshooting

The WebUI is unreachable. Two common causes. In the container setup, you published the port on the wrong service — it must be on gluetun, since that’s who owns the network. In the firewall setup, your LAN allow rule doesn’t match the subnet you’re browsing from. Confirm your management machine’s IP falls inside the accepted range.

The tunnel won’t even come up. The kill switch is blocking the VPN handshake itself. You need an explicit allow for the VPN server’s real IP and port over the physical interface before the default drop, exactly as in the nftables example. This is the single most common self-inflicted wound with hand-rolled kill switches.

Downloads work but trackers or peers are scarce, and seeding is dead. You’re behind the VPN with no forwarded port, so inbound connections can’t reach you. If your VPN provider offers port forwarding, enable it and point the client’s listen port at the forwarded port; gluetun can fetch it automatically for providers that support it. Without a forwarded port you can still download, but your connectivity will be one-directional.

DNS resolves to nothing, or leaks despite everything. If DNS queries went out over the default route they’d be blocked by the kill switch, so a box that can’t resolve is usually a sign the switch is working and DNS wasn’t routed through the tunnel. Force resolution through the tunnel — gluetun’s built-in DoT, or a resolver reachable only via tun0. Verify with a DNS leak test from inside the namespace.

IPv6 traffic bypasses the tunnel. If a leak test shows an IPv6 address that isn’t the VPN’s, your IPv4 rules are perfect and IPv6 is routing natively. Block it at the firewall (the meta nfproto ipv6 drop line) or disable IPv6 on the box.

Is it worth it?

If you’re routing a download box through a VPN at all, the kill switch is the part that makes the exercise honest. Without it you have a VPN that works until it doesn’t and leaks precisely when it fails, which is the worst possible time. The gateway-container approach is genuinely a twenty-minute job and I’d recommend it for anyone running this in Docker; the hand-rolled nftables version is the right call for bare-metal boxes and is worth understanding even if you use gluetun, because it demystifies what the container is doing on your behalf.

The one thing to keep clear-eyed about is that a kill switch protects the network path, and nothing more. It guarantees no packet leaves outside the tunnel; it says nothing about what you do inside the tunnel, and it’s no licence to treat a commercial VPN as anonymity. What it buys you is a hard, testable guarantee against the specific, common, silent failure of a dropped tunnel — and because you can prove it holds by pulling the plug and watching the client go dark, it’s one of the few security controls in a homelab you can be genuinely certain about. That certainty is worth the twenty minutes.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.