VPN and Tunnels¶
A VPN client does three things to your machine: creates a virtual network interface, inserts routes into the routing table, and (usually) rewrites DNS configuration. Each of these changes is invisible unless you know where to look, and each can break assumptions that other tools (Docker, local services) rely on.
This page is more opinionated than most VPN documentation because most VPN documentation does not exist. The typical corporate VPN deployment is a subscription tool shoved onto people's machines with no explanation of what it does, no documentation of its failure modes, and no recourse when it breaks — because when it breaks, you cannot search for solutions to proprietary client behavior on Stack Overflow. The framework's position is that engineers should understand what their VPN does, why it's configured the way it is, and what tradeoffs they are absorbing. Your mileage will vary more on this page than any other in the framework — corporate VPN policy is one of the highest- variance areas in developer experience.
What a VPN client actually does¶
When a VPN connects, it typically:
- Creates a tunnel interface — a virtual NIC (
utunon macOS,wg0ortun0on Linux) that encrypts traffic and sends it over the real NIC to the VPN gateway. - Adds routes — either a default route (full tunnel: all traffic goes through the VPN) or specific subnet routes (split tunnel: only traffic to corporate ranges goes through).
- Configures DNS — points the system at the corporate DNS servers, either globally (full tunnel) or for specific domains (split DNS).
- Installs firewall rules — some clients add packet filter rules to enforce "kill switch" behavior (block all traffic if the tunnel drops).
The distinction between full tunnel and split tunnel is the single most consequential configuration choice. It determines whether your workstation is "on the internet" or "on the corporate network" while connected.
Full tunnel vs. split tunnel¶
| Full tunnel | Split tunnel | |
|---|---|---|
| Default route | VPN gateway | Unchanged (regular internet) |
| Corporate traffic | Through VPN | Through VPN |
| Internet traffic | Through VPN | Direct |
| DNS | Corporate servers for everything | Corporate for internal domains, system default for everything else |
| Latency | Higher for all traffic | Higher only for corporate traffic |
| Privacy | Corporate can inspect all traffic | Corporate sees only corporate traffic |
| Local services | Often unreachable (localhost routing breaks) | Usually fine |
| Container networking | Frequently breaks | Usually fine |
The framework's recommendation: split tunnel with split DNS,
unless compliance mandates full tunnel. Full tunnel is a blunt
instrument that breaks too many development workflows —
localhost access, container port bindings, local DNS resolution,
and LAN services all become casualties.
Most corporate VPN deployments default to full tunnel because it's simpler to audit. If your organization mandates full tunnel, the mitigations in the "Living with full tunnel" section below apply.
The bandwidth reality¶
A full-tunnel VPN routes all traffic through the corporate gateway. If you have a gigabit connection at home, your effective bandwidth is now whatever the VPN gateway and its upstream provide — often dramatically less, and always with added latency from the extra hop. Legacy OpenVPN tunnels compound this: the userspace encryption and TCP-over-TCP pathologies can bottleneck a gigabit link to a fraction of its capacity, even when you are not accessing any internal resource.
Unless you are actively reaching an internal host, a full-tunnel VPN is burning your bandwidth for no operational benefit. The organization is paying for the gateway capacity, and you are paying in developer experience. Split tunnel eliminates this by routing only internal-bound traffic through the VPN.
The cost nobody mentions¶
VPN infrastructure is not free. AWS Client VPN charges roughly $0.05 per active client connection per hour, plus a separate hourly charge per subnet (endpoint) association — so a full 8-hour workday of connection time runs about $0.40 per engineer before the association fee. Organizations that mandate always-on VPN are spending significant per-seat costs on gateway capacity that serves no purpose when the engineer is not accessing internal resources.
Some organizations monitor client connection metrics as a proxy for employee attendance or engagement — VPN "hours connected" appears in management dashboards alongside badge swipes and Slack activity. This is worth knowing. Whether or not your organization does this, the economics favor split-tunnel or on-demand connection over always-on full-tunnel.
A compliance note¶
This page documents how VPN routing and DNS work, including how
to override pushed configurations (split-tunnel client overrides,
route-nopull, manual DNS configuration). Take this information with
eyes wide open: subverting a corporate VPN or DNS policy likely
violates your employee handbook. Organizations configure full-tunnel
VPN and mandatory DNS for compliance and security reasons — some of
them good, some of them cargo-culted, but all of them enforceable as
employment policy.
The framework's position is not "ignore your corporate security team." It is: understand what the VPN does so you can have an informed conversation with your IT or security team about whether the current configuration serves its intended purpose, diagnose problems when the VPN breaks your workflow, and make deliberate choices about infrastructure you control (personal servers, side projects, home lab). If your corporate VPN is misconfigured, the right move is to document the impact and advocate for a better configuration through proper channels — not to silently subvert it.
MTU and fragmentation¶
The single most common "the VPN is mysteriously broken" failure is not
routes or DNS — it is MTU. It produces a signature that fools people for
hours: ping works, DNS resolves, small requests succeed, but large
transfers hang. SSH connects then freezes after the banner; a git
clone stalls at "Receiving objects"; an HTTPS page loads its HTML but
never its images; a psql query that returns a few rows is fine but one
that returns many rows wedges. Connectivity is fine. Packet size is
the problem.
What MTU is¶
The MTU (Maximum Transmission Unit) is the largest payload a network interface will send in a single packet, in bytes. Ethernet's standard MTU is 1500. Anything larger must be split into multiple packets — fragmentation — or rejected. MTU is a property of every hop along a path; the path MTU is the smallest MTU of any link between you and the destination, and it is what actually constrains your packets.
Why a tunnel lowers it¶
A VPN wraps every one of your packets inside another packet — it adds an outer IP header, plus the protocol's own encapsulation and encryption overhead. That wrapper consumes bytes that would otherwise carry your data. If the physical link is 1500 and WireGuard's overhead is ~60 bytes, the usable MTU inside the tunnel is ~1440. OpenVPN's overhead is larger and varies with cipher and transport (TCP vs UDP). The tunnel interface therefore has a smaller MTU than the physical NIC it rides on, and any packet sized for the physical link is now too big for the tunnel.
How it is supposed to be handled¶
Two mechanisms exist, and both can fail:
- Fragmentation — a router that receives an oversized packet can split it into fragments that each fit the next link's MTU, to be reassembled at the destination. This works but is costly, and IPv6 forbids in-flight fragmentation by routers entirely.
- Path MTU Discovery (PMTUD) — the sender marks packets "Don't Fragment" (DF). A router that can't forward an oversized DF packet drops it and sends back an ICMP "fragmentation needed" message telling the sender the correct MTU. The sender then lowers its packet size. This is the modern, correct mechanism — when the ICMP message gets through.
What breaks (the black hole)¶
PMTUD depends entirely on that ICMP "fragmentation needed" reply reaching the sender. Many firewalls — corporate and cloud alike — block all ICMP as a blunt security measure. Now the chain is broken: the oversized packet is dropped, the ICMP that would have said "send smaller" is also dropped, and the sender never learns anything. It keeps retransmitting the same too-large packet, which keeps getting silently discarded. This is an MTU black hole, and it is exactly why small packets succeed (they fit) while large ones vanish (they don't, and nobody is told). The connection does not error — it hangs.
Diagnosing it¶
Find the largest packet that survives the path with ping and the
don't-fragment flag, shrinking the payload until it stops failing:
If -s 1472 fails but a smaller size works, the path MTU is below 1500
and something is not signaling it — the black-hole signature.
Fixing it¶
In order of preference:
- Fix the firewall (the real fix). ICMP type 3 code 4 ("fragmentation needed") must be permitted for PMTUD to work. Blocking all ICMP is the root cause; allow this type. If it is corporate infrastructure, this is the configuration to advocate for — see the compliance note above.
-
Lower the tunnel interface MTU so packets are sized to fit before they hit the constrained hop:
For WireGuard, set it declaratively in the config instead so it survives reconnects:
-
MSS clamping — for TCP specifically, have the tunnel rewrite the Maximum Segment Size announced in the handshake so each end never sends a segment too big for the tunnel. This sidesteps PMTUD entirely for TCP (it does nothing for UDP-based protocols like QUIC or WireGuard's own transport). On Linux with nftables/iptables:
Many VPN servers offer this as a one-line option (OpenVPN's
mssfix, which is on by default in recent versions). If you control the server, prefer enabling it there over per-client MTU tuning.
The order matters: clamping and lowering MTU are mitigations that make the symptom disappear, but the honest fix is restoring the ICMP path that PMTUD needs. Reach for the mitigations when you do not control the firewall; advocate for the ICMP fix when you do.
The protocol landscape¶
OpenVPN / OpenVPN3 and AWS Client VPN¶
OpenVPN is what most engineers actually encounter. The legacy
openvpn daemon (OpenVPN 2.x) is still everywhere, but the modern
reality is OpenVPN3 — a rewritten client library with cleaner
session management and better platform integration. AWS Client VPN is
OpenVPN under the hood with AWS-specific authentication (SAML,
certificate-based, Active Directory) and a custom client that wraps
openvpn3.
The good: battle-tested protocol, extremely configurable, supports TCP fallback for restrictive networks (TCP port 443 works through almost any firewall). OpenVPN3 improves session lifecycle over the legacy daemon.
The bad on macOS: every OpenVPN-based client on macOS creates
virtual tunnel interfaces (utun). The critical problem is interface
lifecycle:
- Each connect/disconnect cycle allocates a new
utuninterface. - Disconnection does not always tear down the previous interface.
- Over days of usage,
ifconfigaccumulatesutun3,utun4, ...utun17— most of them dead. - Stale routing table entries pointing at dead interfaces accumulate in parallel.
- Eventually, reconnection fails because the client cannot allocate the interface it expects.
This is not an OpenVPN protocol problem — it's an implementation
problem in how some macOS clients manage the lifecycle of the utun
interfaces they create (leaving stale ones behind on disconnect). The
AWS Client VPN macOS app and Tunnelblick both exhibit this behavior.
(Modern Tunnelblick uses the built-in macOS utun interface, not a
kernel extension, for standard tun VPNs — so this is an interface
cleanup issue, not a kext-vs-Network-Extension one.) OpenVPN Connect
(the official OpenVPN3-based client) handles it better but not perfectly.
The bad on Linux: fewer problems. The tun device model is
well-understood by the kernel, ip link delete cleans up reliably,
and NetworkManager's OpenVPN plugin handles lifecycle correctly.
AWS Client VPN specifically:
- Pushes full-tunnel by default unless the VPN endpoint is configured for split tunnel (an admin decision, not a client decision).
- DNS configuration is pushed from the server — the client has no local override.
- The macOS client is a rebranded OpenVPN Connect with AWS authentication bolted on. Same utun lifecycle issues.
- On Linux,
openvpn3CLI works directly with AWS Client VPN.ovpnconfigs after handling the SAML authentication flow.
WireGuard¶
The modern replacement when you control the server side. Dramatically simpler: ~4,000 lines of kernel code vs. ~100,000 for OpenVPN's userspace daemon.
The good:
- Interface lifecycle is clean. On macOS, the WireGuard app uses
Network Extensions correctly — interfaces are created on connect
and destroyed on disconnect. No phantom
utunaccumulation. - Configuration is a single flat file. No certificate chains, no TLS negotiation, no complex server-side configuration.
- Performance is measurably better — kernel-level encryption with modern primitives (ChaCha20, Curve25519).
- Roaming is built in — the tunnel survives network changes (Wi-Fi to cellular) without reconnection.
- You understand exactly what is happening. The config file is the complete specification of the tunnel — routes, DNS, endpoints, keys. No hidden state, no opaque server-pushed configuration.
The bad:
- Corporate adoption is slower because existing gateway infrastructure is OpenVPN/IPsec-based. You are unlikely to encounter WireGuard as a corporate-mandated VPN unless your organization self-hosts.
- No TCP fallback — WireGuard is UDP-only, which means it fails on networks that block non-HTTP UDP (some hotel Wi-Fi, aggressive corporate firewalls). OpenVPN over TCP port 443 works everywhere.
- No built-in user authentication — WireGuard authenticates keys, not users. Corporate deployments need a management layer for key distribution and revocation.
The framework's recommendation: for infrastructure you control (personal servers, home lab, team infrastructure where you own the server), WireGuard is the right answer. The configuration is transparent, the behavior is predictable, and you will actually understand what your tunnel does. For corporate VPN mandated by IT, you use whatever they provide — usually OpenVPN3 or AWS Client VPN.
IPsec / IKEv2¶
The oldest enterprise standard. Native OS support on both macOS (built into Network preferences) and Linux (strongSwan, libreswan). Configuration complexity is extreme — certificate management, IKE phase negotiation, transform sets, PFS groups. Individual engineers rarely configure IPsec directly; it lives behind whatever client IT provides.
Tailscale: a warning¶
Tailscale is a mesh overlay network built on WireGuard. It markets itself as "zero configuration VPN" — every device in the tailnet reaches every other device directly via NAT traversal without you managing keys, routes, or DNS.
The one thing Tailscale is genuinely good for: traversing NAT without configuration. If you need two machines to talk to each other and both are behind NAT, Tailscale does that in seconds. For this single use case — the banal task of "connect A to B without opening firewall ports" — it works as advertised.
Why the framework actively discourages relying on it beyond that:
- It papers over what is happening. Tailscale hides the WireGuard configuration, the routing decisions, and the DNS resolution path behind a proprietary control plane. When it works, you learn nothing about your network. When it breaks, you have no mental model to debug from because the "zero configuration" promise means there is no configuration to inspect.
- It breaks at scale. The magic works for a handful of personal devices. Add a corporate VPN, container networks, and split DNS, and Tailscale's implicit routing and DNS claims conflict with everything else on the machine. The debugging story becomes "which of these three tools wrote to the routing table last?"
- MagicDNS destroys DNS ownership. On Linux, enabling MagicDNS
rewrites
/etc/resolv.confto point at100.100.100.100. If that file is a symlink managed by systemd-resolved, Tailscale replaces the symlink with a regular file — silently breaking whatever was managing DNS before. On macOS it uses the scoped DNS API (less destructive) but still inserts itself as a resolver that can conflict with corporate split DNS. - It is a corporate product offering what you can build yourself. Tailscale is a managed WireGuard deployment. Everything it does is reproducible with WireGuard configuration files, a key distribution mechanism, and DNS records. The "free tier" subsidizes you learning a proprietary abstraction instead of the underlying technology. When the pricing changes or the feature set diverges from your needs, you have learned nothing transferable.
- The always-on interface causes routing conflicts. The Tailscale
interface (
tailscale0on Linux,utunon macOS) is always present with routes for the100.x.x.xCGNAT range. Corporate VPN clients that route100.64.0.0/10(which overlaps Tailscale's range) create silent conflicts.
The framework's position: understand WireGuard. Configure it yourself. Use Tailscale only if the sole thing you need is zero-config NAT traversal for a handful of personal devices, and be aware that you are trading understanding for convenience. Do not build infrastructure dependencies on it.
Other corporate clients¶
Cisco AnyConnect, Palo Alto GlobalProtect, and Zscaler/Cloudflare WARP exist in the wild. If your organization mandates one, you use it — there is no choice. Brief notes on their behavior:
- Cisco AnyConnect — IPsec or SSL-VPN. On macOS, creates
utuninterfaces with the same lifecycle problems as OpenVPN. Its socket filter kernel extension modifies routing even when disconnected. Rewrites global DNS unconditionally. On Linux, the open-sourceopenconnectclient is better-behaved and integrates with systemd-resolved. - Palo Alto GlobalProtect — similar to AnyConnect. Full-tunnel by
default, aggressive DNS rewriting. Uses Network Extensions on macOS
(better interface lifecycle). On Linux,
globalprotect-openconnectorgp-saml-guiprovide open-source alternatives. - Zscaler / Cloudflare WARP — cloud proxy products rather than
traditional VPNs. Generally better about routing and DNS coexistence
because they are designed to layer on top of existing infrastructure.
The tradeoff: they install root certificates for TLS interception,
which breaks certificate pinning in development tools. If
curlorgitfails with certificate errors after installation, the client's root CA is the cause.
Living with full tunnel¶
When your organization mandates full-tunnel VPN and you cannot change it, these mitigations keep development workflows functional:
Preserve localhost routing¶
Full-tunnel VPNs sometimes route 127.0.0.0/8 through the tunnel,
breaking local services. Check:
If it routes through the VPN interface, add a local route:
Preserve container networking¶
The container runtime's bridge network (10.88.0.0/16 for Podman,
172.17.0.0/16 for Docker) and custom networks can conflict with VPN
routes. If containers become unreachable after VPN connection:
- Check if the VPN claims the container subnet:
route -n get 172.17.0.1(macOS) orip route get 172.17.0.1(Linux). - If so, reconfigure the runtime to use a non-conflicting subnet. See the Container Networking page for Podman and Docker configuration.
Preserve LAN access¶
Split-tunnel VPNs preserve LAN access by default. Full-tunnel VPNs often do not. If you need to reach local network devices (printers, NAS, other machines):
# Add a route for your LAN subnet via the physical interface
# macOS (assuming en0 is Wi-Fi)
sudo route add -net 192.168.1.0/24 -interface en0
# Linux
sudo ip route add 192.168.1.0/24 dev wlan0
These routes need to be re-added after every VPN reconnection. A post-connect script (if your VPN client supports hooks) is the sustainable solution.
Interface management¶
Listing active interfaces¶
Cleaning up phantom interfaces (macOS)¶
When OpenVPN or AnyConnect leaves dead utun interfaces:
# List interfaces that have no assigned address (likely dead)
ifconfig | grep -B1 'flags.*UP' | grep utun
# There is no clean userspace command to remove utun interfaces.
# The reliable fix is a restart of the VPN client's network extension:
# System Settings > General > Login Items & Extensions > Network Extensions
# Toggle the VPN extension off and back on.
# Nuclear option: reboot. This clears all utun interfaces.
The real fix is to use a VPN client that manages interface lifecycle correctly (WireGuard, or a corporate client built on modern Network Extensions).
WireGuard interface management¶
WireGuard interfaces are explicit and clean:
The WireGuard macOS app handles interface creation/destruction through Network Extensions. Tunnels appear only when active and disappear completely on disconnect.
# Create interface
sudo ip link add wg0 type wireguard
# Apply configuration
sudo wg setconf wg0 /etc/wireguard/wg0.conf
# Bring up with routes
sudo ip link set wg0 up
sudo ip route add 10.0.0.0/24 dev wg0
# Tear down completely
sudo ip link delete wg0
# Or use wg-quick for all of the above:
sudo wg-quick up wg0
sudo wg-quick down wg0
Split tunnel configuration¶
If you control the VPN server (self-hosted WireGuard, personal infrastructure), configure split tunneling explicitly:
WireGuard split tunnel¶
The AllowedIPs field in the peer configuration determines what
traffic routes through the tunnel:
[Interface]
PrivateKey = <your-private-key>
Address = 10.0.0.2/32
DNS = 10.0.0.1
[Peer]
PublicKey = <server-public-key>
Endpoint = vpn.example.com:51820
# Split tunnel: only route these subnets through the VPN
AllowedIPs = 10.0.0.0/24, 172.16.0.0/12
# Full tunnel would be: AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
OpenVPN split tunnel (client-side override)¶
If the server pushes a full tunnel but you want split:
# In your .ovpn client config, ignore pushed routes:
route-nopull
# Then add only the routes you need:
route 10.0.0.0 255.255.255.0
route 172.16.0.0 255.240.0.0
This only works if the server does not enforce full-tunnel at the gateway level (dropping traffic that doesn't arrive through the tunnel).
Coexistence patterns¶
VPN + containers¶
Container bridge networks (Podman's 10.88.0.0/16, Docker's
172.17.0.0/16) can overlap with VPN-claimed RFC 1918 ranges. When
they do, container traffic routes through the VPN instead of locally.
The fix is to move the runtime to a non-conflicting subnet. See the "Preserve container networking" section above.
Multiple VPNs¶
Running two VPN clients simultaneously (e.g., corporate VPN + customer VPN for a consulting engagement) is a routing nightmare. Both want to own the default route and DNS. The only sustainable approach is:
- Use one VPN at a time, switching as needed.
- If simultaneous is required, ensure both are split-tunnel and
their
AllowedIPs/ routed subnets do not overlap. - Use network namespaces (Linux) to isolate one VPN's traffic entirely from the other.
Monitoring and debugging¶
# Watch routing table changes in real time (macOS)
route -n monitor
# Watch interface state changes (Linux)
ip monitor link
# See which DNS server answered a query
dig +short +identify example.com
# Trace the path to a host (useful for identifying where VPN routing diverges)
traceroute -n 10.0.0.1
mtr -n 10.0.0.1 # better interactive version
# On macOS, see all DNS resolver configurations
scutil --dns
# Check if a specific port is reachable through the current route
nc -zv 10.0.0.50 5432 -w 3