Debian

WireGuard VPN Setup: Ubuntu 26.04/24.04, Debian 13, Rocky 10

Advertising disclosure: We earn commissions when you shop through the links on this page. Read full disclosure.

A WireGuard server is one UDP port, one key pair and about ten lines of config. The parts that break are around it: forwarding, NAT, the firewall and DNS on the client. This guide shows how to set up a WireGuard VPN on Linux as a full-tunnel server for laptops and phones, then links two networks site to site.

Original content from computingforgeeks.com - post 64197

The same server steps cover Ubuntu, Debian and Rocky Linux. Where the distributions differ (package source, firewall, the NAT rule), each gets its own block. Every error in the troubleshooting section came up in the lab, with the exact message the tools printed. Everything below was run in October 2026 on Ubuntu 26.04.1 and 24.04.5, Debian 13 and Rocky Linux 10.2 with SELinux enforcing, using wireguard-tools 1.0.20250521 on Ubuntu 26.04 and Rocky, and 1.0.20210914 on Ubuntu 24.04 and Debian. The steps are unchanged since that run; we rechecked the links in this guide in November 2026.

WireGuard VPN server architecture diagram: laptop 10.10.0.2, phone 10.10.0.3 and a third client 10.10.0.4 connect to wg0 10.10.0.1/24 on UDP 51820, the server forwards and masquerades through eth0 to the internet for clients using AllowedIPs 0.0.0.0/0

That is the server this guide builds. It exposes a single UDP port, and every client owns one /32 inside 10.10.0.0/24. Forwarding plus the MASQUERADE rule on eth0 is what turns a working handshake into working internet access. When a tunnel connects but pages never load, that last hop is the culprit almost every time.

What you need

One Linux server with a public IP (or a port forward for UDP 51820 from your router), sudo access and at least one client. WireGuard is cheap to run. The kernel does the crypto, so the real sizing knob is the uplink: every byte a full-tunnel client sends leaves the server twice, once in and once out. Our lab servers had 2 vCPU and 2 GB RAM, a floor for following along that is far more than a few dozen peers need. The one hard requirement is the kernel module, and every distribution in this guide ships it.

The distributions differ in three places. Keep this table open while you work:

ItemUbuntu 26.04 / 24.04Debian 13Rocky Linux 10
Packagewireguard (main)wireguard (main)wireguard-tools (AppStream, no EPEL)
wireguard-tools build1.0.20250521 / 1.0.202109141.0.202109141.0.20250521
Firewallufwnone by defaultfirewalld
NAT for clientsiptables in PostUpiptables in PostUpfirewalld masquerade
iptables binaryinstalledoptional priority, often missingnot installed
Kernel support statussupportedsupportedTechnology Preview (dmesg warns)

Install WireGuard on Ubuntu and Debian

The module is already in the kernel. The wireguard package only pulls in wireguard-tools, which provides wg and wg-quick:

sudo apt update
sudo apt install -y wireguard iptables

Name iptables explicitly. Ubuntu ships it, but Debian 13 marks it optional, and wireguard-tools only recommends nftables | iptables, which nftables already satisfies. Without it the PostUp NAT rules below fail. Confirm the tools are on the path:

wg --version

Ubuntu 26.04 printed the newer build. Ubuntu 24.04 and Debian 13 both carry the 2021 release, which handles everything in this guide:

wireguard-tools v1.0.20250521 - https://git.zx2c4.com/wireguard-tools/

Either build runs the same config syntax, so the version gap never changes a step below.

Install WireGuard on Rocky Linux

Older guides tell you to enable EPEL first. On Rocky Linux 10 and AlmaLinux 10 that step is unnecessary, because wireguard-tools sits in AppStream:

sudo dnf install -y wireguard-tools

Load the module and check that it registered:

sudo modprobe wireguard
lsmod | grep wireguard

The module and its UDP tunnel helpers show up:

wireguard             122880  0
ip6_udp_tunnel         16384  1 wireguard
udp_tunnel             36864  1 wireguard
curve25519_x86_64      36864  1 wireguard
libcurve25519_generic    49152  2 curve25519_x86_64,wireguard

Check dmesg and you will find this line, logged when the module loads:

[   13.928901] TECH PREVIEW: WireGuard may not be fully supported.
               Please review provided documentation for limitations.

That is not an error. Red Hat still lists WireGuard as an unsupported Technology Preview in RHEL 10, and Rocky inherits the kernel build. The tunnel works the same. The catch is a support contract: if you run paid RHEL, Red Hat will not take tickets on it. The dnf transaction also pulled in systemd-resolved as a dependency of wireguard-tools, but leaves it disabled and inactive. That only matters when this host is a client with a DNS = line, covered in the errors section below.

Generate the server keys

Each peer has a private key that never leaves the machine and a public key you hand to the other side. The package creates /etc/wireguard as mode 700 owned by root, so every read and write there needs sudo. Generate the pair in one pipeline:

wg genkey | sudo tee /etc/wireguard/server.key | wg pubkey | sudo tee /etc/wireguard/server.pub
sudo chmod 600 /etc/wireguard/server.key

The only thing printed is the public key. That is the value clients put in their [Peer] block:

eHX/lgUwf+TmiRhGnv5Hc2KQOEA8FuQFod0l5rSXs20=

Running cat /etc/wireguard/server.key without sudo fails with Permission denied on the directory, not the file. Use sudo cat whenever a later step needs a key.

Write the server config

The NAT rule must name the interface that faces the internet. It is eth0 on most cloud images, ens18 or enp1s0 elsewhere. Detect it instead of guessing:

export WAN_IF=$(ip route show default | awk '{print $5; exit}')
echo "WAN interface: ${WAN_IF}"

Our server reported:

WAN interface: eth0

That is the interface the NAT rule masquerades out of, so note yours before you write the config.

Ubuntu and Debian: NAT in PostUp

Create the interface file:

sudo vim /etc/wireguard/wg0.conf

Paste this exactly. The two _HERE placeholders are filled in by the next command, so leave them as they are:

[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = SERVER_PRIVATE_KEY_HERE
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -s 10.10.0.0/24 -o WAN_IF_HERE -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -D POSTROUTING -s 10.10.0.0/24 -o WAN_IF_HERE -j MASQUERADE

Swap in the interface name and the private key from the file, so the key never passes through your clipboard:

sudo sed -i "s|WAN_IF_HERE|${WAN_IF}|g" /etc/wireguard/wg0.conf
sudo sed -i "s|SERVER_PRIVATE_KEY_HERE|$(sudo cat /etc/wireguard/server.key)|" /etc/wireguard/wg0.conf
sudo chmod 600 /etc/wireguard/wg0.conf

%i expands to the interface name, so the same lines work for wg1. The NAT rule is scoped to -s 10.10.0.0/24, which keeps it from rewriting anything else that leaves the box. The second FORWARD rule lets replies back in on a host whose FORWARD policy is DROP and has no stateful rule of its own.

Rocky Linux: let firewalld do the NAT

Rocky Linux 10 has no iptables command, so the PostUp lines above fail with iptables: command not found. firewalld handles masquerading natively, which leaves a shorter file:

sudo vim /etc/wireguard/wg0.conf

Only the interface section is needed:

[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = SERVER_PRIVATE_KEY_HERE

Fill in the key and lock the file down:

sudo sed -i "s|SERVER_PRIVATE_KEY_HERE|$(sudo cat /etc/wireguard/server.key)|" /etc/wireguard/wg0.conf
sudo chmod 600 /etc/wireguard/wg0.conf

The 600 leaves the file readable by root alone, which is what a file holding the server’s private key should have.

Turn on IP forwarding

Linux drops packets between interfaces until forwarding is on. Skip this and clients still get a handshake and can ping 10.10.0.1, so the tunnel looks healthy while nothing reaches the internet. All three distributions read the same drop-in:

echo "net.ipv4.ip_forward = 1" | sudo tee /etc/sysctl.d/99-wireguard.conf
sudo sysctl -p /etc/sysctl.d/99-wireguard.conf

sysctl echoes the value it applied:

net.ipv4.ip_forward = 1

This guide gives clients IPv4 tunnel addresses only, so IPv6 forwarding stays off. The client config later still routes ::/0 into the tunnel, where it goes nowhere. That keeps a dual-stack laptop from sending IPv6 traffic around the VPN.

Open the firewall

ufw on Ubuntu

Allow SSH first so enabling ufw cannot cut your session, then the WireGuard port:

sudo ufw allow OpenSSH
sudo ufw allow 51820/udp
sudo ufw --force enable
sudo ufw status verbose

The routed default stays at deny:

Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           ALLOW IN    Anywhere
51820/udp                  ALLOW IN    Anywhere
22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)
51820/udp (v6)             ALLOW IN    Anywhere (v6)             

Many guides tell you to flip DEFAULT_FORWARD_POLICY to ACCEPT in /etc/default/ufw. That opens forwarding for every interface on the box. It is not needed here: the FORWARD rules in PostUp cover wg0 only. We rebooted the server with ufw active and the tunnel still passed traffic, because the ufw chains return to FORWARD and our two rules sit after them.

Debian ships without a firewall. If you run nftables or ufw there, open UDP 51820 the same way. A cloud server also needs UDP 51820 allowed in its security group. The ufw guide covers the rest of the rule set.

firewalld on Rocky Linux

A standard Rocky install already runs firewalld. The GenericCloud image does not, so install it if firewall-cmd --state fails:

sudo dnf install -y firewalld
sudo systemctl enable --now firewalld

Open the port, masquerade traffic leaving the public zone, and put wg0 in the trusted zone so forwarded client traffic is accepted:

sudo firewall-cmd --permanent --add-port=51820/udp
sudo firewall-cmd --permanent --zone=public --add-masquerade
sudo firewall-cmd --permanent --zone=trusted --add-interface=wg0
sudo firewall-cmd --reload

The trusted zone also accepts anything a VPN client sends to the server itself (SSH, a web UI). For an admin VPN that is usually the point. If clients should not reach the server itself, add a firewalld policy instead of using trusted. Check the public zone:

sudo firewall-cmd --list-all --zone=public

Look for the port and masquerade: yes:

public (default, active)
  target: default
  ingress-priority: 0
  egress-priority: 0
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services: cockpit dhcpv6-client ssh
  ports: 51820/udp
  protocols:
  forward: yes
  masquerade: yes
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules: 

SELinux stayed enforcing for the whole test. ausearch -m avc -ts recent returned <no matches> after clients connected, so no booleans or custom policy are needed.

Start the tunnel and enable it at boot

Let systemd own the interface from the start:

sudo systemctl enable --now wg-quick@wg0
sudo wg show wg0

With no peers yet, the interface shows only its own key and port:

interface: wg0
  public key: eHX/lgUwf+TmiRhGnv5Hc2KQOEA8FuQFod0l5rSXs20=
  private key: (hidden)
  listening port: 51820

If you already ran wg-quick up wg0 by hand, the unit fails with wg-quick: `wg0' already exists. Run sudo wg-quick down wg0, then start the unit.

Create a client

Generate the client keys on the client itself when you can. The private key then never crosses the network. On a Linux laptop with wireguard-tools installed:

wg genkey | sudo tee /etc/wireguard/client.key | wg pubkey | sudo tee /etc/wireguard/client.pub
sudo chmod 600 /etc/wireguard/client.key

Copy the printed public key to the server and register it there, replacing the example key with yours. Each client gets the next free /32:

export CLIENT_PUBKEY="x5KmdMEx2bNb/psSgYhjdhj/wYlJYikLR1BZaWd9GWg="
sudo wg set wg0 peer "${CLIENT_PUBKEY}" allowed-ips 10.10.0.2/32
sudo wg-quick save wg0

wg set adds the peer to the running interface without dropping anyone. wg-quick save writes the live state back to wg0.conf, PostUp lines included:

[Interface]
Address = 10.10.0.1/24
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -s 10.10.0.0/24 -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -D POSTROUTING -s 10.10.0.0/24 -o eth0 -j MASQUERADE
ListenPort = 51820
PrivateKey = (redacted)

[Peer]
PublicKey = x5KmdMEx2bNb/psSgYhjdhj/wYlJYikLR1BZaWd9GWg=
AllowedIPs = 10.10.0.2/32

On the client, create its interface file:

sudo vim /etc/wireguard/wg0.conf

Add the following. The placeholders are filled in by the next command:

[Interface]
Address = 10.10.0.2/32
PrivateKey = CLIENT_PRIVATE_KEY_HERE
DNS = 1.1.1.1, 9.9.9.9

[Peer]
PublicKey = SERVER_PUBLIC_KEY_HERE
Endpoint = SERVER_ENDPOINT_HERE:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25

Set the public key of the server and its public IP or DNS name (find the IP with curl -4 -s ifconfig.me on the server), then substitute all three values:

export SERVER_PUBKEY="eHX/lgUwf+TmiRhGnv5Hc2KQOEA8FuQFod0l5rSXs20="
export SERVER_ENDPOINT="203.0.113.10"
sudo sed -i -e "s|CLIENT_PRIVATE_KEY_HERE|$(sudo cat /etc/wireguard/client.key)|" \
  -e "s|SERVER_PUBLIC_KEY_HERE|${SERVER_PUBKEY}|" \
  -e "s|SERVER_ENDPOINT_HERE|${SERVER_ENDPOINT}|" /etc/wireguard/wg0.conf
sudo chmod 600 /etc/wireguard/wg0.conf

AllowedIPs = 0.0.0.0/0, ::/0 is a full tunnel: everything goes through the server. For a split tunnel that only reaches the VPN and an office subnet, list those instead, for example 10.10.0.0/24, 192.168.10.0/24, and drop the DNS line if you want the normal resolver of the client. PersistentKeepalive = 25 keeps the NAT mapping alive on home and mobile connections.

Connect and verify the tunnel

Bring the client up:

sudo wg-quick up wg0

wg-quick prints each step. The fwmark rules and table 51820 are how a full tunnel routes everything through wg0 without also routing its own encrypted packets back into itself:

[#] ip link add wg0 type wireguard
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.10.0.2/32 dev wg0
[#] ip link set mtu 1420 up dev wg0
[#] resolvconf -a wg0 -m 0 -x
[#] wg set wg0 fwmark 51820
[#] ip -6 rule add not fwmark 51820 table 51820
[#] ip -6 rule add table main suppress_prefixlength 0
[#] ip -6 route add ::/0 dev wg0 table 51820
[#] nft -f /dev/fd/63
[#] ip -4 rule add not fwmark 51820 table 51820
[#] ip -4 rule add table main suppress_prefixlength 0
[#] ip -4 route add 0.0.0.0/0 dev wg0 table 51820
[#] sysctl -q net.ipv4.conf.all.src_valid_mark=1
[#] nft -f /dev/fd/63

On the server, the peer should now carry a recent handshake and traffic counters in both directions:

sudo wg show

Ours, after a few requests from the laptop:

interface: wg0
  public key: eHX/lgUwf+TmiRhGnv5Hc2KQOEA8FuQFod0l5rSXs20=
  private key: (hidden)
  listening port: 51820

peer: x5KmdMEx2bNb/psSgYhjdhj/wYlJYikLR1BZaWd9GWg=
  endpoint: 198.51.100.24:57083
  allowed ips: 10.10.0.2/32
  latest handshake: 56 seconds ago
  transfer: 10.85 KiB received, 9.81 KiB sent

A handshake proves the keys match. It does not prove the server forwards. Test the path out from the client:

tracepath -n -m 3 1.1.1.1

The first hop is the tunnel address of the server, then the server’s own upstream router:

 1?: [LOCALHOST]                      pmtu 1420
 1:  10.10.0.1                                             0.969ms
 1:  10.10.0.1                                             0.541ms
 2:  192.168.1.1                                           1.002ms
 3:  203.0.113.188                                         3.625ms

From a client on a different network, curl -sS https://ifconfig.me should now print the server’s public IP instead of your own. Our lab client and server shared one internet uplink, so both addresses were the same there and tracepath was the proof.

On clients that run systemd-resolved, such as Ubuntu, confirm the DNS servers from the config are attached to wg0:

resolvectl status wg0

The ~. routing domain sends every lookup to the tunnel resolvers, not the ones on eth0:

Link 3 (wg0)
    Current Scopes: DNS
         Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Current DNS Server: 1.1.1.1
       DNS Servers: 1.1.1.1 9.9.9.9
        DNS Domain: ~.

Make the client tunnel persistent with sudo systemctl enable --now wg-quick@wg0 once it works by hand (bring it down first). For a mesh with automatic key distribution instead of hand-managed peers, Tailscale runs WireGuard underneath.

Add more peers without dropping anyone

The second way to add a peer is to edit the file and reload. The reload action of the unit runs wg syncconf, which applies peer changes to the live interface without touching existing sessions:

systemctl cat wg-quick@wg0 | grep ExecReload

It strips the wg-quick-only keys and syncs the rest:

ExecReload=/bin/bash -c 'exec /usr/bin/wg syncconf %i <(exec /usr/bin/wg-quick strip %i)'

A reload makes the live interface match the file, so any peer added with wg set but never saved with wg-quick save is removed. Save first, or always edit the file.

For a phone, generating the keys on the server is the practical route because the result goes into a QR code. Keep per-client files in their own directory:

sudo mkdir -p /etc/wireguard/clients
wg genkey | sudo tee /etc/wireguard/clients/phone.key | wg pubkey | sudo tee /etc/wireguard/clients/phone.pub
sudo chmod 600 /etc/wireguard/clients/phone.key

Append the phone as a peer and reload:

printf "\n[Peer]\nPublicKey = %s\nAllowedIPs = 10.10.0.3/32\n" "$(sudo cat /etc/wireguard/clients/phone.pub)" | sudo tee -a /etc/wireguard/wg0.conf
sudo systemctl reload wg-quick@wg0
sudo wg show wg0 allowed-ips

Both peers are live, and the laptop’s session was never interrupted:

x5KmdMEx2bNb/psSgYhjdhj/wYlJYikLR1BZaWd9GWg=	10.10.0.2/32
0oEotyqD6HV5BekSMY9rfNBpfGUEASJsgGdbcIDhTyM=	10.10.0.3/32

Each peer keeps its own /32 in that map, and adding one never touches the tunnels the others already have.

Connect a phone with a QR code

Build the phone config from the keys on the server. Replace 203.0.113.10 with the public address of your server:

printf "[Interface]\nAddress = 10.10.0.3/32\nPrivateKey = %s\nDNS = 1.1.1.1, 9.9.9.9\n\n[Peer]\nPublicKey = %s\nEndpoint = 203.0.113.10:51820\nAllowedIPs = 0.0.0.0/0, ::/0\nPersistentKeepalive = 25\n" "$(sudo cat /etc/wireguard/clients/phone.key)" "$(sudo cat /etc/wireguard/server.pub)" | sudo tee /etc/wireguard/clients/phone.conf > /dev/null
sudo chmod 600 /etc/wireguard/clients/phone.conf

Install qrencode (sudo apt install -y qrencode, or sudo dnf install -y qrencode with EPEL enabled on Rocky) and print the code in the terminal. The -r flag reads the file as root, so no shell redirect has to open it:

sudo qrencode -t ansiutf8 -r /etc/wireguard/clients/phone.conf

In the WireGuard app on iOS or Android, tap the plus button, choose to scan a QR code and point the camera at the terminal. The code contains the private key of the phone, so clear the screen afterwards and do not screenshot it.

Windows and macOS use the official apps from wireguard.com/install. Both import the same .conf file: “Import tunnel(s) from file”, then Activate. A laptop that should stay connected on hotel Wi-Fi without any client at all is a job for a travel router that runs WireGuard itself.

A site-to-site tunnel joins whole subnets. Here an office LAN (192.168.10.0/24) reaches a cloud network (172.16.0.0/24) through one gateway on each side. Neither side needs a routing protocol. Both gateways are separate machines from the road-warrior server above, so they reuse wg0, port 51820 and 10.10.0.0/24. If you add a site link to that same server instead, give it wg1, its own subnet such as 10.20.0.0/24 and port 51821, or it collides with the laptop at 10.10.0.2:

WireGuard site-to-site topology diagram: office LAN 192.168.10.0/24 routes to the Site A gateway at 192.168.10.1 with wg0 10.10.0.1, a UDP 51820 tunnel links it to the Site B gateway wg0 10.10.0.2, which masquerades office traffic into the cloud VPC 172.16.0.0/24

Each gateway lists the subnet of the other side in AllowedIPs. That single line is both the crypto routing table and the reason wg-quick adds a kernel route for the remote subnet. The two remaining problems are getting traffic to the gateway and getting replies back, and the lab hit both of them.

Generate a key pair on each gateway the same way as the server (site.key and site.pub), and turn on IP forwarding on both. Open UDP 51820 on at least one side, ideally both: in the cloud security group for Site B, and as a port forward on the office router for Site A. With PersistentKeepalive on both ends, the side behind NAT dials out and holds the link up. Then create the Site A config on the office gateway:

sudo vim /etc/wireguard/wg0.conf

The gateway configs use iptables hooks, so these gateways run Ubuntu or Debian. On a Rocky gateway, drop the hooks, put wg0 in the trusted zone and enable masquerade on Site B, as in the server firewall section. Site A forwards in both directions and does no NAT. Office hosts keep their real addresses:

[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = SITE_A_PRIVATE_KEY_HERE
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT

[Peer]
PublicKey = SITE_B_PUBLIC_KEY_HERE
Endpoint = SITE_B_ENDPOINT_HERE:51820
AllowedIPs = 10.10.0.2/32, 172.16.0.0/24
PersistentKeepalive = 25

On the cloud gateway, open its file:

sudo vim /etc/wireguard/wg0.conf

Site B adds one NAT rule for office traffic leaving toward the cloud hosts. LAN_IF_HERE is the interface that faces 172.16.0.0/24. On a single-NIC cloud VM that is the same eth0 it uses for everything:

[Interface]
Address = 10.10.0.2/24
ListenPort = 51820
PrivateKey = SITE_B_PRIVATE_KEY_HERE
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -s 192.168.10.0/24 -o LAN_IF_HERE -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -s 192.168.10.0/24 -o LAN_IF_HERE -j MASQUERADE

[Peer]
PublicKey = SITE_A_PUBLIC_KEY_HERE
Endpoint = SITE_A_ENDPOINT_HERE:51820
AllowedIPs = 10.10.0.1/32, 192.168.10.0/24
PersistentKeepalive = 25

Fill in the Site A placeholders on the office gateway, replacing the example keys and addresses with yours. Use the Site B public key (from its site.pub) and its public address:

export PEER_PUBKEY="g5xqZVF2wZ80H2UrFCzEy021kN+/WRbuZliVMp8SVUY="
export PEER_ENDPOINT="198.51.100.20"
sudo sed -i -e "s|SITE_A_PRIVATE_KEY_HERE|$(sudo cat /etc/wireguard/site.key)|" \
  -e "s|SITE_B_PUBLIC_KEY_HERE|${PEER_PUBKEY}|" \
  -e "s|SITE_B_ENDPOINT_HERE|${PEER_ENDPOINT}|" /etc/wireguard/wg0.conf
sudo chmod 600 /etc/wireguard/wg0.conf
sudo systemctl enable --now wg-quick@wg0

Then the mirror image on the cloud gateway, which also needs its LAN interface name:

export PEER_PUBKEY="blO4G5ce2E/9BN5f+TowHom/aK2dwRpDAoCkEu7rQxs="
export PEER_ENDPOINT="192.0.2.10"
export LAN_IF=$(ip route show default | awk '{print $5; exit}')
sudo sed -i -e "s|SITE_B_PRIVATE_KEY_HERE|$(sudo cat /etc/wireguard/site.key)|" \
  -e "s|SITE_A_PUBLIC_KEY_HERE|${PEER_PUBKEY}|" \
  -e "s|SITE_A_ENDPOINT_HERE|${PEER_ENDPOINT}|" \
  -e "s|LAN_IF_HERE|${LAN_IF}|g" /etc/wireguard/wg0.conf
sudo chmod 600 /etc/wireguard/wg0.conf
sudo systemctl enable --now wg-quick@wg0

The LAN_IF detection assumes a single-NIC cloud VM. If the gateway has a separate interface for 172.16.0.0/24, set that name instead. On Site B, the peer and the kernel route for the office subnet should appear:

sudo wg show
ip route | grep wg0

Handshake done, 192.168.10.0/24 routed into the tunnel:

interface: wg0
  public key: g5xqZVF2wZ80H2UrFCzEy021kN+/WRbuZliVMp8SVUY=
  private key: (hidden)
  listening port: 51820

peer: blO4G5ce2E/9BN5f+TowHom/aK2dwRpDAoCkEu7rQxs=
  endpoint: 192.0.2.10:51820
  allowed ips: 10.10.0.1/32, 192.168.10.0/24
  latest handshake: 4 seconds ago
  transfer: 124 B received, 180 B sent
  persistent keepalive: every 25 seconds
10.10.0.0/24 dev wg0 proto kernel scope link src 10.10.0.2
192.168.10.0/24 dev wg0 scope link

The handshake timestamp and the two routes are the lines to check whenever a site link connects but moves no traffic.

Point office hosts at the gateway

An office PC sends 172.16.0.0/24 to its default router, which knows nothing about the tunnel. Our test PC’s pings to 172.16.0.20 failed at 100% loss until it had a route through the WireGuard box. Add it on the PC, or once on the office router so every host inherits it:

sudo ip route add 172.16.0.0/24 via 192.168.10.1

That route is gone after a reboot. Make it permanent in the PC’s netplan or NetworkManager profile, or better, add it once on the office router, or push it to every host with DHCP option 121. If the WireGuard box is the default gateway of the office already, this step is not needed.

Fix the return path on the cloud side

The first test ran without the MASQUERADE rule on Site B, and the office PC still lost every ping. In the lab the cloud side was a separate LAN segment behind the gateway, and a capture on the Site B LAN interface showed why:

sudo tcpdump -nni "${LAN_IF}" icmp or arp

Requests arrive. The cloud host then asks for its own default router (.254 in the lab) to send the reply, and that router has never heard of 192.168.10.0/24. On AWS or GCP you will not see the ARP, because the VPC router is virtual, but the result is the same: requests reach the host and replies go to the VPC router, which drops them:

13:49:39.504173 IP 192.168.10.20 > 172.16.0.20: ICMP echo request, id 2832, seq 1, length 64
13:49:39.504202 ARP, Request who-has 172.16.0.254 tell 172.16.0.20, length 28
13:49:40.506687 ARP, Request who-has 172.16.0.254 tell 172.16.0.20, length 28
13:49:40.563552 IP 192.168.10.20 > 172.16.0.20: ICMP echo request, id 2832, seq 2, length 64

With the MASQUERADE rule in the Site B PostUp, office traffic arrives from the gateway address itself and the reply goes straight back to it. From the office PC:

ping -c3 172.16.0.20
tracepath -n 172.16.0.20

All three replies come back, through the Site A LAN address and then the Site B tunnel address:

--- 172.16.0.20 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 1.334/2.331/3.833/1.080 ms
 1?: [LOCALHOST]                      pmtu 1500
 1:  192.168.10.1                                          0.073ms
 1:  192.168.10.1                                          0.012ms
 2:  192.168.10.1                                          0.019ms pmtu 1420
 2:  10.10.0.2                                             1.214ms 

NAT makes the link one way. Office hosts can open connections to the cloud, but a cloud host pinging 192.168.10.20 still failed in our test, and cloud logs only ever see the gateway address. For both directions with real source IPs, drop the MASQUERADE rule and add a route for 192.168.10.0/24 pointing at the gateway in the VPC route table. On AWS that also means disabling source/destination checks on the gateway instance. With that route in place, pings worked both ways without NAT.

Real errors and their fixes

Each of these came up during the lab runs, with the message exactly as printed.

curl: (6) Could not resolve host: ifconfig.me

The tunnel is up and the handshake is fine, but the server does not forward. In a full tunnel the first thing to break is DNS, because the query to 1.1.1.1 goes into the tunnel and dies at the server. The client could still ping 10.10.0.1:

curl: (6) Could not resolve host: ifconfig.me

Check sysctl net.ipv4.ip_forward on the server. It read 0 here, and enabling forwarding fixed it with no client change. If forwarding is 1, check that the MASQUERADE rule names the right interface: sudo iptables -t nat -S POSTROUTING.

/bin/wg-quick: line 295: iptables: command not found

An iptables-based PostUp on a host without the iptables command: Rocky Linux 10 by default, and Debian 13 whenever iptables was not installed. wg-quick rolls the interface back when a hook fails:

[#] iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -s 10.10.0.0/24 -o eth0 -j MASQUERADE
/bin/wg-quick: line 295: iptables: command not found
[#] ip link delete dev wg0

On Debian, sudo apt install -y iptables and bring the tunnel up again. Debian reports /usr/bin/wg-quick in the message. On Rocky, remove the PostUp and PostDown lines and use the firewalld commands from the firewall section. Installing iptables-nft also works there, but then firewalld and your hooks manage the same tables.

/usr/bin/wg-quick: line 32: resolvconf: command not found

A client config with a DNS = line on a Debian system that does not run systemd-resolved, which is the normal state of a netinst server install. wg-quick hands DNS to resolvconf, does not find it, and tears the interface down:

[#] resolvconf -a wg1 -m 0 -x
/usr/bin/wg-quick: line 32: resolvconf: command not found
[#] ip link delete dev wg1

Install openresolv, which manages /etc/resolv.conf directly, and bring the tunnel up again:

sudo apt install -y openresolv

On systems that do run systemd-resolved (Ubuntu and the Debian cloud image), /usr/sbin/resolvconf is a symlink to resolvectl and works as is.

Failed to set DNS configuration: Could not activate remote peer ‘org.freedesktop.resolve1’

A Rocky Linux client with a DNS = line. Installing wireguard-tools pulls in systemd-resolved but leaves it disabled, and resolvconf on Rocky is a symlink to resolvectl, so wg-quick fails and removes the interface:

[#] resolvconf -a wg9 -m 0 -x
Failed to set DNS configuration: Could not activate remote peer 'org.freedesktop.resolve1': activation request failed: unknown unit
[#] ip link delete dev wg9

Enable the resolver and the same config comes up, with the DNS server attached to the tunnel. The other option is to drop the DNS = line:

sudo systemctl enable --now systemd-resolved

Bringing the interface up after that succeeds with the DNS line left in place.

wg-quick: `wg0′ already exists

The systemd unit cannot start because a manual wg-quick up already created the interface. systemctl status wg-quick@wg0 shows failed and the journal has the line above. Run sudo wg-quick down wg0 and start the unit again.

No “latest handshake” line and 0 B received

WireGuard never logs a failed handshake on the client. The only symptom is in wg show: no handshake line, and bytes sent but none received. We reproduced it by putting a wrong server public key in the client config:

peer: dr6UkaUWEaMwLYDN3SxLsh6vntXXj0DEU1s68Lh/5U8=
  endpoint: 203.0.113.10:51820
  allowed ips: 10.10.0.0/24
  transfer: 0 B received, 148 B sent
  persistent keepalive: every 25 seconds

The same picture appears when UDP 51820 is blocked anywhere on the path (cloud security group, home router, ufw). Compare the keys first: sudo cat /etc/wireguard/server.pub on the server must match the client [Peer] PublicKey character for character. Then check the port. Clock skew between peers does not matter, but a client clock that jumps backwards does: the server rejects handshake timestamps older than the newest it has seen from that peer, until the clock catches up or the server restarts.

Clients stall for about a minute after a server reboot

After we rebooted the Rocky server, the first curl from an idle client timed out with curl: (28) Connection timed out after 15002 milliseconds. The client still held session keys the server had lost, and it only re-handshakes on its retry timer. The server showed a fresh handshake roughly a minute after boot and traffic resumed with no action on either side. Scripts that test the VPN right after a reboot need a retry loop, not a single attempt.

Harden the server before you hand out configs

Add a preshared key to each peer. It mixes a symmetric secret into the handshake, which protects recorded traffic against a future break of Curve25519. Generate one per peer with wg genpsk, save it as a root-only file and put PresharedKey = <value> in the matching [Peer] block on both ends. On a running interface, sudo wg set wg0 peer <pubkey> preshared-key /etc/wireguard/site.psk applies it without a restart. We added one to the site link this way and wg show then listed preshared key: (hidden) for the peer.

Move SSH behind the tunnel once the VPN works, using the firewall rather than ListenAddress 10.10.0.1 in sshd. sshd starts before wg0 exists at boot, fails to bind that address and stays down, which locks you out after the next reboot. Before you touch the rules, have console access (cloud serial console or Proxmox/IPMI) ready as the way back in, and confirm from a connected client that ssh to 10.10.0.1 works with your usual login. Then, on ufw, allow SSH from the tunnel subnet and drop the open rule:

sudo ufw allow from 10.10.0.0/24 to any port 22 proto tcp
sudo ufw delete allow OpenSSH

On Rocky, wg0 is already in the trusted zone, so removing SSH from the public zone is enough: sudo firewall-cmd --permanent --zone=public --remove-service=ssh, then sudo firewall-cmd --reload. Remove cockpit the same way (--remove-service=cockpit) unless you use it. Only UDP 51820 is then left facing the internet. WireGuard does not answer unauthenticated packets, so with a DROP policy (the ufw default) a scan cannot tell that port from a closed one. firewalld’s default zone rejects closed ports with an ICMP error instead, so there the silent port still stands out. Keep Fail2ban on SSH until you have made the move.

Remove lost devices immediately: sudo wg set wg0 peer <pubkey> remove, then sudo wg-quick save wg0. Keep a list that maps every /32 and public key to an owner. With twenty peers nobody remembers which key is the old phone. sudo wg show wg0 latest-handshakes prints a Unix timestamp per peer, which is easy to alert on from cron when a site link goes quiet.

Patch the kernel, not a package. WireGuard fixes ship in kernel updates on all three distributions, so a server that skips reboots skips VPN fixes. For a Rocky-only deployment with SELinux notes and more firewalld detail, see the Rocky and AlmaLinux WireGuard guide. If you need TCP fallback through hostile networks that block UDP, OpenVPN is still the tool, because WireGuard is UDP only.

A tunnel this broad has a ceiling: once every peer reaches every service, access control stops being about the network and starts being about identity, which is the problem the zero trust model takes on. Moving a working VPN toward it is mostly a reordering of the firewall and peer rules you already have, not a rebuild. Our zero trust books for network engineers shortlist covers that shift and the order to roll it out in.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP Php PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP Upgrade a Single Package on Ubuntu / Debian with apt Debian Upgrade a Single Package on Ubuntu / Debian with apt APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian Cheat Sheets APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian Install and Configure Squid Proxy on RHEL 10 / Rocky Linux 10 Networking Install and Configure Squid Proxy on RHEL 10 / Rocky Linux 10

Leave a Comment

Press ESC to close