How to Set Up a WireGuard VPN Server on Ubuntu 24.04 (2026)
This guide is for anyone who needs a self-hosted WireGuard VPN server, whether that is a break-glass path into a VPC, a replacement for a public bastion host, or a way to reach a homelab from a phone. At the end you will have a WireGuard server on Ubuntu 24.04 that starts at boot under systemd, NATs client traffic out to the internet or your private network, and serves configs to laptops and phones (by QR code for the phones). I checked every command against the current wg(8) and wg-quick(8) man pages and Ubuntu 24.04 (noble), which ships wireguard-tools 1.0.20210914 on its 6.8 kernel with the WireGuard module built in. You need an Ubuntu 24.04 VM with a public IP, root or sudo, and the ability to open UDP port 51820 in your cloud security group or router. If you are still deciding between plain WireGuard and a mesh VPN like Tailscale or NetBird, read WireGuard vs Tailscale in 2026 first.
Step 1: Install WireGuard and open UDP 51820
The kernel module is already in Ubuntu 24.04's kernel; you only need the userspace tools. Install qrencode now as well for phone configs later, and nftables if you take the nftables route in Step 4.
# Ubuntu 24.04 (noble). wireguard-tools provides wg and wg-quick.
sudo apt update
sudo apt install -y wireguard-tools qrencode nftables
# Confirm the module loads (prints a wireguard line when loaded)
sudo modprobe wireguard && lsmod | grep wireguard
# Find your public interface name; it is often ens3, ens5 or enp1s0, not eth0
ip route show default
# default via 203.0.113.1 dev ens3 proto dhcp src 203.0.113.10 metric 100
Note the interface after dev (here ens3). Every NAT rule below uses it, and getting it wrong is the single most common reason a tunnel connects but nothing routes.
Then open UDP 51820 in your cloud firewall (security group, VPC firewall rule or NSG) or forward it on your router. WireGuard never answers unauthenticated packets, so you cannot test the port with nc or a port scanner; it will always look closed. You will confirm it with a handshake in Step 6.
Step 2: Generate server and client keys
WireGuard keys are Curve25519 key pairs encoded as base64. The wg(8) man page's own example sets umask 077 first so the private key is never world-readable.
sudo -i
cd /etc/wireguard
umask 077
# Server key pair
wg genkey | tee server.key | wg pubkey > server.pub
# One key pair per client; never reuse a key across devices
wg genkey | tee laptop.key | wg pubkey > laptop.pub
wg genkey | tee phone.key | wg pubkey > phone.pub
# Optional: a preshared key per peer adds a symmetric layer on top of Curve25519
wg genpsk > laptop.psk
wg genpsk > phone.psk
ls -l /etc/wireguard
# -rw------- 1 root root 45 ... server.key (keys are 44 base64 chars plus a newline)
A preshared key is optional. The protocol mixes it into the handshake (the psk2 in Noise_IKpsk2), which is the usual hedge against a future quantum attack on recorded Curve25519 handshakes. It costs nothing at runtime, so I add one.
Step 3: Write the server wg0.conf
Pick a private subnet that does not clash with any network your clients sit on. 10.8.0.0/24 is a common choice; avoid 192.168.0.0/24 and 192.168.1.0/24, which half of all home routers use.
# /etc/wireguard/wg0.conf (mode 600, owned by root)
[Interface]
# Server address inside the tunnel
Address = 10.8.0.1/24
ListenPort = 51820
# Paste the contents of /etc/wireguard/server.key
PrivateKey = <server.key contents>
# Keep false: true makes wg-quick overwrite this file on shutdown
SaveConfig = false
# NAT rules from Step 4 (nftables variant shown; remove if you use ufw)
PostUp = nft -f /etc/wireguard/wg0-nat.nft
PostDown = nft delete table ip wg0nat
[Peer]
# laptop
PublicKey = <laptop.pub contents>
PresharedKey = <laptop.psk contents>
# A /32 per client: this is both its route and its source-address filter
AllowedIPs = 10.8.0.2/32
[Peer]
# phone
PublicKey = <phone.pub contents>
PresharedKey = <phone.psk contents>
AllowedIPs = 10.8.0.3/32
On the server each peer's AllowedIPs is a single /32. WireGuard uses that list both to decide which peer gets an outbound packet and to drop inbound packets whose source address does not belong to the sender, so two peers must never share an address. Leave MTU unset for now: wg-quick(8) derives it from the endpoint or default route, which the man page calls usually a sane choice.
Step 4: Enable IP forwarding and NAT
Without forwarding, the server receives client packets and drops them. Without NAT, they leave with a 10.8.0.x source address that nobody on the internet can reply to.
# Persist forwarding across reboots
cat <<'EOF' | sudo tee /etc/sysctl.d/99-wireguard.conf
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
sudo sysctl --system | grep forward
# net.ipv4.ip_forward = 1
# net.ipv6.conf.all.forwarding = 1
Now pick one NAT method. Do not mix them.
Option A: nftables (no ufw)
This keeps the rules in a dedicated table that wg-quick creates on up and deletes on down, so it never collides with anything else.
# /etc/wireguard/wg0-nat.nft (replace ens3 with your interface from Step 1)
cat <<'EOF' | sudo tee /etc/wireguard/wg0-nat.nft
table ip wg0nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 10.8.0.0/24 oifname "ens3" masquerade
}
}
EOF
sudo nft -c -f /etc/wireguard/wg0-nat.nft && echo "syntax OK"
The -c flag checks the file without applying it.
Option B: ufw
If ufw is already active on the box, let ufw own the rules instead. Drop the PostUp and PostDown lines from wg0.conf, then add a NAT block to the top of /etc/ufw/before.rules, above the existing *filter line.
# 1. Prepend NAT to /etc/ufw/before.rules (replace ens3)
sudo sed -i '1i *nat\n:POSTROUTING ACCEPT [0:0]\n-A POSTROUTING -s 10.8.0.0/24 -o ens3 -j MASQUERADE\nCOMMIT\n' /etc/ufw/before.rules
# 2. Allow the WireGuard port and forwarding from wg0 out to the public interface
sudo ufw allow 51820/udp
sudo ufw allow OpenSSH # do this before enabling ufw on a remote box
sudo ufw route allow in on wg0 out on ens3
# 3. Reload
sudo ufw enable
sudo ufw reload
sudo ufw status verbose | grep -E "51820|wg0"
The ufw(8) man page for noble documents the ufw route allow in on ... out on ... form and notes that ufw installs with a forward policy of deny, which is why the route rule is needed. It also warns that its own /etc/ufw/sysctl.conf and the system sysctl settings can override each other; the /etc/sysctl.d file above is the one I rely on, so leave forwarding commented out in ufw's copy.
Step 5: Start wg0 with wg-quick and systemd
sudo systemctl enable --now wg-quick@wg0
sudo systemctl status wg-quick@wg0 --no-pager
sudo wg show wg0
# interface: wg0
# public key: <server.pub>
# private key: (hidden)
# listening port: 51820
#
# peer: <laptop.pub>
# preshared key: (hidden)
# allowed ips: 10.8.0.2/32
No latest handshake line yet, because no client has connected. To add or change peers later without dropping existing sessions, edit wg0.conf and sync the running interface rather than restarting it:
# Apply wg0.conf changes to the live interface without a restart
sudo bash -c 'wg syncconf wg0 <(wg-quick strip wg0)'
wg-quick strip prints only the parts of the file that wg understands, and wg syncconf applies the difference.
Step 6: Create client configs and QR codes
Each client gets its own file. The only choice that matters is AllowedIPs on the client side, which decides what goes through the tunnel.
# laptop.conf (split tunnel: only the VPN and a private subnet go through wg)
[Interface]
PrivateKey = <laptop.key contents>
Address = 10.8.0.2/32
# Optional; requires resolvconf on Linux clients (see Troubleshooting)
DNS = 10.0.0.2
[Peer]
PublicKey = <server.pub contents>
PresharedKey = <laptop.psk contents>
Endpoint = 203.0.113.10:51820
# Split tunnel: the WireGuard subnet plus your VPC CIDR
AllowedIPs = 10.8.0.0/24, 10.0.0.0/16
# Needed when the client sits behind NAT and rarely sends traffic
PersistentKeepalive = 25
For a full tunnel, where all traffic leaves via the server, change one line:
# Full tunnel: everything, IPv4 and IPv6
AllowedIPs = 0.0.0.0/0, ::/0
wg-quick(8) notices a default route in AllowedIPs and uses policy routing rules to override the default gateway, so the encrypted UDP packets to the server itself still take the normal route. Split tunnel is what I use for infrastructure access; full tunnel is for untrusted Wi-Fi. The wg(8) man page uses 25 seconds as its example PersistentKeepalive for a peer behind NAT and says most users will not need it at all, but phones and laptops almost always sit behind NAT, so set it on clients and leave it off the server.
Generate a QR code for the phone and scan it in the official WireGuard app:
# Prints a scannable QR code in the terminal
qrencode -t ansiutf8 < /etc/wireguard/phone.conf
# Or write a PNG to copy off the box (then delete it)
qrencode -t png -o phone.png < /etc/wireguard/phone.conf
The QR code contains the phone's private key, so treat the screen and any PNG as a secret. Then bring the laptop up and check for a handshake:
# On a Linux laptop
sudo wg-quick up ./laptop.conf
ping -c 3 10.8.0.1
# On the server: a recent handshake and non-zero transfer mean it works
sudo wg show wg0 latest-handshakes
A peer with a handshake timestamp of 0 has never completed a handshake. Since WireGuard rekeys roughly every 120 seconds (Rekey-After-Time in the whitepaper), an active peer's handshake should never be more than about two minutes old.
Troubleshooting
The most searched WireGuard error is the client log line "Handshake did not complete after 5 seconds, retrying". The 5 seconds is Rekey-Timeout, and the message means the client sent an initiation and heard nothing back. Because the server is silent to anything it cannot authenticate, a wrong key and a blocked port look identical from the client. Work through the table top to bottom.
| Symptom | Likely cause | Fix |
|---|---|---|
| Handshake did not complete after 5 seconds, retrying | UDP 51820 blocked by security group, NSG or router | Allow inbound UDP 51820; run sudo tcpdump -ni ens3 udp port 51820 on the server and confirm packets arrive |
| Packets arrive on the server but still no handshake | Client has the wrong server public key, or server has the wrong client public key | Compare wg show wg0 on both ends with the .pub files; regenerate if in doubt |
| Handshake works, cannot reach anything past 10.8.0.1 | IP forwarding off | sysctl net.ipv4.ip_forward must print 1; recheck /etc/sysctl.d/99-wireguard.conf |
| Handshake works, no internet in full tunnel | NAT rule on the wrong interface name | Compare ip route show default with the oifname or -o in your rule |
| Worked until Docker was installed | Docker sets the iptables FORWARD policy to DROP when it enables forwarding itself | Add an explicit accept for wg0 in the DOCKER-USER chain, or use the ufw route rule |
resolvconf: command not found on wg-quick up |
A DNS = line with no resolvconf on the client |
On Ubuntu 24.04 install systemd-resolved, which provides resolvconf (openresolv is not packaged for noble), or remove the DNS line |
| Ping works, HTTPS and SSH hang | MTU too large for the path (PPPoE, nested tunnels, some mobile networks) | Set MTU = 1280 in the client [Interface], test, then raise it |
| Second client knocks the first one offline | Two peers share an address or a key | Give every device its own key pair and its own /32 |
| Phone connects on Wi-Fi, not on mobile data | Carrier NAT drops idle UDP mappings | Set PersistentKeepalive = 25 on the phone |
| Server config edits vanish after restart | SaveConfig = true overwrote the file on shutdown |
Set SaveConfig = false and use wg syncconf for live changes |
For deeper digging on Linux, the kernel module can log handshake attempts to the kernel log when dynamic debug is available:
# Turn on WireGuard debug logging (needs debugfs; Secure Boot lockdown may block it)
echo 'module wireguard +p' | sudo tee /sys/kernel/debug/dynamic_debug/control
sudo dmesg -w | grep wireguard
# Turn it off again
echo 'module wireguard -p' | sudo tee /sys/kernel/debug/dynamic_debug/control
If the server logs nothing at all while the client retries, the packets are not reaching it, and the problem is a firewall or the Endpoint address. If the server logs invalid handshake messages, the problem is keys.
FAQ
What port does WireGuard use?
WireGuard has no fixed port. ListenPort is whatever you set, and 51820 is the conventional choice used in the official examples. If you omit it, the wg(8) man page says a port is chosen randomly, which is fine for clients but not for a server others need to find. It is always UDP.
What is the difference between split tunnel and full tunnel in WireGuard?
It is entirely the client's AllowedIPs. Listing only private ranges such as 10.8.0.0/24, 10.0.0.0/16 sends only that traffic through the tunnel; listing 0.0.0.0/0, ::/0 sends everything. The server config does not change between the two.
How do I add a WireGuard peer without restarting?
Add a [Peer] block to wg0.conf, then run wg syncconf wg0 <(wg-quick strip wg0) as root. Existing peers keep their sessions. You can also use wg set wg0 peer <pubkey> allowed-ips 10.8.0.4/32, but then remember to add it to the file too.
Is a single WireGuard server enough for a team?
For a handful of people and a break-glass path, yes. Past that, manual key distribution and revocation become the real cost, and a mesh VPN with SSO is usually the better tool; the trade-offs are in the companion explainer.
When a WireGuard tunnel misbehaves in production, the first thing I check is wg show on the server: no handshake means firewall or keys, a fresh handshake with zero received bytes means forwarding or NAT. I keep wg0.conf and the NAT file in configuration management, never edit live peers by hand without syncing the file, and I harden the box the same way as any other internet-facing host (the SSH hardening checklist applies). The primary references are the WireGuard quick start, the wg(8) and wg-quick(8) man pages, and the protocol description.
Related: How to Fix SSH Permission Denied (publickey): Causes and Fixes (2026), How to Build an AWS VPC With Terraform: Public and Private Subnets (2026) and How to Troubleshoot DNS with dig: NXDOMAIN, SERVFAIL, DNSSEC (2026).
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Harden Ubuntu 24.04 With an Ansible Playbook (2026)
Step-by-step Ansible playbook to harden Ubuntu 24.04: pinned ansible-core 2.21.6, inventory, SSH drop-in that beats 50-cloud-init.conf, ufw, unattended-upgrades, sudo, fail2ban, auditd, sysctl, --check --diff, ansible-lint and fixes for common errors.
13 minNov 10, 2026How to Expose a Local Service With Cloudflare Tunnel (2026)
Step-by-step Cloudflare Tunnel guide for cloudflared 2026.10: install, create a remotely managed tunnel, add a public hostname, run it as a systemd service, in Docker Compose or on Kubernetes, put Cloudflare Access in front, and fix error 1033, 502 and 1016.
12 minOct 31, 2026How to Deploy to Cloud Run with GitHub Actions: Step-by-Step (2026)
Deploy a container to Google Cloud Run from GitHub Actions in 2026 with no service account keys: Artifact Registry, Workload Identity Federation with auth@v3, deploy-cloudrun@v3, Secret Manager, tagged canary revisions, rollbacks, custom domains, and fixes for the PORT 8080 startup error.
15 min