Self-host a WireGuard VPN on your VPS
Set up WireGuard on your own Linux VPS: key generation, wg0.conf, IP forwarding, NAT, AllowedIPs semantics, DNS, and the handshake failures that bite.
What you are building
A WireGuard VPN on a server you own is about forty lines of config: one key pair, one interface file, one sysctl, one NAT rule, one firewall hole. The install is trivial, so most of this guide covers what breaks — key permissions, AllowedIPs, forwarding and DNS.
WireGuard is a Layer 3 tunnel in the kernel, mainline since Linux 5.6, so Ubuntu 24.04 and Debian 13 ship it with no external module. No cipher negotiation, no certificate authority, no username/password step: a peer is a public key plus the IP addresses that key may use. A packet failing its MAC check is dropped with no reply, so the port does not answer scans. The flip side: no auth server exists, so removing access means deleting a peer on the box.
Check the virtualisation first
WireGuard needs a kernel you can load a module into, and on a KVM VPS it works out of the box. On container virtualisation sharing the host kernel — OpenVZ, LXC — the first command fails with RTNETLINK answers: Operation not supported, and the fallback is the wireguard-go userspace implementation. Check with sudo modprobe wireguard && echo ok first.
Generate keys without leaking them
A world-readable /etc/wireguard/server.key is the same as no VPN at all. The common umask 077 && wg genkey | sudo tee ... line is unreliable, since sudo applies its own umask to the file tee creates. Set the mode explicitly.
sudo apt update && sudo apt install -y wireguard nftables
sudo install -d -m 700 /etc/wireguard
sudo sh -c 'umask 077; wg genkey > /etc/wireguard/server.key'
sudo sh -c 'wg pubkey < /etc/wireguard/server.key > /etc/wireguard/server.pub'
sudo chmod 600 /etc/wireguard/server.key
Generate the client pair the same way. wg genpsk adds an optional pre-shared key, one line in each config.
The server interface: /etc/wireguard/wg0.conf
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = <contents of /etc/wireguard/server.key>
[Peer]
PublicKey = <laptop public key>
PresharedKey = <psk, optional>
AllowedIPs = 10.8.0.2/32
chmod 600 it; a startup warning that the file is world accessible means you skipped that. Address is the server's address inside the tunnel, carrying the mask of the whole VPN subnet. Pick a range you will not meet in the wild — 192.168.1.0/24 collides with half the home routers your clients sit behind, and the tunnel then loses silently to the local route.
A peer's AllowedIPs on the server side is a /32, the one tunnel address that client owns. Give two peers the same allowed IP and it moves to whichever was configured last, and the first stops receiving traffic with no error printed anywhere. Leave SaveConfig unset, or wg-quick down rewrites this file from live state.
Turn the box into a router
A Linux server drops packets not addressed to it. Forwarding and source NAT are both missing by default.
printf 'net.ipv4.ip_forward = 1\nnet.ipv6.conf.all.forwarding = 1\n' \
| sudo tee /etc/sysctl.d/99-wireguard.conf
sudo sysctl --system
sysctl net.ipv4.ip_forward
A bare sysctl -w works until the next reboot and then quietly stops working. NAT needs the egress interface — the NIC that reaches the internet, not wg0. Do not assume eth0; take yours from ip route show default, since current images use names like enp1s0 or ens3.
Firewall: the port, and the forward path
One nftables file covers filter and NAT. Write /etc/nftables.conf — it flushes the existing ruleset, so skip this on a box already managed by ufw or Docker.
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
iif lo accept
tcp dport 22 accept
udp dport 51820 accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
iifname "wg0" oifname "enp1s0" accept
}
}
table ip nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 10.8.0.0/24 oifname "enp1s0" masquerade
}
}
Apply it with sudo systemctl enable --now nftables, keeping a second SSH session open: policy drop plus a typo in the SSH rule locks you out of your own server. Note what the forward chain does not allow — wg0 to wg0. Peers reach the internet, not each other; add iifname "wg0" oifname "wg0" accept for a peer-to-peer VPN. The same chain governs what a peer may touch on the server itself, which matters when the box doubles as a remote development box running Claude Code in tmux and you would rather not expose that side of it publicly.
On a ufw box: ufw allow 51820/udp, DEFAULT_FORWARD_POLICY="ACCEPT" in /etc/default/ufw, and a *nat POSTROUTING MASQUERADE rule at the top of /etc/ufw/before.rules.
Bring it up under systemd
sudo systemctl enable --now wg-quick@wg0
sudo wg show
wg-quick creates the interface, adds the addresses, and installs routes derived from AllowedIPs. enable --now is the important half: a hand-run wg-quick up wg0 is gone after the next reboot, and kernel upgrades mean reboots.
The client config, and the setting everybody gets wrong
[Interface]
PrivateKey = <laptop private key>
Address = 10.8.0.2/32
DNS = 10.8.0.1
[Peer]
PublicKey = <server public key>
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
AllowedIPs does two different jobs at once, and conflating them is the source of most WireGuard confusion.
Outbound it is a routing table. A packet whose destination matches a peer's AllowedIPs is encrypted and sent to that peer. 0.0.0.0/0, ::/0 sends everything down the tunnel — a full tunnel, the server as default route. A split tunnel is a narrower list: AllowedIPs = 10.8.0.0/24, 10.20.0.0/16 carries VPN traffic plus one private network behind the server, and everything else keeps its local route. That narrow list is what lets you keep services off the public internet entirely — a private Nextcloud instance on a VPS bound to the tunnel address, or the nested-virtualisation lab VMs running on the same box, stay reachable to peers and invisible to everyone else.
Inbound it is an access-control list. A decrypted packet from a peer whose source address is not in that peer's AllowedIPs is dropped. That is why the server lists 10.8.0.2/32 for the laptop: an entry of 0.0.0.0/0 there would let that client spoof any address in the tunnel.
PersistentKeepalive is for clients behind NAT, where the router holds the UDP mapping open only while packets flow. When it expires, the server can no longer reach the client. PersistentKeepalive = 25 holds the mapping open — set it on the client, not on a server with a public IP.
DNS, and the leak nobody notices
With AllowedIPs = 0.0.0.0/0 and no DNS = line, the client keeps the resolver it learned from the local network — the café router at 192.168.1.1. That route is more specific than the default route, so DNS queries leave over the local link in cleartext while everything else is tunnelled. The traffic is private; the list of names is not.
Two honest options. Point DNS at a public resolver (DNS = 9.9.9.9) and the queries ride the tunnel and exit from your server, though that resolver still sees them. Or run unbound or dnsmasq bound to 10.8.0.1, set DNS = 10.8.0.1, and add udp dport 53 iifname "wg0" accept to the input chain — set that line and forget the resolver, and nothing resolves at all.
On Linux clients wg-quick applies DNS through resolvconf; if it is absent you get resolvconf: command not found. Install openresolv, or set PostUp = resolvectl dns %i 10.8.0.1 on a systemd-resolved client.
Adding and removing peers without dropping the tunnel
Restarting the interface to add a user kicks off everyone connected. Append the [Peer] block to wg0.conf, then reload the peer set in place.
sudo bash -c 'wg syncconf wg0 <(wg-quick strip wg0)'
wg-quick strip prints the config without the wg-quick-only keys (Address, DNS, PostUp), and syncconf applies the difference while live sessions survive. It updates peers only: a changed Address still needs a full down/up. Revoke with sudo wg set wg0 peer <public key> remove, then delete the block from the file or it returns on the next reload.
Failure modes, with the strings you will see
Handshake never completes. wg show lists the peer with no latest handshake, and the client logs:
Handshake for peer 1 (203.0.113.10:51820) did not complete after 5 seconds, retrying (try 2)
Nothing is arriving, or nothing is accepted. In order: is UDP 51820 open on the VPS firewall and on your provider's network firewall, a separate control on most panels; is the Endpoint address and port right; are the keys crossed. The key in the client's [Peer] block must be the server's public key, and vice versa — pasting a private key, or a client's own public key, gives exactly this symptom. sudo dmesg | grep -i wireguard shows whether packets arrive at all; a key mismatch shows up there as an invalid-MAC drop.
Handshake works, no internet. ping 10.8.0.1 succeeds but ping 1.1.1.1 times out: forwarding or NAT is missing. Check sysctl net.ipv4.ip_forward reads 1, then watch the counters while the client pings, with sudo nft list ruleset or sudo iptables -t nat -L POSTROUTING -n -v. Zero packets on the masquerade rule means its egress interface name is wrong; a rising counter with no replies points at the forward chain policy.
Internet works, names do not. ping 1.1.1.1 succeeds and curl https://example.com returns Could not resolve host. The DNS line is missing, or it names a resolver unreachable from inside the tunnel.
Some HTTPS sites hang. SSH and ping are fine; large pages stall. That is path MTU: the tunnel adds overhead, and some link in the middle drops the oversized packets without an ICMP message getting back. Lower MTU on the client [Interface] — try 1420, then 1380, then 1280.
Interface refuses to start. Address already in use means another process holds UDP 51820. Cannot find device wg0 after a failed up usually means the config was rejected; read journalctl -u wg-quick@wg0 -n 50.
Migrating from Streisand or OpenVPN
Streisand is unmaintained and its repository archived, and running a VPN on abandoned automation is a slow security problem. There is no in-place upgrade, and OpenVPN's PKI does not convert: WireGuard has no certificates, no CA and no expiry, so every client gets a fresh key pair.
Migrate in parallel — WireGuard on UDP 51820 coexists with OpenVPN on 1194 on the same box. Stand up wg0, move clients one at a time, then stop the old service. OpenVPN's username/password and revocation model does not carry across; if you need accounts or an audit trail, layer that above WireGuard.
Backups, upgrades, and what strains at scale
/etc/wireguard is the server. Back it up (sudo tar czf wg-backup.tgz -C /etc wireguard, mode 600, kept off the box) and you can rebuild on a fresh VPS in minutes. Lose the server's private key and every client config must be reissued, since clients pin the server's public key. Upgrades are an ordinary apt upgrade plus a reboot for kernel updates, and wg-quick@wg0 returns on its own if you enabled it.
Per-peer state is small and the crypto runs in the kernel, so the ceiling is your VPS's CPU and bandwidth allowance rather than anything in this config — measure it with iperf3 across the tunnel instead of trusting a published figure. What strains at scale is operations. Every peer needs a unique tunnel IP, and hand-editing sixty [Peer] blocks is how duplicate AllowedIPs sneak in: generate the configs from a script. One server is one UDP endpoint and one point of failure, and WireGuard has no clustering: redundancy means a second server with its own keys. Key rotation stays manual, so write down who holds which key and how you revoke one.
All of this needs a Linux box you control — a public IP, a kernel you can load a module into, and a firewall you own end to end.
FAQ
Why does the WireGuard handshake never complete?
wg show listing a peer with no latest handshake means packets are not arriving or not being accepted. Check UDP 51820 on both the VPS firewall and your provider's separate network firewall, confirm the Endpoint host and port, then check the keys are not crossed — the client's [Peer] block must hold the server's public key. A key mismatch shows up in sudo dmesg | grep -i wireguard as an invalid-MAC drop.
The tunnel connects but I have no internet. What is missing?
ping 10.8.0.1 working while ping 1.1.1.1 times out points at forwarding or NAT. Confirm sysctl net.ipv4.ip_forward reads 1 and that it is set in /etc/sysctl.d/, not just with a sysctl -w that dies at reboot. Then check the masquerade rule names your real egress interface from ip route show default — enp1s0 or ens3, rarely eth0.
Do I need the DNS = line in my client config?
With a full tunnel and no DNS = line, the client keeps the resolver it learned from the local network, and those queries leave in cleartext over the local link while everything else is tunnelled. Point DNS at a public resolver, or run unbound/dnsmasq bound to 10.8.0.1 and open udp dport 53 iifname "wg0" in the input chain.
What does AllowedIPs actually control?
It does two jobs. Outbound it is a routing table: traffic matching a peer's AllowedIPs is encrypted and sent to that peer. Inbound it is an access-control list: a decrypted packet whose source is outside that peer's AllowedIPs is dropped. That is why the server side lists a /32 per client while the client side may list 0.0.0.0/0.
Will WireGuard run on any VPS?
On a KVM VPS it works with the in-kernel module and no extra setup. On container virtualisation that shares the host kernel, such as OpenVZ or LXC, modprobe wireguard fails with Operation not supported and the fallback is the wireguard-go userspace implementation. Run sudo modprobe wireguard && echo ok before anything else.