Setting up a VPN with WireGuard
The VPN and tunneling protocols article described WireGuard's handshake and configuration shape without ever bringing an actual tunnel up. This lab does that: two machines, each with its own keypair, exchanging traffic through a tunnel you configure and verify yourself.
Goal and end result
By the end of this lab, you'll have a working WireGuard tunnel between two hosts, confirmed with wg show that a real handshake completed, and pinged one host through the tunnel's own private address rather than its normal network address.
Topology
+------------------+ +------------------+
| peer-a (server) | | peer-b (client) |
| wg0: 10.8.0.1 |<---- WireGuard --->| wg0: 10.8.0.2 |
| real: 192.168.100.10 | real: 192.168.100.11
+------------------+ +------------------+
\_____________ same network reachable directly ______________/
Both peers can already reach each other's real address directly — the tunnel doesn't create reachability that didn't exist, it adds an encrypted, authenticated path on top of reachability that already does. That's deliberate for this lab: isolating the WireGuard-specific behavior from also having to debug basic network reachability at the same time.
Requirements
- Two Linux hosts (VMs or containers) that can already reach each other — the virtual home network lab's Docker setup works directly for this, with
NET_ADMINcapability, which WireGuard's tunnel interface needs. wireguard-toolsinstalled on both.
Safety note
This lab's tunnel carries no real traffic worth protecting and uses freshly generated keys that exist only for this exercise — there's no key material here worth safeguarding beyond the scope of the lab itself. On a real deployment, a WireGuard private key is exactly as sensitive as an SSH private key, and should be generated with the same umask 077 discipline used below, never committed to version control, and never transmitted anywhere except directly onto the host that needs it.
Step 1: install WireGuard tools on both peers
Step 2: generate a keypair on each peer
cat privatekey
YJ2ld0hMpPNrKus1tJCLxyknnAp9ge86nGI+p1XwM3o=
cat publickey
d1m6jYClsToBNPgC1QGV90TB9VeUj7bin/BXZuL9u1I=
umask 077 ensures the private key file is created readable only by its owner from the moment it's written — WireGuard's own documentation treats a leaked private key exactly as seriously as a leaked SSH key, since it's what lets a peer authenticate as itself. Run this on both peer-a and peer-b; each needs its own distinct keypair, never a shared one.
Step 3: configure peer-a (the "server" side)
# /etc/wireguard/wg0.conf on peer-a
[Interface]
PrivateKey = <peer-a's private key>
Address = 10.8.0.1/24
ListenPort = 51820
[Peer]
PublicKey = <peer-b's public key>
AllowedIPs = 10.8.0.2/32
This matches the shape the VPN and tunneling protocols article already introduced — a private key for this interface, an address on the tunnel's own private range, and a [Peer] block naming the other side by its public key. AllowedIPs here is deliberately narrow, 10.8.0.2/32, a single address — this side only expects traffic from and to peer-b's tunnel address specifically, not an entire subnet.
Step 4: configure peer-b (the "client" side)
# /etc/wireguard/wg0.conf on peer-b
[Interface]
PrivateKey = <peer-b's private key>
Address = 10.8.0.2/24
[Peer]
PublicKey = <peer-a's public key>
Endpoint = 192.168.100.10:51820
AllowedIPs = 10.8.0.1/32
PersistentKeepalive = 25
peer-b needs an Endpoint — the real, reachable address of peer-a — since it's the side initiating contact; peer-a doesn't need one because it's the side waiting to be reached. PersistentKeepalive = 25 sends a keepalive packet every 25 seconds, which matters specifically when this peer sits behind NAT: without it, a NAT gateway's mapping for this connection can expire from inactivity, and peer-a would have no way to re-initiate contact since it never has an Endpoint recorded for peer-b in the first place.
Step 5: bring up both interfaces and verify with wg show
[#] ip link add wg0 type wireguard
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.8.0.1/24 dev wg0
[#] ip link set mtu 1420 up dev wg0
Run this on both peers, then check the handshake state:
interface: wg0
public key: wSOxBmQxKueg5URlDVlQyI+GlrS1pK1kPgpp4AlHrHA=
private key: (hidden)
listening port: 51820
peer: uhez9BCyErD1wnrf1OP4lPiLB1RaHdnzP3nYYjzWbjM=
endpoint: 192.168.100.11:38959
allowed ips: 10.8.0.2/32
latest handshake: 2 seconds ago
transfer: 180 B received, 92 B sent
This is peer-a's own view: its own public key, then a peer: block for peer-b. Notice the endpoint port here — 38959 — is an ephemeral port peer-b's kernel picked for its outbound connection, not peer-b's ListenPort; peer-a never configured one because it's the side waiting to be reached. The exact byte counts and port number will differ on every run — what matters is that they're present and nonzero at all.
latest handshake: 2 seconds ago is the single most important line here — it confirms an actual Noise-based handshake completed recently, not just that the interface exists and is configured. An interface with no handshake line at all, or one that never updates from "never," means the two sides haven't successfully exchanged the initial handshake messages yet, which is the first thing to check if the tunnel doesn't seem to be passing traffic.
Step 6: confirm traffic actually flows through the tunnel
Run this from peer-b, addressing peer-a by its tunnel address, not its real one:
PING 10.8.0.1 (10.8.0.1) 56(84) bytes of data.
64 bytes from 10.8.0.1: icmp_seq=1 ttl=64 time=0.412 ms
64 bytes from 10.8.0.1: icmp_seq=2 ttl=64 time=0.389 ms
64 bytes from 10.8.0.1: icmp_seq=3 ttl=64 time=0.401 ms
64 bytes from 10.8.0.1: icmp_seq=4 ttl=64 time=0.397 ms
--- 10.8.0.1 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3052ms
A successful reply here confirms the whole chain worked: both keypairs matched what the other side's config expected, the handshake completed, and the encrypted tunnel is carrying real IP traffic — the exact tunneling mechanism VPN basics described generically, now running with WireGuard specifically as the tunnel's implementation. Re-checking wg show afterward should show the transfer counters have grown, confirming this specific traffic passed through the tunnel rather than some other path.
Troubleshooting
wg shownever shows a handshake. The two most common causes are a public key transcription error — copy-pasting keys is error-prone, and a single wrong character produces total silence rather than a clear error — orpeer-b'sEndpointpointing at an address or portpeer-aisn't actually reachable on. Double-check both configs against each other's actual generated keys before anything else.- The handshake completes but
pingover the tunnel address fails. CheckAllowedIPson both sides — this field doubles as an access-control list, and a peer configured withAllowedIPsthat doesn't include the address actually being pinged will silently drop that traffic even with a working handshake. - The tunnel works, then stops after a period of inactivity, specifically on the side behind NAT. This is precisely what
PersistentKeepaliveexists to prevent — confirm it's actually set on the peer sitting behind NAT, not just the one that doesn't need it.
Cleanup
Run on both peers, then confirm the interface is actually gone:
An empty result (no output at all) confirms no WireGuard interface remains active.
Evaluation criterion
You've completed this lab correctly if wg show on both peers shows a recent handshake timestamp that keeps updating, and a ping to the tunnel address (not the real network address) succeeds with a plausible round-trip time for a local network.
WireGuard's configuration stayed manageable here specifically because it has no negotiable cipher suite to configure — the VPN and tunneling protocols article already contrasted that directly with IPsec's much larger surface of negotiable parameters. The next lab sets up a VPN protocol from that other family — OpenVPN, built on TLS instead of a purpose-built handshake — and the configuration difference between the two will be immediately visible.
Sources
- WireGuard, Quick Start
- WireGuard, Protocol & Cryptography