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
sudoprivileges 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:
| Node | Public IP | Mesh IP |
|---|---|---|
| node-a | 203.0.113.11 | 10.10.0.1 |
| node-b | 203.0.113.12 | 10.10.0.2 |
| node-c | 203.0.113.13 | 10.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
NoteAll three nodes have public IPs, so
PersistentKeepaliveis not needed. If a node sits behind NAT, addPersistentKeepalive = 25to the[Peer]sections on that node so the NAT mapping stays open, and omitEndpointfor it on the other nodes: they learn its address from its first handshake.
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 handshakefor a peer: check that UDP 51820 is open on both sides, that theEndpointIP 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@wg0shows configuration errors. - Handshake works but ping fails: the destination is not covered by the peer's
AllowedIPs, or UFW drops traffic onwg0. Check withsudo wg show wg0 allowed-ipsandsudo ufw status verbose. wg-quickfails withAddress already in useorRTNETLINK answers: File exists: the interface is already up from a manualwg-quick up. Runsudo wg-quick down wg0, thensudo systemctl start wg-quick@wg0.- Large transfers hang while ping works: an MTU problem on the underlay path. Set
MTU = 1380in 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.
