Split tunneling is what you do when “VPN everything” breaks something you actually use: your local printer, your office Wi‑Fi captive portal, your video calls,
your cloud IDE, your latency budget, or your sanity. The trick is making WireGuard carry only the traffic that needs to be private or reachable,
while everything else stays on your normal network—without accidentally creating leaks, blackholes, or weird half-working DNS.
If you’ve ever watched a developer “fix” split tunneling by setting AllowedIPs = 0.0.0.0/0 and calling it done, you already know how this ends:
a Monday morning incident with lots of shrugging and one very confident wrong assumption.
The mental model: what split tunneling really is in WireGuard
WireGuard is a VPN, but it behaves more like a “secure virtual Ethernet cable with a routing brain” than a classic full-tunnel appliance.
There’s no negotiation of “push routes” like some legacy VPN stacks; WireGuard mostly does what you tell it locally: you create an interface,
you add peers, and then your OS routes packets into that interface based on ordinary routing rules.
Split tunneling is not a WireGuard feature as much as it is a routing decision:
- Full tunnel: most or all traffic uses the VPN interface (default route via
wg0). - Split tunnel: only certain destination prefixes (subnets/IPs) use the VPN; everything else uses the normal default route.
- Selective + policy routing: certain source apps/users/marks use the VPN; others don’t (more advanced, very useful).
WireGuard’s configuration ties identity and routing together with AllowedIPs on the peer stanza. That parameter both:
(1) defines which destination IPs should be sent to that peer, and (2) defines which source IPs are considered valid from that peer.
It’s elegant. It’s also the reason a “small” change can quietly rewrite your routing table.
The reliable way to think about it is:
“When I send a packet to X, which peer claims X in AllowedIPs?”
If the answer is “none,” it goes out your normal interface. If the answer is “a peer,” it goes into WireGuard.
If the answer is “more than one,” you built a foot-gun and the kernel will pick a “best match” that may not be your idea of best.
One dry truth: split tunneling often fails not because WireGuard is hard, but because routing is unforgiving and DNS is petty.
Interesting facts and a little history (because it matters)
Concrete context points that explain why WireGuard split tunneling looks the way it does:
- WireGuard was designed to be small. The Linux kernel implementation is famously compact compared to older VPN stacks, which reduces attack surface and surprises.
- It uses modern crypto choices by default. You don’t spend your day debating cipher suites; you spend it on routing and operations instead (a better use of oxygen).
- “AllowedIPs” is both routing and access control. That’s not typical in many VPNs, where route injection and ACLs are separate. It’s powerful and sharp-edged.
- WireGuard relies on UDP. Great for performance; occasionally annoying in networks that “helpfully” block or mangle UDP.
- There’s no built-in concept of “server” vs “client.” Every peer is just a peer. Your org chart does not impress the protocol.
- NAT traversal is a first-class citizen. Roaming clients work well because WireGuard tracks endpoints and can update them on the fly after a valid packet.
- wg-quick is intentionally opinionated. It adds routes, sets up firewall bits (depending on your config hooks), and tries to get you to “working” quickly—sometimes too quickly for complex split tunnels.
- Android/iOS split tunnel UX came later than the protocol. The protocol never cared; the clients had to grow toggles and exclusion lists over time.
- Split tunneling is older than WireGuard. Enterprises have been doing “route only corp subnets” since the early IPsec days; the difference is WireGuard makes the route list explicit and local.
AllowedIPs: the lever that moves routes (and sometimes your soul)
On the client side, split tunneling is usually just “don’t install a default route via wg0.”
Translation: don’t put 0.0.0.0/0 (and ::/0) into AllowedIPs.
Put only the subnets you actually need.
Example: you want access to internal services at 10.40.0.0/16 and a private database at 172.20.10.5/32.
Your peer config becomes:
cr0x@server:~$ cat /etc/wireguard/wg0.conf
[Interface]
Address = 10.99.0.2/32
PrivateKey = <client-private-key>
DNS = 10.40.0.53
[Peer]
PublicKey = <server-public-key>
Endpoint = vpn-gw.example:51820
AllowedIPs = 10.40.0.0/16, 172.20.10.5/32
PersistentKeepalive = 25
The important part is what wg-quick does with this: it installs kernel routes for those prefixes via wg0.
Everything else uses your default route (usually your Wi‑Fi or Ethernet gateway).
What about “AllowedIPs = 10.99.0.0/24” on the server?
Server-side AllowedIPs is about what IPs the server will accept from a peer and what it will route back to that peer.
For a typical road-warrior setup, each client gets a single /32 inside the VPN (like 10.99.0.2/32) and the server’s peer stanza lists that /32.
That way, the server knows where to send return traffic for that client’s VPN address.
For site-to-site, server-side AllowedIPs can include whole LAN subnets behind the peer, but then you’re building a real routed network.
Do that intentionally. Document it. Treat it like production routing, because it is.
Joke #1 (short, relevant): Routing is like office politics—everything works until someone claims they own the whole building.
Design patterns for real split tunnels
Pattern A: “Corporate subnets only” (classic split tunnel)
You route only RFC1918 internal ranges that are actually used by corporate services. Not 10.0.0.0/8 unless you mean it.
Be precise. People love to squat on IP space like it’s free real estate.
Good:
AllowedIPs = 10.40.0.0/16, 10.41.12.0/24
Suspicious:
AllowedIPs = 10.0.0.0/8(you probably don’t own all of it)AllowedIPs = 0.0.0.0/0(that’s full tunnel, no matter what you call it)
Pattern B: “One service only” (tightest split)
You route a handful of /32s (or small subnets) for the exact services needed: a Git host, a secrets manager, an internal API gateway.
Great for contractors, CI runners, and “I don’t trust that laptop” situations.
The trade-off: you must keep the list current. IPs change, services move, someone adds a new region, and suddenly “VPN is down.”
It’s not down. Your routing is stale.
Pattern C: “Policy routing split tunnel” (source-based)
Sometimes you want traffic from a specific user or container network to go through WireGuard, while the rest of the host stays local.
That’s not primarily AllowedIPs; that’s Linux policy routing (ip rule + custom tables) and maybe fwmarks.
wg-quick can do some of this, but many production setups go explicit.
Pattern D: “Exclude local LAN” (full-ish tunnel with exceptions)
Some clients (especially mobile) want full tunnel for privacy, but still need local LAN access (printers, Chromecasts, NAS, whatever).
That’s “default route via VPN, but add explicit routes for local subnets via the local gateway.”
It can work. It can also create weird asymmetry when local devices try to reply to your VPN-assigned source IP. If you do this,
use NAT on the client or avoid sourcing LAN traffic from the VPN address.
DNS in split tunnels: don’t leak, don’t break
DNS is where split tunnels go to die quietly. You route 10.40.0.0/16 correctly, but your client still asks its coffee-shop resolver
for jira.corp. Of course it fails. Or worse: the public resolver returns something unexpected, and you connect to the wrong thing.
Split tunnel DNS strategies, in increasing order of operational maturity:
1) “Just set DNS = corp resolver”
Simple: DNS = 10.40.0.53 in the WireGuard interface config. When the tunnel is up, your resolver becomes corp.
Downside: it may break local name resolution (hotel captive portal domains, split-horizon quirks), and it can slow down everything if the corp resolver is far away.
2) Split DNS (route only certain domains to corp)
On Linux with systemd-resolved, you can configure per-interface routing domains. That means *.corp goes to the VPN resolver,
and everything else stays local. This is the sweet spot for many orgs.
3) Local stub resolver that forwards corp zones via VPN
You run a local resolver (or use an existing one) and forward internal zones to the VPN DNS server, with caching and sane timeouts.
It’s boring. It works. It also makes debugging less theatrical.
A reliability paraphrased idea from John Allspaw: “You don’t get reliability by hoping; you get it by designing and practicing for failure.”
Split tunnel DNS is a perfect example—design it, don’t wish it into existence.
Practical tasks (commands, outputs, decisions)
This is the part where we stop admiring the protocol and start interrogating the system.
Every task below includes: a command, realistic output, what it means, and what you decide next.
Task 1: Confirm the tunnel is actually up (and not “configured but dead”)
cr0x@server:~$ sudo wg show
interface: wg0
public key: 2pQ2c2fWmGm6l5qgkq8n8fZ4xX9o7u1nJt4XxR9gQhE=
private key: (hidden)
listening port: 51820
peer: Hx2e1F0rQyQH7cQqX2o3v9KQ0m7E4uE3r4Y8z8u4L0w=
endpoint: 203.0.113.50:53211
allowed ips: 10.99.0.2/32
latest handshake: 42 seconds ago
transfer: 88.31 MiB received, 12.04 MiB sent
persistent keepalive: every 25 seconds
Meaning: Handshake is recent and bytes are moving. Cryptography is fine; focus on routing/DNS/MTU if apps still fail.
Decision: If latest handshake is “never” or very old, skip ahead to firewall/NAT/endpoint checks before touching routes.
Task 2: Inspect what routes wg-quick installed
cr0x@server:~$ ip route show table main | grep wg0
10.40.0.0/16 dev wg0 proto static scope link
172.20.10.5 dev wg0 proto static scope link
Meaning: Only the intended prefixes route via wg0. That’s a clean split tunnel.
Decision: If you see default dev wg0 and you didn’t want full tunnel, fix AllowedIPs immediately.
Task 3: Verify the default route stays local
cr0x@server:~$ ip route show default
default via 192.168.1.1 dev wlan0 proto dhcp metric 600
Meaning: Internet traffic remains on wlan0. That’s the entire point of split tunneling.
Decision: If default points to wg0, confirm whether you actually intended full tunnel and whether local LAN exceptions exist.
Task 4: Check which route the kernel will pick for a specific destination
cr0x@server:~$ ip route get 10.40.12.34
10.40.12.34 dev wg0 src 10.99.0.2 uid 1000
cache
Meaning: Packets to 10.40.12.34 will go through wg0, sourced from 10.99.0.2.
Decision: If it says dev wlan0, your split tunnel isn’t installed, or the route is being overridden by a more specific route.
Task 5: Validate DNS configuration on systemd-resolved systems
cr0x@server:~$ resolvectl status wg0
Link 8 (wg0)
Current Scopes: DNS
Protocols: +DefaultRoute
Current DNS Server: 10.40.0.53
DNS Servers: 10.40.0.53
Meaning: wg0 has a DNS server assigned. Depending on your setup, it may become default for all queries.
Decision: If internal names fail while routes are correct, configure split DNS (routing domains) rather than forcing all DNS through corp.
Task 6: Confirm internal name resolution goes where you expect
cr0x@server:~$ resolvectl query jira.corp
jira.corp: 10.40.12.80 -- link: wg0
-- Information acquired via protocol DNS in 22.4ms.
-- Data is authenticated: no
Meaning: The query was answered via wg0 DNS, returning an internal IP.
Decision: If it resolves via wlan0 to a public IP or NXDOMAIN, you have a DNS routing problem, not a WireGuard problem.
Task 7: Prove the path with a targeted curl and interface binding
cr0x@server:~$ curl -sS --interface wg0 -o /dev/null -w "HTTP %{http_code}\n" http://10.40.12.80/
HTTP 200
Meaning: The service is reachable over wg0 and responds. Your app problem is likely DNS name selection, proxy settings, or auth.
Decision: If binding to wg0 fails but unbound works (or vice versa), you’ve uncovered a routing asymmetry or local proxy behavior.
Task 8: Check for MTU pain (the silent killer of “it works for small stuff”)
cr0x@server:~$ ping -M do -s 1420 -c 3 10.40.12.80
PING 10.40.12.80 (10.40.12.80) 1420(1448) bytes of data.
1428 bytes from 10.40.12.80: icmp_seq=1 ttl=63 time=38.2 ms
1428 bytes from 10.40.12.80: icmp_seq=2 ttl=63 time=38.6 ms
1428 bytes from 10.40.12.80: icmp_seq=3 ttl=63 time=38.1 ms
--- 10.40.12.80 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
Meaning: A 1420-byte payload with DF set is working. MTU is probably not your issue at this size.
Decision: If you see “Frag needed,” lower the WireGuard interface MTU (common values: 1280–1420) and retest.
Task 9: Inspect the interface MTU and counters
cr0x@server:~$ ip -s link show dev wg0
8: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1420 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/none
RX: bytes packets errors dropped missed mcast
98123456 74231 0 12 0 0
TX: bytes packets errors dropped carrier collsns
13200456 52122 0 3 0 0
Meaning: Some drops exist. A few drops can be normal; persistent growth under load can signal MTU, queueing, or firewall issues.
Decision: If drops climb fast while transfers stall, test lower MTU and check for UDP filtering.
Task 10: Confirm IP forwarding and NAT on the gateway (site-to-site or road-warrior via server)
cr0x@server:~$ sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 1
Meaning: The server can route between interfaces.
Decision: If it’s 0, clients may handshake but won’t reach internal networks behind the server. Turn it on and make it persistent.
Task 11: Validate NAT/forward rules (iptables example)
cr0x@server:~$ sudo iptables -t nat -S | grep -E "POSTROUTING|wg0"
-A POSTROUTING -s 10.99.0.0/24 -o eth0 -j MASQUERADE
Meaning: VPN clients (10.99.0.0/24) are NATed when exiting via eth0. This is common for road-warrior access to private networks.
Decision: If you expect routed (no NAT) behavior, remove MASQUERADE and ensure internal routers know how to reach the VPN subnet.
Task 12: See whether policy routing or marks are involved (Linux advanced split tunnel)
cr0x@server:~$ ip rule show
0: from all lookup local
32764: from all fwmark 0xca6c lookup 51820
32766: from all lookup main
32767: from all lookup default
Meaning: Traffic marked with 0xca6c uses routing table 51820 (common wg-quick behavior in some setups).
Decision: If only some applications should use the tunnel, mark those flows intentionally (cgroups, iptables mangle) and keep the rest unmarked.
Task 13: Inspect the WireGuard peer’s AllowedIPs and endpoints on the client
cr0x@server:~$ sudo wg show wg0 peers
Hx2e1F0rQyQH7cQqX2o3v9KQ0m7E4uE3r4Y8z8u4L0w=
cr0x@server:~$ sudo wg show wg0 peer Hx2e1F0rQyQH7cQqX2o3v9KQ0m7E4uE3r4Y8z8u4L0w=
peer: Hx2e1F0rQyQH7cQqX2o3v9KQ0m7E4uE3r4Y8z8u4L0w=
endpoint: 198.51.100.22:51820
allowed ips: 10.40.0.0/16, 172.20.10.5/32
latest handshake: 1 minute, 10 seconds ago
transfer: 12.04 MiB received, 88.31 MiB sent
Meaning: Your split prefixes are present. Endpoint is known and handshake is live.
Decision: If AllowedIPs contains a broad range you didn’t intend, fix config first. Debugging anything else is wasting time.
Task 14: Catch leaks and wrong-path traffic with tcpdump (fast truth)
cr0x@server:~$ sudo tcpdump -ni wg0 host 10.40.12.80 and port 443 -c 3
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on wg0, link-type RAW (Raw IP), snapshot length 262144 bytes
IP 10.99.0.2.54422 > 10.40.12.80.443: Flags [S], seq 18273612, win 64240, options [mss 1360,sackOK,TS val 102030 ecr 0,nop,wscale 7], length 0
IP 10.40.12.80.443 > 10.99.0.2.54422: Flags [S.], seq 23011222, ack 18273613, win 65160, options [mss 1360,sackOK,TS val 556677 ecr 102030,nop,wscale 7], length 0
IP 10.99.0.2.54422 > 10.40.12.80.443: Flags [.], ack 1, win 502, options [nop,nop,TS val 102031 ecr 556677], length 0
3 packets captured
Meaning: The TCP handshake is happening over wg0. Routing is correct for that flow.
Decision: If you see no packets on wg0 but you expect them, check ip route get and policy routing rules.
Task 15: Detect when the underlay blocks UDP (symptom: handshake never happens)
cr0x@server:~$ sudo tcpdump -ni eth0 udp port 51820 -c 5
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 203.0.113.50.53211 > 198.51.100.22.51820: UDP, length 148
IP 203.0.113.50.53211 > 198.51.100.22.51820: UDP, length 148
IP 203.0.113.50.53211 > 198.51.100.22.51820: UDP, length 148
5 packets captured
Meaning: Packets arrive at the server. If handshakes still don’t happen, the server may be dropping replies or keys don’t match.
Decision: If you see nothing arriving, the network path blocks UDP or the endpoint/port is wrong. Fix reachability before touching routing.
Task 16: Confirm reverse path routing isn’t dropping replies (Linux rp_filter)
cr0x@server:~$ sysctl net.ipv4.conf.all.rp_filter
net.ipv4.conf.all.rp_filter = 1
Meaning: Strict-ish reverse path filtering may drop asymmetric traffic (common when mixing VPN and multiple uplinks).
Decision: If you’re doing advanced policy routing, consider setting rp_filter to loose mode (2) on relevant interfaces after understanding the security trade-off.
Fast diagnosis playbook
When split tunneling “kind of works,” your time gets eaten by the wrong layer. Here’s the order that usually finds the bottleneck fastest.
It’s biased toward production reality: most failures are config drift, routing surprises, DNS, or MTU—not crypto.
First: is the tunnel alive?
- Check:
wg showon both sides. - Signal: Recent handshake + increasing transfer counters.
- If bad: verify endpoint reachability, firewall rules, UDP path, correct keys, and time (clock skew usually isn’t fatal here, but don’t run antique NTP).
Second: is routing doing what you think?
- Check:
ip routeandip route get <target>. - Signal: Destinations you care about go via wg0; default route stays local.
- If bad: fix
AllowedIPsfirst; then consider policy routing rules or competing routes from NetworkManager/VPN clients.
Third: does DNS follow the split?
- Check:
resolvectl queryfor internal names, and which link answered. - Signal: Internal zones resolve via wg0 DNS or split DNS rules; public names resolve locally as intended.
- If bad: implement split DNS (routing domains) or run a local stub resolver and forward internal zones.
Fourth: is MTU making large transfers fail?
- Check: DF pings at 1280/1380/1420 and compare “small works, large stalls”.
- Signal: No fragmentation-needed errors; stable throughput.
- If bad: lower wg0 MTU; verify path MTU on underlay; look for PPPoE or encapsulation overhead.
Fifth: are firewalls and NAT rules consistent with your story?
- Check: forwarding, NAT (if used), and that internal routers know return routes (if not NATing).
- Signal: return traffic reaches clients; no asymmetric drops by rp_filter.
- If bad: either NAT intentionally, or add correct routes in the internal network; don’t half-do both.
Three corporate mini-stories from the trenches
1) Incident caused by a wrong assumption: “AllowedIPs is just an ACL”
A mid-sized company rolled out WireGuard to replace an aging remote access VPN. The pilot group was mostly developers,
and it went smoothly—until the finance team joined, along with a handful of third-party SaaS integrations that used fixed IP allowlists.
One engineer assumed AllowedIPs was purely an “access control list” on the peer—like “what the client is allowed to reach.”
They added 0.0.0.0/0 on the client because “it’s safe; we only NAT corp subnets anyway.”
The result was an accidental full tunnel: everyone’s outbound internet started going through the corporate egress.
It didn’t fail loudly. It failed socially: latency to common sites increased, random geolocation checks started flagging logins,
and an internal IDS began seeing traffic patterns it wasn’t sized for. The finance team’s web-based accounting system started rate-limiting
because requests now came from a smaller set of shared NAT IPs.
The technical diagnosis was straightforward: default route pointed to wg0, and DNS had been set to internal resolvers.
The “VPN is slow” complaint was real, but the root cause was not WireGuard performance—it was an unintentional architecture change.
The fix was boring: remove 0.0.0.0/0, route only corp prefixes, and keep SaaS traffic local.
The lasting lesson: in WireGuard, AllowedIPs is routing. Treat it like you’re editing the routing table on every laptop—because you are.
2) Optimization that backfired: “Let’s squeeze MTU for speed”
Another org had a global workforce and a WireGuard gateway in one region. They noticed some flaky file uploads to an internal artifact store.
Someone remembered “MTU issues” from an old VPN and decided to “optimize” by dropping MTU aggressively to 1200 on all clients.
The rationale: smaller packets, fewer fragmentation issues, fewer retransmits. It sounded plausible in a meeting.
What actually happened: interactive traffic got slightly more stable, but throughput cratered for bulk transfers, especially on high-latency links.
CPU usage on both clients and gateway increased due to more packets per megabyte. Some endpoints hit rate limits or queue drops because the packet rate was higher.
The artifact store didn’t just feel slower—it started timing out on large publishes.
The tricky part was that “it works” remained true for small tests: you could curl a small endpoint, ping worked, SSH felt fine.
It was only when a build pipeline pushed hundreds of megabytes that the pain showed up.
The fix: stop guessing. They ran DF pings to find an MTU that worked end-to-end (often 1380–1420 depending on underlay),
set MTU to a sane value, and then reduced upload retries by improving timeouts rather than compressing the network into tiny packets.
The “optimization” became a postmortem footnote about cargo-cult tuning.
3) Boring but correct practice that saved the day: “We keep a route inventory”
A regulated enterprise with multiple subsidiaries ran a WireGuard-based access layer to reach a handful of internal subnets.
Their network was a patchwork: overlapping RFC1918 ranges, mergers, and a few “temporary” NATs that had outlived several managers.
It was the kind of place where “just route 10/8” is a career-limiting move.
The operations team maintained a simple route inventory: which internal prefixes exist, who owns them, which ones are reachable via VPN,
and which ones must never be routed over remote access due to data classification.
It wasn’t glamorous. It lived in version control. It got reviewed like code.
When a new business unit requested access, the team added two specific /24s to AllowedIPs and updated DNS routing domains.
Two weeks later, a different team accidentally introduced an overlapping subnet in a lab that would have hijacked traffic if broad prefixes were used.
Because the VPN routing was narrow and documented, nothing leaked, and nothing broke.
The incident that could have happened didn’t happen. The best operational wins are often invisible and deeply unsexy.
Also, the team slept normally, which is a forgotten SRE objective.
Common mistakes: symptoms → root cause → fix
1) Symptom: Internet traffic unexpectedly goes through the VPN
Root cause: Client AllowedIPs includes 0.0.0.0/0 and/or ::/0, installing a default route via wg0.
Fix: Replace with specific prefixes. If you need “mostly full tunnel,” add explicit exceptions for local LAN and verify return paths.
2) Symptom: “Handshake works, but I can’t reach internal services”
Root cause: Server isn’t forwarding packets (ip_forward=0), or internal network doesn’t have routes back to the VPN subnet.
Fix: Enable forwarding and either NAT on the gateway or add proper routes on internal routers. Pick one design and stick to it.
3) Symptom: Internal hostnames don’t resolve, but IPs work
Root cause: DNS still points to local resolvers; internal zones aren’t forwarded via VPN.
Fix: Configure split DNS (per-interface routing domains) or set VPN DNS when connected. Verify with resolvectl query.
4) Symptom: Some internal sites load, others hang on login or large downloads
Root cause: MTU/PMTUD problems, often due to underlay encapsulation or blocked ICMP “fragmentation needed.”
Fix: Lower wg0 MTU; test with DF pings; if you control firewalls, allow necessary ICMP types.
5) Symptom: Local LAN devices stop working when VPN is up
Root cause: Full tunnel without LAN exceptions, or DNS changes causing local names to resolve differently, or rp_filter drops on multi-homed systems.
Fix: Keep split tunnel narrow; add explicit LAN routes via local gateway if needed; consider loose rp_filter in advanced routing scenarios.
6) Symptom: Two peers “fight” and traffic goes to the wrong place
Root cause: Overlapping AllowedIPs across peers. Longest-prefix match chooses a peer you didn’t intend, or route flaps when configs change.
Fix: Make AllowedIPs non-overlapping; if you must overlap, use more-specific prefixes deliberately and document ownership.
7) Symptom: VPN works on home Wi‑Fi but not on hotel/airport networks
Root cause: UDP blocked or captive portal interfering; endpoint unreachable until portal is accepted.
Fix: Keep a non-VPN path to reach the portal; consider a fallback port or transport strategy on your gateway; debug with tcpdump on server.
Joke #2 (short, relevant): Captive portals are the only systems that can bring down both networking and optimism with the same splash page.
Checklists / step-by-step plan
Plan 1: Build a clean split tunnel for “corp subnets only”
-
Inventory what you actually need.
List internal prefixes and internal DNS zones. If you can’t list them, you’re not ready to do this safely. -
Assign stable VPN client addresses.
Prefer /32 per client (IPv4) and /128 per client (IPv6) to keep routing unambiguous. -
Client config: keep AllowedIPs narrow.
Include only internal service subnets and/or specific /32s. -
Server config: accept only the client’s VPN IP(s).
In the server peer stanza, use the client’s VPN /32. Don’t accept broad ranges unless it’s site-to-site. -
Routing on server: choose NAT or routed returns.
- If NAT: MASQUERADE VPN subnet out to the internal interface.
- If routed: advertise the VPN subnet into the internal routing domain.
-
DNS: decide between “all DNS via VPN” vs split DNS.
If users need local browsing unaffected, implement split DNS. Otherwise you’ll get “VPN breaks my printer portal” tickets forever. -
MTU: set sane defaults and test.
Start with 1420. If you’re on PPPoE or nested tunnels, expect to go lower. -
Log and observe.
Keep an eye on handshake times, transfer counters, and interface drops during rollout.
Plan 2: Policy-routing split tunnel (advanced, worth it for shared hosts)
- Create wg0 without installing broad routes. Use narrow AllowedIPs or manual route management.
- Create a dedicated routing table for VPN traffic. Example: table 51820.
- Add routes to table 51820 for corp prefixes via wg0.
- Mark traffic that should use the VPN. Use iptables/nftables mangle or cgroup-based marking.
- Add ip rules mapping fwmark to the VPN table.
- Test with ip route get and tcpdump. Don’t “assume” you marked what you think you marked.
- Document the policy. Six months later, you’ll forget why some traffic bypasses the tunnel, and future-you will be annoyed.
Plan 3: “Mostly full tunnel but keep local LAN” (do this carefully)
- Use full-tunnel AllowedIPs (
0.0.0.0/0,::/0) only if you truly want it. - Add explicit routes for local subnets (like
192.168.0.0/16or your actual LAN) via the local gateway. - Verify return traffic. Some local devices won’t reply to your VPN source IP; you may need NAT for LAN-destined traffic or careful source selection.
- Test printing, casting, and local discovery. Multicast/broadcast discovery often won’t traverse VPN anyway; manage expectations.
FAQ
1) Is split tunneling “less secure” than full tunnel?
It depends on your threat model. Split tunneling keeps non-corporate traffic off your VPN, reducing load and blast radius.
But it also means the client is simultaneously on the public network and the corporate network.
If you need strong egress control, DLP, or consistent inspection, full tunnel may be justified—just do it intentionally.
2) What exactly does AllowedIPs do on the client?
It’s the routing selector: destinations matching AllowedIPs go into the WireGuard tunnel to that peer.
It also acts as a filter for which source addresses are acceptable from that peer. It is not “just an ACL.”
3) Why does my tunnel connect but I can’t reach anything behind the server?
Usually forwarding or return routing. The handshake proves keys and reachability to the endpoint, not that your internal network knows how to get back.
Check net.ipv4.ip_forward, NAT rules (if you’re NATing), or internal routes back to the VPN subnet.
4) How do I keep internet traffic local but still use internal DNS?
Use split DNS: route only internal zones to the VPN resolver and keep everything else on the local resolver.
On systemd-resolved, this is done with per-link DNS servers and routing domains.
5) Can I split tunnel by application (only my browser through VPN)?
Not natively with WireGuard alone. You do it with OS features: policy routing, packet marking, and sometimes per-app VPN on mobile platforms.
On Linux, combine ip rule with fwmarks and routes in a custom table.
6) Why do some corporate apps break only when VPN is up?
Common causes: DNS changes (split-horizon), proxy auto-config behavior, or routing overlap (your VPN routes hijack a subnet used by a local network).
Verify with ip route get to the app endpoint and check which DNS link answers the query.
7) Do I need PersistentKeepalive for split tunneling?
If the client is behind NAT (common), yes—usually. Keepalive helps maintain the NAT mapping so inbound packets from the server can reach the client.
For always-on site-to-site with stable endpoints, you might not need it. For laptops on coffee-shop Wi‑Fi, you do.
8) Should I route all RFC1918 ranges through the VPN?
Avoid it unless you truly control them. Routing 10.0.0.0/8 through your VPN often collides with home networks, cloud VPCs, and lab environments.
Route only what you own and need. Be specific; your future self will thank you.
9) How do I debug “it works for ping but not for HTTPS”?
Start with MTU and firewall. ICMP might succeed while TCP stalls due to fragmentation issues. Test with DF pings and inspect with tcpdump.
Also check that the service IP you’re hitting is actually reachable and not changing via DNS.
Conclusion: next steps that won’t ruin your week
Split tunneling with WireGuard is simple in concept and surprisingly easy to get wrong in production, mostly because humans are optimistic about routing.
The winning strategy is not cleverness. It’s clarity: narrow prefixes, explicit DNS behavior, and verification with real tools.
Practical next steps:
- Decide the routing goal in one sentence (“only these subnets via VPN” or “everything via VPN except LAN”). Write it down.
- Audit AllowedIPs on every client profile. Remove broad ranges you can’t justify.
- Validate with two commands:
wg show(alive) andip route get(path truth). - Fix DNS deliberately: either accept “all DNS via VPN” or implement split DNS; don’t drift into it accidentally.
- MTU test once on representative networks and set a sane default; don’t “optimize” blindly.
- Document the prefixes like you would firewall rules. Because they are firewall rules with better PR.