Headscale out and WireGuard Easy in

wireguardmullvadselfhostingkubernetesvpnlinux

I’ve been running Headscale for a while to access my home network remotely. It worked, but came with friction: Tailscale clients on every device, MagicDNS quirks, and a particularly annoying bug where DNS breaks when using an exit node on Android. After chasing that issue through multiple workarounds, I decided to simplify.

The setup

I replaced Headscale with two WireGuard Easy instances running on my K3s cluster:

  1. Mullvad tunnel (port 51831): for routing all traffic (0.0.0.0/0) through Mullvad VPN. This one runs wg-easy with a gluetun sidecar that manages the Mullvad WireGuard connection. This is the primary profile on my phones since Android only allows one active VPN at a time.
  2. LAN-only (port 51830): for accessing 192.168.86.0/24 when I’m away from home. AllowedIPs is set to just the LAN subnet. I switch to this one when something isn’t reachable through the Mullvad tunnel.

On Android, switching between the two profiles is one tap. No Tailscale client needed, no MagicDNS, no exit node bugs.

On my Linux machines, I import the configs that wg-easy generates through NetworkManager. I rename the downloaded config to match the interface name I want (e.g. wg_lan.conf or wg_mullvad.conf) and import it with:

nmcli connection import type wireguard file wg_lan.conf

This creates a NetworkManager connection named after the file. From there I can bring it up and down with nmcli connection up wg_lan or through the network manager applet. Same two-profile setup as on Android -one for LAN access, one for Mullvad.

Gluetun sidecar

The Mullvad instance uses gluetun as a sidecar container. Gluetun connects to Mullvad via WireGuard and manages the firewall. The wg-easy container shares the network namespace, so all its traffic can be routed through gluetun’s tun0 interface.

The deployment looks roughly like this:

Phone → wg0 (wg-easy) → tun0 (gluetun) → Mullvad → Internet

Gluetun handles the VPN connection, firewall rules, and health checks. I set FIREWALL_INPUT_PORTS to allow the WireGuard and web UI ports, and FIREWALL_OUTBOUND_SUBNETS to 192.168.86.0/24 so LAN traffic bypasses the tunnel.

The routing puzzle

My knowledge of iptables and policy routing is limited, so I leaned on Claude to work through this part. Getting packets from wg0 to tun0 and back required three ip policy rules:

# LAN traffic from wg clients goes direct, not through the tunnel
ip rule add from 10.8.0.0/24 to 192.168.86.0/24 lookup main priority 94

# All other wg client traffic routes to tun0 (Mullvad)
ip rule add from 10.8.0.0/24 lookup 51820 priority 95

# Return traffic from tun0 routes back to wg0, not back into the tunnel
ip rule add to 10.8.0.0/24 lookup main priority 96

The last rule was the hardest to find, and Claude had to explain this multiple times before I understood it (somewhat). Without it, gluetun’s catch-all rule (not fwmark 0xca6c lookup 51820) sends return traffic back into tun0 instead of forwarding it to wg0. Packets go out but never come back, a routing loop that’s invisible unless you’re counting iptables matches.

iptables-legacy vs iptables-nft

wg-easy uses iptables-legacy for its PostUp rules. Gluetun uses iptables-nft. Both backends share the kernel’s conntrack, but having MASQUERADE and FORWARD rules split across two table systems is a recipe for confusion. The solution: flush all of wg-easy’s legacy rules in a postStart hook and let gluetun’s nft rules handle everything. A ConfigMap provides the forwarding and NAT rules via gluetun’s /iptables/post-rules.txt:

iptables -A FORWARD -i wg0 -o tun0 -j ACCEPT
iptables -A FORWARD -i tun0 -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
iptables -A FORWARD -i wg0 -d 192.168.86.0/24 -j ACCEPT
iptables -A FORWARD -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o tun0 -j MASQUERADE
iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -d 192.168.86.0/24 -j MASQUERADE

The result

On Android the WireGuard app is lighter than Tailscale, and on Linux the configs slot right into NetworkManager. Switching profiles is instant on both, and there’s no dependency on a coordination server. DNS just works because the WireGuard config points directly at my AdGuard Home instance.

The whole thing is about 200 lines of YAML managed by ArgoCD. Compared to Headscale + Tailscale clients + exit node workarounds, it’s a significant reduction in moving parts. Even if I now have 2 wg-easy deployments.