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 · Fondateur · VPNSmith — Spécialiste self-host VPN & VPS GDPR7 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.

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.

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

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.

★ Datacenter Nuremberg GDPR · ✓ IPv4 dédiée incluse · 200+ Mbps garantis

Get Contabo30 jours satisfait ou remboursé