VPNSmith
self-host-vpnINFO

Linux VPN kill switch with iptables and systemd (2026)

Netfilter kill switch dropping all out-of-tunnel WireGuard traffic. Ordered systemd service, VPN interface exception, reconnect test. Zero leak guaranteed.

By Eric Gerard · Founder · VPNSmith - Self-host VPN & GDPR VPS specialist9 min readPhoto via Unsplash

You use a self-host VPN. You wonder what happens if the tunnel drops for 12 seconds mid-upload of something sensitive. On most default Linux configs: traffic flows clear through your default route, with no visible indication. Local DNS leaks, real IP shows up in remote server logs. An app-level kill switch won't save you - this must be blocked at the kernel level.

This guide sets up an iptables + systemd kill switch that guarantees nothing exits your machine in the clear, even during the 50 ms gap between two WireGuard handshakes.

Why a netfilter kill switch is the only serious guarantee

The commercial VPN industry has been talking about "kill switches" for seven years, but most implementations are fragile because they operate at the application level: the NordVPN or ExpressVPN app detects a tunnel drop and cuts traffic by manipulating the routing table. The problem: during the 200 to 800 milliseconds it takes for the application to detect the drop and react, packets exit in the clear through the default interface. That's enough for an active BitTorrent connection to expose your real IP to the tracker, or for an in-progress multipart S3 upload to reveal your geographic origin.

The netfilter kill switch (iptables or nftables) sidesteps this problem by placing the filtering rule in the Linux kernel itself, not in a user-space process. Packets traverse the netfilter subsystem at every send/sendmsg/sendto syscall - there's no application latency window. If WireGuard crashes, packets keep being DROP-ed by netfilter until the tunnel comes back, independent of the WireGuard process state. That's why Mullvad recommends netfilter as a second layer in addition to their app, and why Tor Project guides explicitly mention the netfilter kill switch for hosts that run relays.

Our choice of iptables rather than nftables comes down to support: Ubuntu Server 22.04 LTS and Debian 12 still have iptables as the de-facto tool even though the backend is nftables under the hood. iptables commands are copy-pasteable from any doc of the past 10 years, which reduces typo risk and eases maintenance. If you're starting fresh in 2026 on a new host and you know nftables, go directly with nftables - the syntax is cleaner and chain order is explicit.

The model we build

The core idea in 3 sentences:

  1. All OUTPUT traffic is DROP by default unless it exits via the VPN interface (wg0).
  2. Exception: the UDP connection to the VPN server (handshake) stays allowed - without it, the tunnel can't re-establish after a drop.
  3. systemd guarantees those iptables rules apply before WireGuard and survive tunnel restarts.

Result: if WireGuard dies, your machine literally can't send anything except the VPN handshake. You see "no connection" in the browser, but no packet leaks.

Prep

On Ubuntu/Debian (tested Ubuntu 22.04 and 24.04, kernel 5.15+):

sudo apt update
sudo apt install -y iptables iptables-persistent
# At netfilter-persistent prompt: YES for IPv4 and IPv6

Note these items before continuing:

  • The VPN public IP: nslookup vpn.example.com or check the Endpoint line in wg0.conf.
  • The VPN UDP port: usually 51820 for WireGuard.
  • The VPN interface: wg0 by default, can be wg1 with multiple tunnels.
  • The local LAN subnet to keep reachable (192.168.1.0/24, 10.0.0.0/8, etc.) - so printer and NAS don't break.

IPv4 iptables rules

Create /etc/iptables/rules.v4 with this content (adapt boxed values):

*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT DROP [0:0]

# Loopback always allowed
-A INPUT  -i lo -j ACCEPT
-A OUTPUT -o lo -j ACCEPT

# Already-established connections (VPN handshake responses, etc.)
-A INPUT  -m state --state ESTABLISHED,RELATED -j ACCEPT
-A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# Incoming SSH (if you admin remotely - otherwise drop these 2 lines)
-A INPUT  -p tcp --dport 22 -m state --state NEW -j ACCEPT

# Outbound VPN handshake: only out-of-tunnel traffic allowed
-A OUTPUT -p udp -d 1.2.3.4 --dport 51820 -j ACCEPT

# Local LAN DNS (Pi-hole, router) - optional
-A OUTPUT -d 192.168.1.0/24 -j ACCEPT

# Everything else OUTPUT goes via VPN interface
-A OUTPUT -o wg0 -j ACCEPT

# Allow incoming on wg0
-A INPUT  -i wg0 -j ACCEPT

COMMIT

Replace:

  • 1.2.3.4 with your VPN server's actual public IP
  • 51820 with your WireGuard port
  • 192.168.1.0/24 with your LAN subnet
  • Drop the --dport 22 line if you don't need inbound SSH admin

Load rules:

sudo iptables-restore < /etc/iptables/rules.v4
sudo iptables -L -v -n
# Confirm DROP chain counters are at 0

ip6tables rules (CRITICAL)

Without IPv6 explicitly blocked, many distros enable it by default and traffic leaks. Create /etc/iptables/rules.v6:

*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT DROP [0:0]

-A INPUT  -i lo -j ACCEPT
-A OUTPUT -o lo -j ACCEPT

-A INPUT  -m state --state ESTABLISHED,RELATED -j ACCEPT
-A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# If your VPN supports IPv6 (rare in self-host):
# -A OUTPUT -p udp -d 2001:db8::1 --dport 51820 -j ACCEPT
# -A OUTPUT -o wg0 -j ACCEPT
# -A INPUT  -i wg0 -j ACCEPT

# Otherwise all IPv6 outside loopback is DROP

COMMIT

Load:

sudo ip6tables-restore < /etc/iptables/rules.v6

At this point you can test: without the WireGuard tunnel, no ping to the Internet passes. Expected.

systemd service ordering for WireGuard after iptables

Server racks lit in blue in a data center
Server racks lit in blue in a data center

Default issue: wg-quick@wg0 may start before netfilter-persistent. During that window (~1-3 seconds), the tunnel is UP but iptables rules aren't loaded → possible leak.

Solution: systemd override forcing order.

Create /etc/systemd/system/wg-quick@wg0.service.d/killswitch.conf:

sudo mkdir -p /etc/systemd/system/wg-quick@wg0.service.d
sudo nano /etc/systemd/system/wg-quick@wg0.service.d/killswitch.conf

Content:

[Unit]
After=netfilter-persistent.service
Wants=netfilter-persistent.service
Requires=netfilter-persistent.service

[Service]
# If wg-quick crashes, force OUTPUT DROP to prevent any leak
ExecStopPost=/sbin/iptables -P OUTPUT DROP
ExecStopPost=/sbin/ip6tables -P OUTPUT DROP

Reload systemd:

sudo systemctl daemon-reload
sudo systemctl enable wg-quick@wg0
sudo systemctl enable netfilter-persistent
sudo systemctl restart netfilter-persistent
sudo systemctl restart wg-quick@wg0

At each boot, systemd starts netfilter-persistent first (iptables loaded), then wg-quick@wg0 (tunnel up). No leak window.

Validation tests

Test 1 - Drop the tunnel, verify silence

# Tunnel up
sudo wg show
# Must show a recent handshake

# Test Internet access
curl -s -m 5 https://ifconfig.me
# Must return VPS IP

# Drop tunnel
sudo wg-quick down wg0

# Re-test
curl -s -m 5 https://ifconfig.me
# Must time out with no response - KILL SWITCH ACTIVE

If you see an IP on the second command → leak, iptables rules aren't applied. Check sudo iptables -L -v -n and confirm OUTPUT default is DROP.

Test 2 - Simulate a WireGuard crash

# Tunnel up
sudo wg-quick up wg0
ping -c 2 1.1.1.1
# Must ping OK

# Brutal kill of WireGuard process (simulate crash)
sudo pkill -9 wg
sudo ip link delete wg0 2>/dev/null

# Re-test
ping -c 2 1.1.1.1
# Must fail "Operation not permitted"

The kernel refuses the ping because no route remains allowed. Without ExecStopPost in the systemd drop-in, some distros leave a leak window after the crash - that line matters.

Test 3 - DNS leak

# Tunnel UP
dig @9.9.9.9 ifconfig.me +short
# Must return VPS IP

# Drop tunnel
sudo wg-quick down wg0

# Re-test
dig @9.9.9.9 ifconfig.me +short
# Must timeout / "no servers could be reached"

Even DNS is blocked outside the tunnel. Goal achieved.

Special cases

You run several tunnels (wg0 + wg1)

Duplicate the OUTPUT exception:

-A OUTPUT -p udp -d 1.2.3.4 --dport 51820 -j ACCEPT  # main tunnel
-A OUTPUT -p udp -d 5.6.7.8 --dport 51820 -j ACCEPT  # secondary tunnel
-A OUTPUT -o wg0 -j ACCEPT
-A OUTPUT -o wg1 -j ACCEPT

Run Tailscale in parallel

Tailscale uses UDP 41641 by default. Add:

-A OUTPUT -p udp --dport 41641 -j ACCEPT
-A OUTPUT -o tailscale0 -j ACCEPT

Keep local LAN reachable (NAS, printer)

The template rule -A OUTPUT -d 192.168.1.0/24 -j ACCEPT already does that. Adapt the subnet.

You don't want inbound SSH (mobile workstation)

Drop the --dport 22 -m state --state NEW -j ACCEPT line. Existing (ESTABLISHED) connection stays OK.

Logging dropped packets (debug)

To see what would've leaked without the kill switch:

sudo iptables -I OUTPUT 1 -j LOG --log-prefix "OUTPUT-DROP: " --log-level 4

Then:

sudo journalctl -k -f | grep "OUTPUT-DROP"

You'll see every packet your OS tries to send in the clear when the tunnel is down (Apple DNS, NTP, Spotify heartbeat, etc.). Educational.

Once done debugging, remove the LOG rule (else journalctl fills up):

sudo iptables -D OUTPUT -j LOG --log-prefix "OUTPUT-DROP: " --log-level 4

Bonus: kill switch inside wg0.conf directly

If you prefer not to manage iptables separately, WireGuard accepts PostUp/PreDown doing the same job:

[Interface]
# ...
PostUp = iptables -I OUTPUT ! -o %i -m mark ! --mark $(wg show %i fwmark) -m addrtype ! --dst-type LOCAL -j REJECT
PostUp = ip6tables -I OUTPUT ! -o %i -m mark ! --mark $(wg show %i fwmark) -m addrtype ! --dst-type LOCAL -j REJECT
PreDown = iptables -D OUTPUT ! -o %i -m mark ! --mark $(wg show %i fwmark) -m addrtype ! --dst-type LOCAL -j REJECT
PreDown = ip6tables -D OUTPUT ! -o %i -m mark ! --mark $(wg show %i fwmark) -m addrtype ! --dst-type LOCAL -j REJECT

Pro: zero extra file. Con: rules vanish if WireGuard crashes without calling PreDown (kernel panic, OOM kill). The systemd approach above is more robust for long-term prod.

Also see the travel kill-switch template in the WireGuard 2026 guide for the inline-wg0.conf version.

Final check

# Full reboot
sudo reboot

# After reboot, no manual steps
ip a show wg0
# Must show tunnel UP

iptables -L OUTPUT -v -n | head -5
# Default policy must be DROP, accept counters must grow

curl -s ifconfig.me
# Must return VPS IP

All green: your kill switch is in place, runs at boot, survives crashes. You can plug the machine into a hostile network without leak risk.

Going further

The kill switch blocks leaks. But for genuine peace of mind, add:

And the foundation: WireGuard setup on Contabo in 20 minutes.

★ Nuremberg GDPR datacenter · ✓ Dedicated IPv4 included · 200+ Mbps guaranteed

Self-host your VPN on your own VPS → ContaboFull root access · public IPv4 · pick your region

Frequently asked questions

Why a netfilter kill switch over an app-level one?
App-level kill switches (Mullvad app, NordVPN app) stop the VPN app's traffic, but if it crashes or is OS-killed, traffic leaks. Netfilter applies rules in the kernel - no bypass without root.
Does the kill switch cover IPv6?
Only if you add the matching ip6tables rules (this guide does). Without them, IPv6 traffic leaks by default on most recent distros.
Performance impact?
Negligible. Netfilter -j REJECT mode burns <0.05% CPU at 1 Gbps. No measurable throughput loss or added latency.