Set up a WireGuard VPN on your own server

Published on 15 min read

From an empty server to a working WireGuard tunnel: key pairs, NAT with nftables, wg-quick as a service, a QR code for your phone and the three failure modes that really cost you time.

What is the same on all four distributions, and what is not

WireGuard has been part of the Linux kernel since kernel 5.6. On Debian 13, Debian 12, Ubuntu 24.04 and Ubuntu 22.04 you therefore no longer have to build a DKMS module, add a backport repository or import a third-party key. All four also ship the same version of the userland tools (upstream version 1.0.20210914), which means the commands wg and wg-quick behave identically everywhere.

The differences are all in the surrounding pieces, and that is exactly where most guides fall apart:

  • Packet filter: wireguard-tools recommends nftables or iptables. Since apt pulls in the first available alternative, a slim Debian installation ends up with nftables, not iptables. The iptables -t nat -A POSTROUTING lines that everyone copies from everyone else do nothing there unless you install the package yourself.
  • DNS handover: for the DNS = line, wg-quick stubbornly calls a program named resolvconf. On Ubuntu, the systemd-resolved package ships a compatibility wrapper for it, while a minimal Debian installation often has no resolvconf at all. If you need to install it, the matching package on Debian and on Ubuntu 22.04 is openresolv, and on Ubuntu 24.04 that package no longer exists. This only affects Linux clients, not phones.
  • Firewall frontend: Ubuntu images often come with an active ufw, Debian usually does not. ufw blocks forwarding by default, even when net.ipv4.ip_forward is set to 1.

Check what you are dealing with first:

apt-get update
apt-get install -y wireguard wireguard-tools nftables qrencode
wg --version
apt-cache policy wireguard-tools

If wg --version prints a version number, the userland is in place. Whether the kernel plays along only becomes clear on the first wg-quick up. If that leaves you with RTNETLINK answers: Operation not supported or Unable to access interface: Protocol not supported, you are running a kernel without WireGuard support, typically a very old one or a heavily stripped-down container kernel.

Generate the key pairs without building in trouble

WireGuard has no user names and no certificates. Each participant has exactly one key pair, plus an optional shared preshared key as an additional symmetric layer. Create both with umask set, otherwise the private keys sit on disk readable by everyone:

umask 077
wg genkey | tee /etc/wireguard/server.key | wg pubkey > /etc/wireguard/server.pub
wg genkey | tee /etc/wireguard/PHONE.key | wg pubkey > /etc/wireguard/PHONE.pub
wg genpsk > /etc/wireguard/PHONE.psk
ls -l /etc/wireguard

You do not have to create the /etc/wireguard directory yourself, the wireguard-tools package already ships it with mode 0700. More important is a quirk of umask: the value only applies to the shell session in which you set it. If you generate the keys in two rounds, for example because the connection dropped in between and you logged in again, you are back to the default mask afterwards, and server.key as well as PHONE.psk end up on disk with 0644. So set the permissions explicitly once more at the end:

chmod 600 /etc/wireguard/*.key /etc/wireguard/*.psk

Three mistakes keep coming up here:

  1. Public and private key swapped. Both are 44-character Base64 strings and look identical. If a public key ends up in [Interface] PrivateKey by accident, the tunnel still starts, but a handshake never happens. You can derive the answer at any time: wg pubkey < /etc/wireguard/server.key has to produce exactly the contents of server.pub.
  2. Line break copied along. Keys copied out of a terminal with the mouse like to bring whitespace with them. wg answers that with Key is not the correct length or format.
  3. File permissions. If you forget umask 077, wg-quick warns at startup with Warning: `/etc/wireguard/wg0.conf' is world accessible. That is not cosmetic, this file holds the private key in plain text.

The server configuration

First find out the name of your internet-facing interface. On virtual machines it is called eth0, ens3 or enp1s0, depending on the image:

ip route show default

Then create /etc/wireguard/wg0.conf. Pick a tunnel network that you are unlikely to run into on a hotel Wi-Fi while travelling, so preferably not 192.168.0.0/24 or 192.168.1.0/24:

[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = CONTENTS_OF_SERVER_KEY

PostUp = nft add table ip wgnat
PostUp = nft add chain ip wgnat postrouting '{ type nat hook postrouting priority srcnat; policy accept; }'
PostUp = nft add rule ip wgnat postrouting ip saddr 10.8.0.0/24 oifname "eth0" masquerade
PostDown = nft delete table ip wgnat

[Peer]
# PHONE
PublicKey = CONTENTS_OF_PHONE_PUB
PresharedKey = CONTENTS_OF_PHONE_PSK
AllowedIPs = 10.8.0.2/32

Replace eth0 with your actual interface. If you prefer to stay with iptables, install iptables and use this instead:

PostUp = iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE
PostDown = iptables -t nat -D POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE

Two details that are rarely mentioned. First: every PostUp line is executed through a shell, and if one of them fails, wg-quick rolls back the entire start. A typo in the nft rule therefore does not give you a half-working tunnel, it gives you no tunnel at all. Second: AllowedIPs means something completely different on the server side than on the client side. Here it is a mapping table that says which source address belongs to which peer. If you enter the same address for two peers, the one loaded last wins and the other stays silent. Every client gets exactly one /32.

Set the permissions and check the syntax before you start:

chmod 600 /etc/wireguard/wg0.conf
wg-quick strip wg0

wg-quick strip prints the configuration without the wg-quick specific lines. If the command runs through, the file is formally correct. If it reports wg-quick: Line unrecognized, you have usually put an option into the wrong section, for example DNS under [Peer].

Enable IP forwarding permanently

Without forwarding, every packet ends at the server. Turn it on immediately and permanently:

echo 'net.ipv4.ip_forward=1' > /etc/sysctl.d/99-wireguard.conf
sysctl --system
sysctl net.ipv4.ip_forward

The last command has to print net.ipv4.ip_forward = 1. If you want to send IPv6 through the tunnel, net.ipv6.conf.all.forwarding=1 belongs in the same file.

If ufw is running, that is not enough yet. ufw sets its own forwarding policy that applies independently of the kernel switch. Open the port and allow the traffic through explicitly:

ufw allow 51820/udp
ufw route allow in on wg0 out on eth0

The typical symptom of forgotten forwarding is particularly nasty: the handshake works, the ping to 10.8.0.1 works, but nothing behind it is reachable. If you only look at the handshake, you will spend hours searching in the wrong place.

Set up wg-quick as a service

The package ships a template unit, and the name after the @ is the name of the configuration file without the extension:

systemctl enable wg-quick@wg0
systemctl start wg-quick@wg0
systemctl status wg-quick@wg0
journalctl -u wg-quick@wg0 -n 50 --no-pager

One trap that many people only notice weeks later: SaveConfig = true. This option writes the running state back into the file when the service stops. In the process you lose all comments, all PostUp lines in their original order and every bit of structure you added by hand. For a server whose configuration you maintain by hand, leave the option out.

After starting, check the state at the interface rather than at the exit code:

wg show
ip -brief address show wg0
ss -ulpn

ss -ulpn has to show a listener on UDP port 51820. wg show lists the peers, at this point still without a handshake.

Client configuration and a QR code for the phone

The client file is best generated on the server, because all the keys are there anyway. Careful: on the client side, AllowedIPs means something else, namely which destinations should go through the tunnel. 0.0.0.0/0, ::/0 means everything.

mkdir -p /etc/wireguard/clients

Contents of /etc/wireguard/clients/PHONE.conf:

[Interface]
PrivateKey = CONTENTS_OF_PHONE_KEY
Address = 10.8.0.2/32
DNS = 9.9.9.9, 149.112.112.112

[Peer]
PublicKey = CONTENTS_OF_SERVER_PUB
PresharedKey = CONTENTS_OF_PHONE_PSK
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = your.server.address:51820
PersistentKeepalive = 25

PersistentKeepalive = 25 is not a luxury on phones. Mobile network NAT often forgets UDP mappings after 30 to 60 seconds, and without a keepalive the server can no longer reach an established connection on its own initiative.

You generate the QR code directly in the terminal:

qrencode -t ansiutf8 < /etc/wireguard/clients/PHONE.conf

In the WireGuard app, tap the plus, choose the import via QR code and point the camera at the terminal. Two practical notes: shrink the terminal font before you generate the code, otherwise it will not fit on the screen. And do not delete the file straight afterwards, you will need it again when you switch devices. If you do delete it, you have to generate a new key pair, because the private key cannot be recomputed from the public one.

How to tell that it really works

The fact that systemctl start returns without output only means that the interface exists. The actual test has three stages, and you should go through them in this order:

wg show wg0 latest-handshakes
wg show wg0 transfer

Stage one, the handshake. latest-handshakes prints one Unix timestamp per peer. If it says 0, a connection has never been established. In the verbose output of wg show you read latest handshake: 42 seconds ago instead.

Stage two, the data flow. transfer shows received and sent bytes. Sent bytes without received bytes means your packets go out and nothing comes back. That is almost always a firewall or a wrong endpoint, never a key problem.

Stage three, the actual path. On the client, check whether the traffic is routed into the tunnel at all:

ip route get 1.1.1.1

If it says dev wg0, the routing is correct. Only after that does it make sense to look at a page that shows your public IP address. If it shows the address of your server, you are done.

Troubleshooting: no handshake

The most common symptom of all. Start by switching on the logging of the kernel module, it usually delivers the answer within ten seconds:

echo 'module wireguard +p' > /sys/kernel/debug/dynamic_debug/control
dmesg -w

Now start a connection attempt on the client and read along. The three messages that matter:

  • wireguard: wg0: Handshake for peer 1 (...) did not complete after 5 seconds, retrying (try 2) and nothing else. Not a single packet arrives at the server. Cross-check with tcpdump -n -i eth0 udp port 51820. If you see nothing there, the cause is the firewall in front of the server, the port, or the client sitting in a network that filters outgoing UDP. A test from a mobile network instead of the office Wi-Fi separates these cases cleanly.
  • wireguard: wg0: Invalid handshake initiation from .... Packets arrive but do not match. Almost always the public key of the server in the client file is wrong, or the preshared key is configured on one side only. A preshared key has to be identical on both sides or absent on both sides.
  • No message at all, even though tcpdump shows packets. In that case WireGuard is listening on a different port or on a different address. Check with ss -ulpn.

A rarely documented special case is the clock. WireGuard puts a timestamp into the first handshake message, and the server remembers the highest value it has ever seen per peer. It discards older timestamps, which is the protection against replay. If a device had its clock set far into the future and connected once, it will no longer get through after the clock is corrected. The server keeps that state in memory, so the fix is: set the clock correctly, then run wg-quick down wg0 and wg-quick up wg0 on the server. After the rebuild the block is gone.

Switch the logging off again afterwards, it is chatty:

echo 'module wireguard -p' > /sys/kernel/debug/dynamic_debug/control

Troubleshooting: DNS does not work

Symptom: the tunnel is up, ping 1.1.1.1 works, but no name resolves. There are exactly three causes.

First: the resolver is not reachable. If you enter DNS = 10.8.0.1, a name server really has to be listening on that address on the server. A bare Debian or Ubuntu has none. Either you enter a public resolver that is reached through the tunnel, or you set one up yourself. For the second option, dnsmasq with a small file under /etc/dnsmasq.d/wireguard.conf is enough:

interface=wg0
bind-dynamic
no-resolv
server=9.9.9.9
server=1.1.1.1
cache-size=1000

bind-dynamic matters because wg0 may not exist yet when dnsmasq starts. no-resolv is mandatory on Ubuntu: without that line, dnsmasq reads /etc/resolv.conf, finds the systemd-resolved stub address 127.0.0.53 there and builds a loop. And the point that bites most often on Ubuntu: dnsmasq occupies port 53. On a system with systemd-resolved active, that port is already taken, the two services collide, and dnsmasq does not start. The lines interface=wg0 and bind-dynamic are therefore not cosmetic, they keep dnsmasq from binding to every address. Afterwards, use ss -ulpn to confirm that dnsmasq and systemd-resolved are not fighting over port 53.

Second: the resolver is outside the AllowedIPs. If you send only selected networks through the tunnel instead of 0.0.0.0/0 and configure a DNS server whose address is not in that list, the queries go past the tunnel into the local network.

Third, on Linux clients only: resolvconf is missing. The DNS = line is passed on by wg-quick to a program called resolvconf. If it is missing, the start aborts with /usr/bin/wg-quick: line 32: resolvconf: command not found. Check first whether the program exists at all:

command -v resolvconf

When it comes to installing it, the distributions diverge, and that is exactly where copied guides fail on Ubuntu 24.04. There the openresolv package exists neither in main nor in universe, and the call ends with E: Package 'openresolv' has no installation candidate:

SystemMatching package
Debian 11, Debian 12, Debian 13apt-get install -y openresolv
Ubuntu 22.04apt-get install -y openresolv
Ubuntu 24.04apt-get install -y resolvconf

On Ubuntu 24.04, resolvconf is a virtual package that resolves unambiguously to systemd-resolved and creates /usr/sbin/resolvconf in the process, which is exactly the binary that wg-quick calls for the DNS line. You can just as well install systemd-resolved directly there. If you want one line that works on all of the systems named above, use this one:

apt-get install -y openresolv || apt-get install -y resolvconf

On Ubuntu 24.04 there is a variant of this that shows up after an upgrade from 22.04: Failed to resolve interface "tun.wg0": No such device. The cause is a leftover old resolvconf package from 22.04 together with /etc/resolvconf/interface-order, so not the virtual package of the same name from 24.04. Based on that file, wg-quick prefixes the interface name with tun., which the compatibility wrapper of systemd-resolved cannot do anything with. The fix is to remove the old package so that only the wrapper from systemd-resolved remains.

Troubleshooting: MTU problems

The most unpleasant symptom, because everything appears to work. The handshake is up, ping runs, SSH runs, but web pages load halfway and then stall, large downloads break off, and HTTPS of all things is affected. The reason: small packets fit, large ones do not.

WireGuard adds 60 bytes around every packet when the tunnel runs over IPv4 (20 bytes IP, 8 bytes UDP, 32 bytes WireGuard), and 80 bytes over IPv6. wg-quick therefore subtracts a flat 80 bytes from the detected path MTU and ends up at 1420 on a normal 1500 byte path. That is deliberately conservative and correct in most cases.

It is not correct when the path is narrower than 1500, for example with DSL over PPPoE (1492), behind another tunnel or in some mobile networks. Measure the actual path MTU from the client to the public address of the server, with the Don't Fragment bit set and without the tunnel:

ping -M do -s 1472 -c 3 TARGET_ADDRESS

1472 plus 28 bytes of headers makes 1500. If ping: local error: message too long, mtu=... or Frag needed and DF set comes back, lower the value step by step until it gets through: 1464, 1444, 1414, 1372. Add 28 to the value you found and subtract 80. For 1464 that would be a path MTU of 1492 and a tunnel MTU of 1412.

This goes into the [Interface] section, on the side that has the problem:

MTU = 1412

The quick counter-test before you start calculating: set MTU = 1280 as an experiment. That is the smallest MTU IPv6 guarantees, and it works practically everywhere. If your pages load cleanly with it, the MTU was the culprit, and you can work your way towards the optimal value at your leisure. If the problem stays, it is somewhere else.

On the server itself a wrong MTU is rarer, but possible: if the value there is higher than the path allows, you only see the effect with certain destinations. A look at ip -brief address show wg0 and ip link show wg0 shows the value currently set.

Change peers in production without kicking everyone out

The reflex to type systemctl restart wg-quick@wg0 after every change drops all existing connections and rebuilds the NAT rules. On a server with several users that is unnecessarily blunt. WireGuard can reconcile the configuration while it runs:

wg syncconf wg0 <(wg-quick strip wg0)

The command compares the file with the running state and changes only the differences. Existing peers keep their session. Note that the process substitution with <(...) requires bash or zsh, in a plain sh it fails. You can also add a single peer directly:

wg set wg0 peer PUBLIC_KEY allowed-ips 10.8.0.3/32

This change only lives in memory. Write it into the configuration file as well, otherwise the peer is gone after the next restart. That, by the way, is the most common reason behind the sentence "it still worked yesterday".

If you want to run a WireGuard endpoint permanently and with a stable address, your own server is the obvious basis. At KernelHost, KVM root servers and dedicated servers run in the maincubes datacenter in Frankfurt am Main (TÜV TIER3+) on our own network, PrePaid and without a minimum term. Related reading: our articles on hardening SSH and on setting up ufw.

Frequently asked questions

Which WireGuard version is in Debian 13, Debian 12, Ubuntu 24.04 and Ubuntu 22.04?
All four ship the same upstream version of the tools, 1.0.20210914, each with its own distribution revision. The WireGuard module itself has come from the kernel since 5.6 and does not have to be built separately. The commands wg and wg-quick behave identically on all four systems, and the differences are in the packet filter (nftables or iptables), resolvconf and ufw. If you need to install resolvconf, the package is called openresolv on Debian and on Ubuntu 22.04, while Ubuntu 24.04 no longer has openresolv and you install resolvconf instead.
Why do the iptables lines from many guides not work on Debian?
The wireguard-tools package recommends nftables or iptables as an alternative. apt installs the first available alternative, which is nftables. On a slim Debian installation, iptables is therefore not present at all, the PostUp line fails, and that aborts the entire start of wg-quick. Either use the nft variant or install iptables explicitly.
The handshake works, but I cannot reach the internet. What is wrong?
Check in this order: net.ipv4.ip_forward has to be 1, the NAT rule has to name the correct outgoing interface (ip route show default shows it), and with ufw active you additionally need ufw route allow in on wg0 out on eth0. The kernel switch alone is not enough with ufw, because ufw sets its own forwarding policy.
How do I recognize an MTU problem?
The typical picture: the tunnel is up, ping and SSH work, but web pages only load halfway and large downloads break off. Set MTU = 1280 in the [Interface] section of the client as a test. If the problem disappears, it was the MTU. You find the optimal value with ping -M do against the server address by lowering the payload until it gets through, then adding 28 and subtracting 80.
Do I have to run my own DNS server for DNS to work inside the tunnel?
No. You can simply enter a public resolver in the client configuration, which is then reached through the tunnel. Your own resolver on the server (dnsmasq on wg0, for example) is worth it if you want to resolve internal names or cache queries. The only thing that matters: if you enter DNS = 10.8.0.1, a name server really has to be listening there.
Why does a device stop getting through after the clock is corrected?
WireGuard protects itself against replay with a timestamp in the first handshake message. The server remembers the highest value seen per peer and discards older ones. If a device connected once with a clock set in the future, it is rejected after the correction. That state lives in memory, so a wg-quick down wg0 followed by wg-quick up wg0 on the server fixes it.

WireGuard VPN Debian Ubuntu nftables Network Tutorial