WireGuard on Ubuntu: dual-stack IPv4 and IPv6, split tunnelling, and why it breaks Android Auto
- ubuntu
- wireguard
- vpn
- ipv6
- networking
- android
WireGuard is roughly 4,000 lines of kernel code against OpenVPN’s hundreds of thousands, and it shows: a working server is about fifteen lines of configuration. Most of the effort goes into IPv6 and into the two things nobody warns you about — split tunnelling and what a full-tunnel VPN does to Android Auto.
Installing
WireGuard has been in the mainline kernel since 5.6, and Ubuntu backported it to the 5.4 kernel on 20.04, so all three releases work with no DKMS and no third-party module:
sudo apt update
sudo apt install -y wireguard wireguard-tools
Check the module is there:
sudo modprobe wireguard
lsmod | grep wireguard
You will also want these later:
sudo apt install -y qrencode # QR codes for phone clients
Keys
Every peer — server and client alike — has a key pair. WireGuard has no concept of a “server”; it is peers all the way down, and one of them just happens to have a public address.
sudo mkdir -p /etc/wireguard
cd /etc/wireguard
umask 077
wg genkey | sudo tee server.key | wg pubkey | sudo tee server.pub
umask 077 before generating is not optional. WireGuard refuses to start if a private key is
world-readable, and rightly so.
Addressing
Pick ranges that will not collide with anything a client is already on. Home networks are almost
always 192.168.x.0/24 and corporate ones love 10.0.0.0/8, so choose carefully.
- IPv4:
10.8.0.0/24— the server takes10.8.0.1, clients10.8.0.2onward. - IPv6: a Unique Local Address prefix,
fd86:ea04:1111::/64. ULA is the IPv6 equivalent of a private range.
Generate your own ULA prefix rather than copying the one above. It should be fd followed by a
random 40-bit global id:
printf 'fd%02x:%04x:%04x::/64\n' $((RANDOM%256)) $((RANDOM)) $((RANDOM))
There are two ways to do IPv6, and they are genuinely different:
| Approach | What clients get | Complexity |
|---|---|---|
| ULA + NAT66 | Working IPv6 through the server’s address | Low |
| Routed prefix | Real, globally routable addresses | Needs a delegated prefix from your provider |
This guide uses ULA plus NAT66. It works everywhere, including the many VPS providers that hand you
a single /128 and nothing to route. If your provider delegates a real /64 or /56 — Hetzner
and OVH do — the routed approach is cleaner: assign from that prefix and skip the IPv6 masquerade
rule. The rest is identical.
Enabling forwarding
The server has to route packets between the tunnel and the internet, which Linux does not do by default:
sudo tee /etc/sysctl.d/99-wireguard.conf > /dev/null <<'EOF'
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
sudo sysctl --system
Verify:
sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding
Both must read 1. Forgetting this produces the single most common symptom: the handshake
succeeds, the tunnel says it is up, and nothing routes.
The server configuration
First, find the real outbound interface — do not assume eth0, it is frequently ens3 or enp1s0
on a VPS:
ip route show default
Then /etc/wireguard/wg0.conf:
[Interface]
Address = 10.8.0.1/24, fd86:ea04:1111::1/64
ListenPort = 51820
PrivateKey = <contents of /etc/wireguard/server.key>
# NAT for both families. Replace ens3 with your real interface.
PostUp = iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o ens3 -j MASQUERADE
PostUp = ip6tables -t nat -A POSTROUTING -s fd86:ea04:1111::/64 -o ens3 -j MASQUERADE
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT
PostUp = ip6tables -A FORWARD -i wg0 -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -s 10.8.0.0/24 -o ens3 -j MASQUERADE
PostDown = ip6tables -t nat -D POSTROUTING -s fd86:ea04:1111::/64 -o ens3 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT
PostDown = ip6tables -D FORWARD -i wg0 -j ACCEPT
Lock the file down:
sudo chmod 600 /etc/wireguard/wg0.conf
Open the port. WireGuard is UDP only — a TCP rule does nothing:
sudo ufw allow 51820/udp
sudo ufw reload
Start it, and enable it at boot:
sudo systemctl enable --now wg-quick@wg0
sudo systemctl status wg-quick@wg0
sudo wg show
Adding a client
Generate the client’s keys — on the client if you can, so the private key never travels:
umask 077
wg genkey | tee laptop.key | wg pubkey > laptop.pub
Add the peer to the server. Doing it live avoids a restart that would drop every other client:
sudo wg set wg0 peer "<laptop public key>" \
allowed-ips 10.8.0.2/32,fd86:ea04:1111::2/128
# Persist it into wg0.conf so it survives a restart
sudo wg-quick save wg0
Note the mask: /32 and /128. On the server, AllowedIPs means “which addresses live behind
this peer” — one address each. Widen it and you will break routing for everyone else.
The client config, laptop.conf:
[Interface]
PrivateKey = <contents of laptop.key>
Address = 10.8.0.2/32, fd86:ea04:1111::2/128
DNS = 1.1.1.1, 2606:4700:4700::1111
[Peer]
PublicKey = <contents of /etc/wireguard/server.pub>
Endpoint = your.server.example:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
On the client, AllowedIPs means something different — “route these destinations through the
tunnel”. 0.0.0.0/0, ::/0 is a full tunnel. This is the field you edit for route-based split
tunnelling, further down.
PersistentKeepalive = 25 sends a packet every 25 seconds to hold the NAT mapping open. Without it
a client behind NAT — every phone, every home router — becomes unreachable from the server side
after a minute or two of silence.
For a phone
qrencode -t ansiutf8 < laptop.conf
Scan it from the WireGuard app. Delete the file afterwards; a config with a private key in it is a credential.
Bring it up
# Linux client
sudo cp laptop.conf /etc/wireguard/wg0.conf
sudo wg-quick up wg0
# Verify
curl https://api.ipify.org # IPv4 — should show the server
curl -6 https://api64.ipify.org # IPv6
A good end-to-end check for both families at once is test-ipv6.com. If
IPv4 works and IPv6 does not, the usual cause is the missing ip6tables masquerade rule or
net.ipv6.conf.all.forwarding.
Split tunnelling
Full tunnel is the default and often the wrong choice. Three ways to narrow it, from crudest to most precise.
By destination
Edit AllowedIPs on the client. Only these routes enter the tunnel:
# Only reach the private network behind the server. Everything else stays local.
AllowedIPs = 10.8.0.0/24, 192.168.50.0/24, fd86:ea04:1111::/64
This is right for “I want to reach my home network”, wrong for “I want to hide my traffic”. It is also the only split-tunnel method that works identically on every platform.
By application, on Android and iOS
The Android app has this built in and it is the answer to most split-tunnel questions:
WireGuard app → your tunnel → Edit → Applications → Excluded/Included applications.
- Excluded — everything goes through the tunnel except the apps you list. Use this for banking apps that block VPNs, and for Android Auto.
- Included — only the listed apps use the tunnel, everything else goes direct.
The iOS app does not offer per-app control. Apple does not expose the API. On iOS your options
are destination-based AllowedIPs, or on-demand rules per Wi-Fi network.
By application, on Linux
Linux has no per-app VPN switch, but network namespaces get you there — and this is genuinely useful: one terminal tunnelled, everything else direct.
# Create a namespace
sudo ip netns add vpn
# Create the interface in the main namespace, then move it across
sudo ip link add wgvpn type wireguard
sudo ip link set wgvpn netns vpn
# Configure it inside the namespace
sudo ip netns exec vpn wg setconf wgvpn /etc/wireguard/wg0.conf
sudo ip netns exec vpn ip address add 10.8.0.2/32 dev wgvpn
sudo ip netns exec vpn ip address add fd86:ea04:1111::2/128 dev wgvpn
sudo ip netns exec vpn ip link set wgvpn up
sudo ip netns exec vpn ip route add default dev wgvpn
sudo ip netns exec vpn ip -6 route add default dev wgvpn
# DNS for the namespace
sudo mkdir -p /etc/netns/vpn
echo "nameserver 1.1.1.1" | sudo tee /etc/netns/vpn/resolv.conf
Then run anything you like inside it:
sudo ip netns exec vpn sudo -u "$USER" firefox
sudo ip netns exec vpn curl https://api.ipify.org
The trick that makes this work is creating the WireGuard interface in the main namespace before
moving it: the encrypted UDP packets keep using the main namespace’s routing to reach the server,
while everything inside the namespace sees only the tunnel. Nothing outside ip netns exec vpn
is affected, and there is no kill-switch hole — an app in the namespace with the tunnel down has
no route at all.
Note the AllowedIPs in the config file used by wg setconf still needs to be 0.0.0.0/0, ::/0,
and wg setconf ignores the Address, DNS and MTU lines — that is why they are set by hand
above.
Android Auto
This is the one that generates support threads, so it gets its own section.
The symptom: with WireGuard connected, Android Auto refuses to connect, connects and drops after a few seconds, or works over USB but never wirelessly.
The cause: Android Auto is not a normal app talking to the internet. Wired, it runs a local transport over USB; wireless, it establishes a direct Wi-Fi link to the head unit, often on a separate interface, and it depends on local network discovery. A full-tunnel VPN captures the routing table, and Android’s “block connections without VPN” makes the local paths it needs unavailable. Google Play Services is involved in the handshake too, which is why the failure can look intermittent and unrelated.
The fix — exclude it from the tunnel:
- WireGuard app → your tunnel → Edit
- Applications → Excluded applications
- Tick Android Auto — package
com.google.android.projection.gearhead - Also tick Google Play services (
com.google.android.gms) if it still fails
If wireless Android Auto still will not connect, the other two things to try:
- Turn off the kill switch. Settings → Network → VPN → gear icon next to WireGuard → disable Block connections without VPN. This setting is the single most common cause, because wireless Android Auto needs untunnelled local connectivity.
- Check the MTU. See below — a head unit dropping oversized packets produces exactly this kind of “connects then dies” behaviour.
If your phone runs the VPN always-on, excluding these two packages is the correct permanent fix. Turning the tunnel off every time you get in the car is not.
MTU
WireGuard defaults to an MTU of 1420: 1500 minus its own overhead. That is right on plain Ethernet and wrong in several common situations.
Symptoms of a bad MTU are distinctive: the handshake works, ping works, small requests work, and
then large downloads or TLS handshakes hang forever. Anything that fits in one packet is fine;
anything that does not, is not.
Lower it on the client:
[Interface]
MTU = 1380
Values worth trying, in order: 1420 (default), 1412 (PPPoE), 1380 (mobile networks, double NAT), 1280 (the IPv6 minimum — always works, slightly wasteful).
To find the real number rather than guessing, probe with ping and the don’t-fragment flag:
ping -M do -s 1372 -c 3 1.1.1.1
The payload size that just succeeds, plus 28 for the IP and ICMP headers, plus 80 for WireGuard’s overhead, gives you the tunnel MTU. Or start at 1280 and stop worrying about it.
Hardening
Preshared keys add a symmetric layer on top, which protects against a future attacker who records traffic now and has a quantum computer later:
wg genpsk > laptop.psk
Add PresharedKey to the peer block on both sides.
Do not open SSH to the world once the tunnel works. Bind it to the tunnel address instead:
sudo ufw allow in on wg0 to any port 22
sudo ufw delete allow 22/tcp
Make sure the tunnel actually works before running the second command.
Unattended upgrades, since this box is now internet-facing by design:
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades
Troubleshooting
Handshake never completes. In sudo wg show, latest handshake stays empty.
sudo wg show # server side
sudo ss -ulnp | grep 51820 # is it listening?
sudo tcpdump -i any udp port 51820 -n
Causes, in order of likelihood: UDP blocked upstream, wrong endpoint address or port, mismatched
keys (public and private swapped is a classic), or the cloud provider’s own firewall — a security
group or network ACL that you configured separately from ufw.
Handshake works, no traffic. Almost always forwarding or NAT:
sysctl net.ipv4.ip_forward
sudo iptables -t nat -L POSTROUTING -n -v
sudo ip6tables -t nat -L POSTROUTING -n -v
IPv4 fine, IPv6 dead. Check net.ipv6.conf.all.forwarding, the ip6tables masquerade rule,
and that the client config actually has ::/0 in AllowedIPs.
DNS leaks or nothing resolves. wg-quick uses resolvconf to set DNS; on a minimal Ubuntu it
may not be installed:
sudo apt install -y openresolv
Connection drops after a couple of minutes idle. Missing PersistentKeepalive = 25 on the
client.
RTNETLINK answers: Operation not supported. The kernel module did not load. Check uname -r
and sudo modprobe wireguard; on a container-based VPS (OpenVZ, LXC) the module may be
unavailable — there you need wireguard-go, the userspace implementation, which is slower.
Large transfers stall. MTU. See above.
Useful commands
sudo wg show # peers, handshakes, transfer counters
sudo wg show wg0 dump # same, script-friendly
sudo wg-quick down wg0 && sudo wg-quick up wg0
sudo systemctl restart wg-quick@wg0
sudo journalctl -u wg-quick@wg0 -n 50 --no-pager
# Add or remove a peer without dropping the others
sudo wg set wg0 peer "<pubkey>" allowed-ips 10.8.0.5/32,fd86:ea04:1111::5/128
sudo wg set wg0 peer "<pubkey>" remove
sudo wg-quick save wg0Comments
Corrections, additions and "this broke on my machine" reports are all welcome. You can post anonymously — no account needed.