WireGuard is a VPN built into the Linux kernel that creates encrypted tunnels with a few lines of configuration. In a full mesh, every server has a direct tunnel to every other server, so there is no central hub to become a bottleneck or a single point of failure. In this tutorial you will connect three Ubuntu 24.04 servers into a WireGuard mesh on the private range 10.10.0.0/24, start it with systemd, give the nodes resolvable names, and optionally route a private subnet behind a node.

Prerequisites

To follow this tutorial you need:

  • Three servers running Ubuntu 24.04 LTS, for example CubePath VPS instances in different locations. The same steps work for more nodes.
  • A non-root user with sudo privileges on each server.
  • A public IP address on each server, reachable from the others on UDP port 51820.

This guide uses the following addressing. Replace the public IPs with your own:

NodePublic IPMesh IP
node-a203.0.113.1110.10.0.1
node-b203.0.113.1210.10.0.2
node-c203.0.113.1310.10.0.3

Step 1 - Installing WireGuard

Run on all three nodes. The WireGuard kernel module ships with the Ubuntu kernel; the wireguard package installs the wg and wg-quick tools:

sudo apt update
sudo apt install wireguard

Check the tools are available:

wg --version
wireguard-tools v1.0.20210914 - https://git.zx2c4.com/wireguard-tools/

Step 2 - Generating a key pair on each node

Every node needs its own private key, which never leaves the server, and the matching public key, which you share with the other nodes. Run on each node. The umask 077 makes the private key readable only by root:

sudo sh -c 'umask 077; wg genkey > /etc/wireguard/private.key'
sudo sh -c 'wg pubkey < /etc/wireguard/private.key > /etc/wireguard/public.key'

Print the public key and write it down next to the node name:

sudo cat /etc/wireguard/public.key
kQ3hZ8X3o0Qm4m0XfJc2yXz4mJ0oS6xM1f3vKj6b9Bk=

You now have three public keys. In the configurations below they are called NODE_A_PUBLIC_KEY, NODE_B_PUBLIC_KEY and NODE_C_PUBLIC_KEY.

Step 3 - Writing the WireGuard configuration

Each node gets an [Interface] section with its own private key and mesh address, and one [Peer] section for every other node. AllowedIPs has two jobs: it tells the node which destination addresses to send through that peer, and it is the list of source addresses accepted from that peer. In a mesh, each peer's AllowedIPs is just its own /32.

wg-quick expects the private key inline, so print it on the node you are configuring:

sudo cat /etc/wireguard/private.key

On node-a, create the configuration file:

sudo nano /etc/wireguard/wg0.conf
[Interface]
PrivateKey = NODE_A_PRIVATE_KEY
Address = 10.10.0.1/24
ListenPort = 51820

# node-b
[Peer]
PublicKey = NODE_B_PUBLIC_KEY
Endpoint = 203.0.113.12:51820
AllowedIPs = 10.10.0.2/32

# node-c
[Peer]
PublicKey = NODE_C_PUBLIC_KEY
Endpoint = 203.0.113.13:51820
AllowedIPs = 10.10.0.3/32

On node-b:

sudo nano /etc/wireguard/wg0.conf
[Interface]
PrivateKey = NODE_B_PRIVATE_KEY
Address = 10.10.0.2/24
ListenPort = 51820

# node-a
[Peer]
PublicKey = NODE_A_PUBLIC_KEY
Endpoint = 203.0.113.11:51820
AllowedIPs = 10.10.0.1/32

# node-c
[Peer]
PublicKey = NODE_C_PUBLIC_KEY
Endpoint = 203.0.113.13:51820
AllowedIPs = 10.10.0.3/32

On node-c:

sudo nano /etc/wireguard/wg0.conf
[Interface]
PrivateKey = NODE_C_PRIVATE_KEY
Address = 10.10.0.3/24
ListenPort = 51820

# node-a
[Peer]
PublicKey = NODE_A_PUBLIC_KEY
Endpoint = 203.0.113.11:51820
AllowedIPs = 10.10.0.1/32

# node-b
[Peer]
PublicKey = NODE_B_PUBLIC_KEY
Endpoint = 203.0.113.12:51820
AllowedIPs = 10.10.0.2/32

The file contains a private key, so restrict it on every node:

sudo chmod 600 /etc/wireguard/wg0.conf

Step 4 - Opening the firewall

WireGuard only needs its UDP port. If UFW is active, run on every node:

sudo ufw allow 51820/udp
sudo ufw status
To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
51820/udp                  ALLOW       Anywhere

To accept the tunnel only from the other mesh members, replace the rule with one rule per peer, for example sudo ufw allow from 203.0.113.12 to any port 51820 proto udp.

Traffic arriving through wg0 is also filtered by UFW. To let the mesh nodes reach each other's services, allow the mesh subnet on the tunnel interface:

sudo ufw allow in on wg0 from 10.10.0.0/24

Step 5 - Starting the mesh with systemd

The wg-quick@wg0 unit brings the interface up from /etc/wireguard/wg0.conf and adds the routes. Enable it at boot and start it now on all three nodes:

sudo systemctl enable --now wg-quick@wg0

Confirm the interface and its address:

ip -br addr show wg0
wg0              UNKNOWN        10.10.0.1/24

Test the tunnels from node-a:

ping -c 3 10.10.0.2
ping -c 3 10.10.0.3
64 bytes from 10.10.0.2: icmp_seq=1 ttl=64 time=18.4 ms
64 bytes from 10.10.0.2: icmp_seq=2 ttl=64 time=17.9 ms
64 bytes from 10.10.0.2: icmp_seq=3 ttl=64 time=17.9 ms

Repeat the pings from node-b to 10.10.0.3 to prove the node-b to node-c tunnel works too. Now look at the peer status:

sudo wg show
interface: wg0
  public key: kQ3hZ8X3o0Qm4m0XfJc2yXz4mJ0oS6xM1f3vKj6b9Bk=
  private key: (hidden)
  listening port: 51820

peer: 2dVq1G9vS5n0mYp8zjB1r6k0q0T3wXy5eH7uLc4aN0s=
  endpoint: 203.0.113.12:51820
  allowed ips: 10.10.0.2/32
  latest handshake: 21 seconds ago
  transfer: 1.02 KiB received, 1.21 KiB sent

peer: Yh8mP3cK1tR6bN2wQ9vZ4xL0sF7jD5gA1eU8oI3yT6c=
  endpoint: 203.0.113.13:51820
  allowed ips: 10.10.0.3/32
  latest handshake: 19 seconds ago
  transfer: 948 B received, 1.13 KiB sent

A latest handshake line for each peer means the tunnel is established. WireGuard only performs a handshake when there is traffic, so a peer you have not pinged yet shows no handshake.

Step 6 - Giving mesh nodes names

Mesh addresses are easier to use with names. The simplest reliable option for a small mesh is /etc/hosts. Edit it on every node:

sudo nano /etc/hosts

Add these lines at the end:

10.10.0.1 node-a.mesh node-a
10.10.0.2 node-b.mesh node-b
10.10.0.3 node-c.mesh node-c

Verify resolution and connectivity by name:

getent hosts node-b.mesh
ping -c 2 node-c
10.10.0.2       node-b.mesh node-b
PING node-c.mesh (10.10.0.3) 56(84) bytes of data.
64 bytes from node-c.mesh (10.10.0.3): icmp_seq=1 ttl=64 time=24.1 ms

Services that must only be reachable inside the mesh, such as a database or a metrics exporter, can now listen on the 10.10.0.x address instead of the public one.

Step 7 - Routing a private subnet through a node (optional)

If a node also has a private network behind it, for example node-c on 192.168.30.0/24, the other nodes can reach that subnet through node-c's tunnel. On node-a and node-b, add the subnet to the AllowedIPs of the node-c peer:

sudo nano /etc/wireguard/wg0.conf
# node-c
[Peer]
PublicKey = NODE_C_PUBLIC_KEY
Endpoint = 203.0.113.13:51820
AllowedIPs = 10.10.0.3/32, 192.168.30.0/24

wg-quick creates a route for every AllowedIPs entry, so no extra ip route commands are needed. Restart the interface to apply the new route:

sudo systemctl restart wg-quick@wg0
ip route show dev wg0
10.10.0.0/24 proto kernel scope link src 10.10.0.1
192.168.30.0/24 scope link

On node-c, enable IP forwarding permanently so it passes packets between wg0 and its private interface:

echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-wireguard-forward.conf
sudo sysctl --system

If UFW is active on node-c, allow forwarding from the mesh to the subnet, replacing eth1 with node-c's private interface:

sudo ufw route allow in on wg0 out on eth1 from 10.10.0.0/24 to 192.168.30.0/24

Finally, the hosts in 192.168.30.0/24 need a route back to 10.10.0.0/24 via node-c's private IP, unless node-c is already their default gateway. Test from node-a with a host in the subnet:

ping -c 3 192.168.30.10

Troubleshooting

  • No latest handshake for a peer: check that UDP 51820 is open on both sides, that the Endpoint IP is correct and that each side has the other's public key (not its own, and not a private key). sudo journalctl -u wg-quick@wg0 shows configuration errors.
  • Handshake works but ping fails: the destination is not covered by the peer's AllowedIPs, or UFW drops traffic on wg0. Check with sudo wg show wg0 allowed-ips and sudo ufw status verbose.
  • wg-quick fails with Address already in use or RTNETLINK answers: File exists: the interface is already up from a manual wg-quick up. Run sudo wg-quick down wg0, then sudo systemctl start wg-quick@wg0.
  • Large transfers hang while ping works: an MTU problem on the underlay path. Set MTU = 1380 in the [Interface] section and restart the unit.

Conclusion

You built a three-node WireGuard mesh on Ubuntu 24.04 where every server has a direct encrypted tunnel to every other one, started by systemd at boot and reachable by name. Adding a node means generating its keys, giving it a [Peer] section for each existing node and adding its [Peer] section to all of them. As the mesh grows, consider generating the configuration with Ansible, or a WireGuard-based tool such as Headscale or Netmaker that distributes peers automatically.