Skip to content

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_ADMIN capability, which WireGuard's tunnel interface needs.
  • wireguard-tools installed 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

apt-get update && apt-get install -y wireguard-tools

Step 2: generate a keypair on each peer

umask 077
wg genkey | tee privatekey | wg pubkey > publickey
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

wg-quick up wg0
[#] 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:

wg show
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

ping -c 4 10.8.0.1

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 show never 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 — or peer-b's Endpoint pointing at an address or port peer-a isn't actually reachable on. Double-check both configs against each other's actual generated keys before anything else.
  • The handshake completes but ping over the tunnel address fails. Check AllowedIPs on both sides — this field doubles as an access-control list, and a peer configured with AllowedIPs that 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 PersistentKeepalive exists to prevent — confirm it's actually set on the peer sitting behind NAT, not just the one that doesn't need it.

Cleanup

wg-quick down wg0
[#] ip link delete dev wg0

Run on both peers, then confirm the interface is actually gone:

wg show

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