Homelab · Networking & Isolation

One host, two worlds: selective VPN routing with a Linux network namespace

How I route only the processes I opt in through a commercial WireGuard VPN on a self-hosted Raspberry Pi — without ever putting SSH, Tailscale, or my Cloudflare Tunnel at the mercy of that tunnel.

Raspberry Pi WireGuard netns fail-closed self-hosted

01The problem

I run an always-on Raspberry Pi that wears a lot of hats. It hosts my SSH management plane, it's a node on my Tailscale mesh, and it runs cloudflared (a Cloudflare Tunnel) that exposes the little apps I build. On top of that, I wanted some outbound traffic — specific scripts and bots — to leave through a commercial WireGuard VPN, so it gets a clean, separate egress IP.

The obvious approach, wg-quick up, hijacks the host's default route and shoves everything through the tunnel. On a box whose entire job is to stay reachable, that's a footgun: the moment the VPN wobbles, SSH, Tailscale, and the tunnel that exposes my projects can all drop with it. And the usual mitigation — a firewall "kill switch" — is a stack of iptables rules that fails open if you get a single one wrong.

I wanted the opposite default: the management plane is never touched, and only the processes I explicitly opt in use the VPN. Isolation by construction, not by policy.

02The idea: give the tunnel its own namespace

A Linux network namespace is a private copy of the network stack — its own interfaces, routing table, and DNS. Processes inside it can't see the host's routes, and the host can't see theirs. That boundary is the whole security property here.

The trick that makes a WireGuard VPN work cleanly across that boundary:

Create the WireGuard interface in the host namespace, then move the interface into the isolated namespace.

WireGuard has a useful quirk: its encrypted UDP socket stays bound to whatever namespace the interface was created in, even after you move the interface somewhere else. So:

Net result: encrypted packets reach the VPN over the host's normal connection, but the decrypted "you're now on the VPN" side exists only inside the namespace. Nothing on the host is rerouted — not one route table entry changes.

        HOST namespace
  ┌──────────────────────────────────────────────┐
  │  wlan0  ⇄  [ wgproton: encrypted UDP socket ] │
  │  SSH · Tailscale · cloudflared  (never routed │
  │  through the tunnel, can't be sealed by it)   │
  └───────────────────────┬──────────────────────┘
                          │  encrypted UDP rides wlan0 normally
                          ▼
        "proton" namespace
  ┌──────────────────────────────────────────────┐
  │  wgproton (cleartext) = the ONLY default route│
  │  → only processes launched in here use the VPN│
  │  → tunnel down? no route at all. Fail-closed. │
  └──────────────────────────────────────────────┘

03Why this beats a kill switch

04How it works, in code

The interesting four lines — create the interface on the host, load the crypto config, then move it into the namespace:

# Create the wg interface in the HOST namespace so its UDP socket binds here
ip link add wgproton type wireguard
wg setconf wgproton <(wg-quick strip /etc/wireguard/proton.conf)
ip link set wgproton netns proton   # move it — the UDP socket stays on the host

Then everything else happens inside the namespace. The address comes from the VPN config; the default route points at the tunnel and nothing else:

# addresses shown are placeholders
ip -n proton addr add 10.2.0.2/32 dev wgproton
ip -n proton link set wgproton mtu 1420 up
ip -n proton route add default dev wgproton   # the ONLY route in this ns

DNS is namespace-local too. ip netns exec bind-mounts this file over /etc/resolv.conf for anything launched in the namespace, so lookups go to the VPN's resolver instead of the host's:

mkdir -p /etc/netns/proton
echo "nameserver 10.2.0.1" > /etc/netns/proton/resolv.conf

Running something through the VPN is then just a matter of launching it in the namespace (dropping back to my normal user, not root):

sudo ip netns exec proton sudo -u me \
     curl -s https://api.ipify.org

05Verifying it actually isolates

The wrapper's status command runs the same IP-echo request twice — once on the host, once inside the namespace — and prints both:

# illustrative — real IPs masked
netns 'proton': present
  exit IP (via VPN):  185.x.x.x      ← namespace egress
  real IP (host):     [home IP]      ← everything else

If the two differ, isolation is working. If the namespace request fails while the host one succeeds, the tunnel is down — and that's the correct behaviour: nothing leaked, the opted-in process just couldn't reach the internet until I bring the tunnel back.

06What I'd harden next

This is a homelab tool, and I treat the write-up as a living checklist. The roadmap from here, roughly in priority order: