
A VPN that takes an afternoon to configure is a VPN that gets configured badly. Most admins have lived this. An OpenVPN server needs certificates, a CA, a pile of directives nobody fully remembers, and a TLS handshake that drags on a bad mobile connection. Then someone leaves the company and nobody knows which client certificate to revoke.
WireGuard changes that. The whole tunnel comes down to a key pair on each side, one UDP port, and a config file you can read in under a minute. It also lives inside the kernel, so packets don’t bounce between kernel space and a userspace daemon. Ubuntu 26.04 LTS makes it easier still. The release shipped on April 23, 2026 with Linux 7.0 on both the GA and HWE stacks, and standard support runs until April 2031. That is a long runway for a VPN gateway you don’t want to rebuild every year.
This guide walks through a real deployment on a fresh Ubuntu 26.04 server. It covers installing the package, generating keys, writing the server config, handling NAT and the firewall, adding clients (including phones), tuning the tunnel, and fixing the things that break. The commands were chosen to also work on Ubuntu 24.04, Debian 12 and 13, and AlmaLinux or Rocky with minor package-manager changes, and those differences are noted where they matter.
What You Need Before Starting
Keep the prerequisites short and check them first. Most failed WireGuard setups come from a missing prerequisite, not from WireGuard.
- An Ubuntu 26.04 LTS server with a public IPv4 address (a small VPS is plenty).
- A sudo user. Ubuntu 26.04 ships sudo-rs by default, which behaves the same for everything in this guide.
- UDP port 51820 reachable from the internet. Check your cloud provider’s security group too, not just the OS firewall.
- A client to test with: a laptop, another Linux box, or a phone.
Confirm the OS and kernel before touching anything:
lsb_release -ds
uname -r
ip -brief address
ip route show default
Note the interface name on the last two lines. It might be eth0, ens3, enp1s0, or something else entirely. Every NAT rule later depends on that name, and guessing it is the number one cause of “the tunnel is up but there’s no internet.”
Why WireGuard Instead of OpenVPN or IPsec?
The kernel module has been in mainline Linux since 5.6, so on Ubuntu 26.04 nothing needs compiling. The codebase is also tiny compared with OpenVPN, which makes it far easier to audit.
In practice, the difference shows up in three places. Handshakes are near-instant. Roaming works properly, so a laptop can move from Wi-Fi to a mobile hotspot and the tunnel keeps going. And there is no daemon to babysit. The interface is just another network device that ip and systemd know how to manage.
There are trade-offs. WireGuard has no built-in user management, no dynamic IP assignment, and no per-user authentication beyond the public key. For 5 to 200 peers, that simplicity is a gift. For thousands of users with SSO requirements, you’ll want a management layer on top, or a different tool.
Installing WireGuard on Ubuntu 26.04
Update the package index first. It’s boring, but it prevents odd dependency mismatches.
sudo apt update
sudo apt full-upgrade -y
sudo apt install -y wireguard iptables qrencode
The wireguard package is a thin metapackage. It pulls in wireguard-tools, which provides the wg and wg-quick commands. The iptables package is included explicitly because gateway-mode NAT rules in this guide use it. qrencode is optional, but it makes onboarding phones painless.
Verify the install:
wg --version
command -v wg-quick
sudo modprobe wireguard
modinfo wireguard | head -n 8
On a tested Ubuntu 26.04 system, the tools reported version v1.0.20250521 and the module loaded from the in-tree kernel path under /lib/modules/. If modprobe complains, you are probably running a stripped-down cloud or container kernel. Check uname -r and consider installing the standard linux-generic kernel.
A quick note on containers. WireGuard inside an LXC or unprivileged container needs the host’s kernel module and the NET_ADMIN capability. Many shared VPS platforms (OpenVZ, for example) simply can’t do it. If the provider’s panel doesn’t mention WireGuard or TUN support, ask before you spend an hour debugging.
Generating Keys the Right Way
WireGuard uses Curve25519 key pairs. Each peer has a private key it never shares and a public key it hands to the other side. Think of it like SSH keys, only simpler.
Create a protected directory and generate the server pair:
sudo mkdir -p /etc/wireguard
sudo chmod 700 /etc/wireguard
cd /etc/wireguard
umask 077
wg genkey | sudo tee server_private.key | wg pubkey | sudo tee server_public.key
The umask 077 matters. Without it, key files can end up world-readable, and wg-quick will warn you about it. Treat the private key like a root password. It should never land in a ticket, a chat message, or a Git repository.
Generate a client pair too. For serious deployments, generate client keys on the client itself so the private key never travels. For a quick lab, doing it on the server is fine:
wg genkey | sudo tee laptop_private.key | wg pubkey | sudo tee laptop_public.key
wg genpsk | sudo tee laptop_psk.key
The pre-shared key is optional. It adds a symmetric layer on top of the Curve25519 exchange, which is a cheap hedge against future cryptographic surprises. It costs nothing, so most admins add it.
Configuring the WireGuard Server
Planning the Address Space
Pick a private subnet that will not collide with anything your clients commonly sit behind. Home routers love 192.168.0.0/24 and 192.168.1.0/24, and many offices use 10.0.0.0/24. A collision gives you confusing routing that is miserable to diagnose. This guide uses 10.8.0.0/24, which is rarely used by home networks, with the server at 10.8.0.1.
Writing wg0.conf
Open the config file:
sudo nano /etc/wireguard/wg0.conf
Paste this, replacing the placeholders with your actual keys and interface name:
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = <contents of server_private.key>
SaveConfig = false
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE
[Peer]
# laptop
PublicKey = <contents of laptop_public.key>
PresharedKey = <contents of laptop_psk.key>
AllowedIPs = 10.8.0.2/32
Lock down the permissions:
sudo chmod 600 /etc/wireguard/wg0.conf
A few lines deserve explanation. %i is replaced by the interface name, which keeps the hooks reusable if you later add wg1. SaveConfig = false stops wg-quick from rewriting your file on shutdown. Leave it on by accident and hand edits can vanish, which is a classic head-scratcher during maintenance windows.
AllowedIPs on the server side has a precise meaning. It is both a routing table and an access-control list. Setting 10.8.0.2/32 says “this peer owns exactly this one address, and only traffic from that source is accepted.” Never put 0.0.0.0/0 there for a client on the server side.
Enabling IP Forwarding
If the server only needs to let peers talk to each other and to the server itself, forwarding is optional. For a full-tunnel gateway, where clients send all their internet traffic through the box, you need it:
echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-wireguard.conf
sudo sysctl --system
sysctl net.ipv4.ip_forward
Using a dedicated file in /etc/sysctl.d/ is cleaner than editing /etc/sysctl.conf. It survives upgrades and tells the next admin exactly why that setting exists. For IPv6 clients, add net.ipv6.conf.all.forwarding=1 as well and give the tunnel a ULA prefix such as fd42:42:42::/64.
Firewall Rules and NAT
If You Use UFW
Ubuntu’s default firewall frontend is UFW. The order of operations here saves you from locking yourself out of a remote box. Allow SSH first, then WireGuard, then enable:
sudo ufw allow OpenSSH
sudo ufw allow 51820/udp
sudo ufw route allow in on wg0 out on eth0
sudo ufw enable
sudo ufw status verbose
Replace eth0 with your real interface. The route allow rule is the one people forget. Without it, UFW’s default forward policy of DROP quietly eats routed client traffic, and the tunnel looks healthy while pages never load.
Since the PostUp hooks above already add FORWARD and MASQUERADE rules, you can use either approach. Some admins prefer to keep NAT in UFW’s before.rules so everything lives in one place. That approach appends a *nat block with a POSTROUTING masquerade for 10.8.0.0/24 and reloads UFW. Pick one method and stick with it. Running both produces duplicate rules, which is harmless but untidy and confusing during audits.
If You Use firewalld (AlmaLinux and Rocky)
sudo firewall-cmd --permanent --add-port=51820/udp
sudo firewall-cmd --permanent --add-masquerade
sudo firewall-cmd --reload
On RHEL-family systems, install with sudo dnf install epel-release followed by sudo dnf install wireguard-tools. The kernel module ships in the stock kernel on current releases. If SELinux is enforcing, the default policy handles wg-quick without custom modules.
Starting the Tunnel
Bring the interface up and make it persistent in one step:
sudo systemctl enable --now wg-quick@wg0
sudo systemctl status wg-quick@wg0 --no-pager
sudo wg show
The wg show output lists the public key, listening port, and each peer. Until a client connects, the peer will show no handshake. That is normal. Also check the address:
ip -brief address show wg0
ss -ulpn | grep 51820
You should see 10.8.0.1/24 on wg0 and a UDP listener on 51820. If you’re on a cloud platform, double-check that its external firewall allows UDP 51820 as well. The OS firewall can be perfect while the provider’s network layer blocks everything.
Configuring a Linux Client
On the client machine, install the same package and create /etc/wireguard/wg0.conf:
[Interface]
Address = 10.8.0.2/24
PrivateKey = <laptop private key>
DNS = 1.1.1.1, 9.9.9.9
[Peer]
PublicKey = <server public key>
PresharedKey = <laptop psk>
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
Then:
sudo chmod 600 /etc/wireguard/wg0.conf
sudo wg-quick up wg0
The client’s AllowedIPs decides what goes through the tunnel. 0.0.0.0/0, ::/0 sends everything, which is a full tunnel. For a split tunnel, list only the networks you want, such as 10.8.0.0/24, 192.168.50.0/24. Split tunneling is usually the better choice for remote workers who just need to reach internal services, since streaming video and software updates stay off your server’s bandwidth bill.
PersistentKeepalive = 25 sends a tiny packet every 25 seconds. It’s required when the client sits behind NAT, because the router will otherwise forget the mapping and inbound packets from the server will die. Servers with public IPs don’t need it.
If you set a DNS line on a client using systemd-resolved, wg-quick calls resolvconf or resolvectl to apply it. If DNS breaks after connecting, installing resolvconf or openresolv often fixes it, or you can manage DNS manually.
Testing the Connection
ping -c 3 10.8.0.1
curl -s https://ifconfig.me
sudo wg show
The curl result should show the server’s public IP if you’re running a full tunnel. On the server, wg show now lists a recent handshake and non-zero transfer counters. A handshake within the last two minutes means the cryptography and the network path both work.
Onboarding Phones With QR Codes
Typing 44-character keys on a phone is nobody’s idea of fun. Build a client file on the server, for example phone.conf, with its own key pair and address 10.8.0.3, then render it:
qrencode -t ansiutf8 < phone.conf
Scan it with the official WireGuard app on Android or iOS. Add the matching [Peer] block to the server with AllowedIPs = 10.8.0.3/32. Delete phone.conf afterwards, or keep it somewhere encrypted. That file contains a private key.
To add peers without restarting the tunnel, use:
sudo wg set wg0 peer <PUBLIC_KEY> allowed-ips 10.8.0.3/32
Then also write it into wg0.conf so it survives a reboot. A neat pattern is the following, which applies config changes live without dropping existing sessions:
sudo bash -c 'wg-quick strip wg0 | wg syncconf wg0 /dev/stdin'
Real-World Scenarios
Secure Admin Access to a Private Network
A common production use is putting SSH, database ports, and dashboards behind the VPN instead of exposing them. Bind those services to the private interface or the 10.8.0.1 address, then close their public ports. The attack surface shrinks to a single UDP port that doesn’t even answer unauthenticated probes. WireGuard stays silent to packets from unknown keys, so port scanners see nothing.
Site-to-Site Between Two Offices
Give each site a gateway running WireGuard, then list the remote LAN in AllowedIPs. If Office A uses 192.168.10.0/24 and Office B uses 192.168.20.0/24, Office A’s peer entry for B contains 10.8.0.2/32, 192.168.20.0/24. Enable forwarding on both gateways and add a route on LAN devices that point to the local gateway, unless the gateway is already the default router.
Surviving Traffic Spikes
WireGuard handles a heavy load well, but the box under it can be the bottleneck. During a spike (a company-wide remote day, a large backup window), the usual culprits are a small VPS with a throttled NIC, single-core encryption limits, or conntrack table exhaustion. Watch ss, nload, and top during the peak rather than guessing.
Performance Tuning
MTU
The default tunnel MTU is 1420, which suits most paths. If you run over PPPoE, a mobile network, or double-encapsulated links, large downloads can stall while small pings succeed. That is the signature of an MTU problem. Test with:
ping -M do -s 1372 10.8.0.1
Reduce the packet size until it succeeds, then set MTU = in the [Interface] section to your working value plus 28 (IPv4 ICMP/IP header overhead). Common stable values are 1380 or 1280 for IPv6-heavy setups.
CPU and Network
Encryption uses ChaCha20-Poly1305, which is fast even without AES hardware instructions. A modest 2 vCPU server can usually saturate a gigabit link. For heavier loads:
- Make sure the NIC has multiple queues (
ethtool -l eth0) so softirq work spreads across cores. - Raise socket buffers for high-latency, high-bandwidth links with
net.core.rmem_maxandnet.core.wmem_max. - Consider enabling BBR congestion control with
net.core.default_qdisc=fqandnet.ipv4.tcp_congestion_control=bbr. It often improves throughput for clients on lossy mobile links. - Increase
net.netfilter.nf_conntrack_maxif you host hundreds of active clients, since each flow takes a conntrack entry.
RAM and Disk I/O
WireGuard itself uses almost no memory. A few megabytes covers dozens of peers. Disk I/O is a non-issue unless logging is verbose. If you turn on dynamic debug (echo 'module wireguard +p' | sudo tee /sys/kernel/debug/dynamic_debug/control), remember to turn it off afterward, because kernel logs fill journald fast. Cap the journal with SystemMaxUse=500M in /etc/systemd/journald.conf as a general habit.
Monitoring
Useful everyday commands:
sudo wg show all latest-handshakes
sudo wg show wg0 transfer
journalctl -u wg-quick@wg0 --since "1 hour ago"
sudo tcpdump -ni eth0 udp port 51820
For metrics, the prometheus_wireguard_exporter project plugs into Prometheus and Grafana nicely. Even without that stack, a cron job that alerts when a critical peer’s last handshake is older than five minutes catches silent failures early.
Troubleshooting Common Problems
Handshake Never Completes
wg show displays the peer with no “latest handshake.” Check, in this order: the UDP port on the cloud firewall, the OS firewall, the endpoint hostname or IP on the client, and whether the public keys are swapped or have a trailing character. Run sudo tcpdump -ni eth0 udp port 51820 on the server while the client tries. If no packets arrive, the problem is upstream of Linux. If packets arrive but no reply leaves, look at the keys and AllowedIPs.
Handshake Works, But No Internet
This is the classic one. Work through it:
- Confirm
sysctl net.ipv4.ip_forwardreturns 1. - Verify the NAT rule:
sudo iptables -t nat -S POSTROUTING. The interface in the rule must match the real outbound interface. - Confirm the forward policy allows it:
sudo ufw route allow in on wg0 out on eth0. - Test raw IP first (
ping 1.1.1.1) before blaming DNS.
Pings Work, Websites Hang
Almost always MTU. Lower it as described earlier. Also check that the client’s DNS line points to a resolver that is reachable through the tunnel.
“RTNETLINK answers: Operation not supported” or “Unknown device type”
The kernel module is missing or unavailable, often in containers or on custom kernels. Run sudo modprobe wireguard and read dmesg | tail.
“resolvconf: command not found”
wg-quick is trying to apply a DNS setting and cannot find a helper. Install resolvconf (or openresolv), or remove the DNS line and manage resolver settings through systemd-resolved.
Service Fails at Boot
If wg-quick@wg0 fails on startup, the cause is often an interface name in PostUp that doesn’t exist yet, or network-online timing. Inspect with journalctl -u wg-quick@wg0 -b. Hostname endpoints on clients can also fail if DNS isn’t up at that moment. In that case, add After=network-online.target nss-lookup.target through a drop-in using sudo systemctl edit wg-quick@wg0.
Locked Out After Enabling UFW
Use the provider’s web console. Allow SSH, and reload. This is exactly why the rule order earlier puts OpenSSH before ufw enable.