VPNSmith
self-host-vpnINFO

Split-tunnel VPN with routing tables (ip-rule + ip-route) 2026

Linux split-tunnel via fwmark + ip rule: route apps through VPN vs ISP direct. Netflix bypass + work VPN cases. Setup scripts and best practices.

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

You want a VPN for work (corporate LAN access via tunnel) and at the same time Netflix seeing your French IP so it serves the FR catalog. Or you want Steam downloading direct (1 Gbps ISP) while everything else goes through VPN. Solution: split-tunnel via Linux routing tables, more powerful than WireGuard's AllowedIPs split. (New to the concept? Start with what split tunneling is and its trade-offs.)

This guide implements the fwmark + ip rule + ip route pattern that routes specific applications through the VPN and others directly, without touching the WireGuard tunnel itself.

Why Linux routing-table split-tunnel beats AllowedIPs mode

The WireGuard AllowedIPs option is elegant when you want to exclude whole IP ranges from the tunnel - typically your local LAN (192.168.1.0/24) to keep NAS and printer access without going through the VPN. That's enough for 80% of home use. But as soon as you want to differentiate routing by application or by system user, AllowedIPs can't. Steam and Netflix don't have fixed IPs - they use CDNs with dynamic globally-distributed ranges. Manually maintaining the list of Steam IPs in AllowedIPs is unmanageable and breaks at every Akamai or Fastly CDN refresh.

fwmark routing solves this by marking packets as they leave the user process (via cgroup or uid), before the IP routing decision is made. The 200 (VPN) or main (direct ISP) routing table is chosen based on the mark, independent of destination. So you can say "all traffic from user steam exits via direct ISP, everything else goes through wg0" without ever listing an IP. That's what Mullvad does internally in their Linux app since 2022, and what Tor uses for their bridge mode.

The other advantage: the fwmark survives network changes. If you switch from home WiFi to mobile 4G without disconnecting, your split-tunnel stays operational because routing table 200 points at wg0 (the WireGuard interface, independent of the underlying physical interface). With AllowedIPs you wouldn't have an issue either, but with a classical netfilter kill switch you might end up with iptables rules referencing eth0 that doesn't exist on mobile 4G (interface becomes wwan0).

Concept in 4 steps

  1. Mark traffic from an app with iptables -j MARK (based on cgroup, user, or destination).
  2. Create an alternate routing table for marked traffic (e.g. table 200).
  3. Add an ip-rule saying "traffic with mark X → use table 200".
  4. Populate table 200 with a different default route (either wg0 or direct eth0).

Result: the kernel inspects every outgoing packet and picks table 200 (VPN) or main table (ISP direct).

Base setup

Prereq: a configured, active WireGuard tunnel. See the Contabo WireGuard setup guide if starting from scratch.

First check iproute2 is installed (should be on any recent distro):

ip -V
# iproute2-6.x.x

Case A - Exclude Steam from the VPN (direct ISP downloads)

Steam identifies itself via a cgroup when launched from systemd. We route its traffic via the ISP interface eth0 instead of wg0.

Step 1 - Create a cgroup for Steam

sudo mkdir -p /sys/fs/cgroup/net_cls/steam
sudo bash -c 'echo 0x42 > /sys/fs/cgroup/net_cls/steam/net_cls.classid'

Step 2 - Mark outbound packets from that cgroup

sudo iptables -t mangle -A OUTPUT -m cgroup --cgroup 0x42 -j MARK --set-mark 200

Step 3 - Create routing table 200 with ISP route

echo "200 isp_direct" | sudo tee -a /etc/iproute2/rt_tables

ISP_GW=$(ip route | awk '/default/ && /eth0/ {print $3}')
sudo ip route add default via $ISP_GW dev eth0 table isp_direct

sudo ip rule add fwmark 200 table isp_direct

Step 4 - Launch Steam in that cgroup

cgexec -g net_cls:steam /usr/games/steam

Or via systemd .slice for Flatpak / Steam Snap setups.

Steam now downloads direct ISP (up to 1 Gbps fiber) while the rest of your machine stays on the WireGuard tunnel.

Case B - Route Netflix via ISP for FR catalog

You want Netflix to see your French IP. Per-destination IP approach, since Netflix uses multiple CDNs.

Step 1 - Get Netflix BGP ranges

Netflix publishes its prefixes: AS2906. You can pull them with whois -h whois.radb.net -- '-i origin AS2906' or use a maintained list:

curl -s https://api.bgpview.io/asn/2906/prefixes | jq -r '.data.ipv4_prefixes[].prefix' > /tmp/netflix-ranges.txt

Step 2 - Mark traffic to those ranges

while read range; do
  sudo iptables -t mangle -A OUTPUT -d "$range" -j MARK --set-mark 200
done < /tmp/netflix-ranges.txt

Step 3 - Use the same isp_direct table (already set up in case A).

Next netflix.com, DNS resolves, TCP packet leaves with mark 200, kernel sends via direct eth0, Netflix sees your French IP and serves the right catalog.

Case C - Reverse: force only Slack/Zoom through VPN

Source code in a terminal editor
Source code in a terminal editor

You work from abroad, you want work traffic (Slack, Zoom, GitHub) through the corp VPN, everything else direct.

Symmetric setup:

  • Mark default = direct
  • Mark work apps = via VPN
echo "201 work_vpn" | sudo tee -a /etc/iproute2/rt_tables
sudo ip route add default dev wg0 table work_vpn
sudo ip rule add fwmark 201 table work_vpn

# Marker for Slack (by destination)
for d in $(dig +short slack.com slack-edge.com app.slack.com); do
  sudo iptables -t mangle -A OUTPUT -d "$d" -j MARK --set-mark 201
done

# Zoom (ranges Zoom publishes in their admin docs)
sudo iptables -t mangle -A OUTPUT -d 3.7.35.0/25 -j MARK --set-mark 201
sudo iptables -t mangle -A OUTPUT -d 3.21.137.128/25 -j MARK --set-mark 201
# ... complete with official Zoom list

Important: main-table default route must point at eth0 (ISP direct), not wg0. Check with ip route show table main.

Practical: full split-tunnel.sh script

To avoid re-typing at every boot, here's an idempotent script wired to systemd:

#!/usr/bin/env bash
# /usr/local/bin/split-tunnel.sh
set -euo pipefail

ISP_IF="eth0"
VPN_IF="wg0"

ISP_GW=$(ip route | awk -v iface="$ISP_IF" '/default/ && $0 ~ iface {print $3; exit}')

grep -q '^200 isp_direct' /etc/iproute2/rt_tables || echo "200 isp_direct" >> /etc/iproute2/rt_tables
grep -q '^201 work_vpn'   /etc/iproute2/rt_tables || echo "201 work_vpn" >> /etc/iproute2/rt_tables

ip route show table isp_direct | grep -q default || ip route add default via "$ISP_GW" dev "$ISP_IF" table isp_direct
ip route show table work_vpn   | grep -q default || ip route add default dev "$VPN_IF" table work_vpn

ip rule show | grep -q "fwmark 0xc8 lookup isp_direct" || ip rule add fwmark 200 table isp_direct
ip rule show | grep -q "fwmark 0xc9 lookup work_vpn"   || ip rule add fwmark 201 table work_vpn

mkdir -p /sys/fs/cgroup/net_cls/steam
echo 0x42 > /sys/fs/cgroup/net_cls/steam/net_cls.classid
iptables -t mangle -C OUTPUT -m cgroup --cgroup 0x42 -j MARK --set-mark 200 2>/dev/null \
  || iptables -t mangle -A OUTPUT -m cgroup --cgroup 0x42 -j MARK --set-mark 200

echo "Split-tunnel rules applied."

systemd unit:

# /etc/systemd/system/split-tunnel.service
[Unit]
Description=Split-tunnel routing rules
After=wg-quick@wg0.service
Wants=wg-quick@wg0.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/split-tunnel.sh
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now split-tunnel.service

Case D - Split by Linux user

You want all traffic from user kid (your kid's account) through the VPN with parental filtering, and your main eric direct.

sudo iptables -t mangle -A OUTPUT -m owner --uid-owner kid -j MARK --set-mark 201
# Mark 201 routed via VPN (configured above)

Every curl, firefox, apt launched under UID kid exits via wg0. Handy too for a test browser (Chromium in another user) that always shows a different VPN IP.

Diagnostic and debug

Show active rules

ip rule show
# Should show your fwmark 200/201 rule with the right priority
ip route show table isp_direct
# Should show default via X dev eth0
ip route show table work_vpn
# Should show default dev wg0

Trace a packet's routing

# From the app shell to debug (or wrap in cgexec)
ip route get 1.1.1.1 mark 200
# Should show "via 192.168.1.1 dev eth0 table isp_direct"

ip route get 1.1.1.1 mark 201
# Should show "dev wg0 table work_vpn"

See the mark in tcpdump

sudo tcpdump -i any -nn -e 'iptables' 2>/dev/null
# Or simpler:
sudo nft monitor trace

Mark not applied?

Check iptables chain order. The mangle table OUTPUT chain runs before routing. If your rule shows in iptables -t mangle -L OUTPUT -v -n but the mark doesn't stick, it's usually an uninherited cgroup (launch via explicit cgexec).

Limits and gotchas

IPv6

Every command above is IPv4 only. For IPv6, mirror with:

sudo ip6tables -t mangle -A OUTPUT ...
sudo ip -6 rule add fwmark 200 table isp_direct
sudo ip -6 route add default via FE80::1 dev eth0 table isp_direct

If you also use our kill switch guide, remember the kill switch OUTPUT DROP rules can block direct split-tunnel traffic. Fix: whitelist marks 200/201 from the global DROP.

DNS leak in split-tunnel

If you split by destination IP, DNS is still resolved by your main DNS. If that DNS is in the tunnel, Netflix may resolve to a UK IP when you wanted FR. Fix: configure a direct DNS for split domains (dnsmasq with server-specific resolvers).

Kernel caches routes

After modifying an ip rule, run:

sudo ip route flush cache

Else already-open connections keep their old routing.

Docker / containers compatibility

Docker manipulates iptables aggressively. If you cohabit split-tunnel + Docker, expect rules to be overwritten on Docker restart. Fix: launch split-tunnel.service with After=docker.service or use nftables over iptables.

Verdict

Split-tunnel via fwmark + ip rule is the most flexible solution on Linux. You can route by destination, user, cgroup, process. Cost: ~10 lines of config and a systemd service. Far superior to WireGuard's AllowedIPs split which only filters by destination IP.

Recommended home setup:

  • VPN default for everything
  • Steam, Netflix, Time Machine → marked 200 → direct ISP
  • Netfilter kill switch with mark 200 exception

Recommended remote-work setup:

  • Direct ISP default
  • Slack, Zoom, GitHub Enterprise, jumphost → marked 201 → via corp VPN

And for the foundation: WireGuard on Contabo guide, paste-ready templates, and Prometheus + Grafana monitoring to track what exits which interface in real-time.

Before you build the split-tunnel, use our WireGuard config generator to get the base wg0.conf right - it sets AllowedIPs and PostUp correctly out of the box so you can focus on the routing-table layer. And if you're deciding between self-hosting and a commercial VPN on budget grounds, our self-hosted VPN cost calculator does the 3-year math for you.

★ 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

How does this differ from WireGuard's AllowedIPs split-tunnel?
AllowedIPs filters by destination IP - useful to exclude local LAN. fwmark + ip rule filters by user, process, or cgroup - useful to exclude a specific app (Steam, Netflix) or Docker container.
Does fwmark split-tunnel leak my real IP?
Yes, by design - that's the point. Marked apps exit via ISP direct, so Netflix sees your French IP and serves the FR catalog. For zero leak, use full tunnel + kill switch.
Does split-tunnel work with NetworkManager?
Yes, but NetworkManager may rewrite the routing table at every network change. Put ip-rule rules in a systemd dispatcher service (NetworkManager-dispatcher) or wrap them in a script run on each WiFi connect.