Skip to content
← All posts
· 7 min read· By

WireGuard: A Minimal VPN Tunnel Between Two Hosts

A working minimal example – generate key pairs, write wg0.conf for both sides, wg-quick up, verify the connection – plus the three classic pitfalls: AllowedIPs, the UDP port and PersistentKeepalive.

WireGuardVPNNetworkingLinuxSelf-HostingDevOpsSecurity

Two servers need to talk to each other – a database port, a metrics endpoint, a backup target – but not across the open internet. The classic route would be an IPsec or OpenVPN setup with certificates, daemon configuration and an afternoon of reading. WireGuard reduces that to two files of about five lines each. It has been in the Linux kernel since 5.6, speaks UDP only, and does no cipher suite negotiation: if you hold the right key you are in, and if you do not, you do not even get a reply. This post builds a complete point-to-point tunnel between two hosts – not a deep dive into cryptokey routing, but the working minimum plus the core idea behind it.

For context: the post Cloudflare Tunnel: Publishing Services Without Open Ports solves a different problem. There, the goal is to make a web service publicly reachable via a third-party provider. Here we build a private network between machines you own, with nobody else in the data path.

What WireGuard Does – and When to Reach for It

WireGuard creates a virtual network interface, conventionally named wg0. Whatever goes into that interface comes back out encrypted on the other side. To your applications it looks like an ordinary local network with its own IP addresses – except that the two ends are spread across the internet.

The one thing you need to understand is the mapping of public key to IP range. Every peer has a key pair. The configuration contains one [Peer] block per counterpart, holding its PublicKey and a list of AllowedIPs. That list is two things at once: a filter for incoming packets and a signpost for outgoing ones. The man page puts it as "a comma-separated list of IP (v4 or v6) addresses with CIDR masks from which incoming traffic for this peer is allowed and to which outgoing traffic for this peer is directed". There is no separate routing configuration and no firewall ruleset for the tunnel itself – just this one list.

WireGuard fits wherever you need a permanent, encrypted connection between a small number of known machines: database replication across two data centres, a monitoring host scraping internal exporters, an office backup server that needs to reach a VPS. With dozens of changing clients, maintaining peers by hand gets tedious – that is when an overlay tool on top starts to pay off.

Generating Key Pairs

After installation – sudo apt install wireguard on Ubuntu and Debian, sudo dnf install wireguard-tools on Fedora – generate a separate key pair on each host. The umask 077 in front is not decoration: without it, the private key ends up world-readable.

umask 077
wg genkey | tee private.key | wg pubkey > public.key

The private key never leaves its host. Only the contents of public.key are exchanged. So each host ends up needing two things: its own private key and the public key of the other side.

The Minimal Example: wg0.conf on Both Sides

Our setup: host A is a server with a fixed public address (vpn.example.com), host B sits behind a NAT router with no inbound reachability. The tunnel network is 10.10.0.0/24; host A takes .1 in it, host B takes .2.

   Host A (public)                            Host B (behind NAT)
   +--------------------+                     +--------------------+
   |  wg0  10.10.0.1/24 |                     |  wg0  10.10.0.2/24 |
   |  ListenPort 51820  |                     |  (random port)     |
   +---------+----------+                     +----------+---------+
             |                                           |
             |   <=== UDP 51820, encrypted ===>          |
             |                                           |
       [ eth0, static IP ]                       [ NAT router ]
       vpn.example.com                            no port forward

On host A, create /etc/wireguard/wg0.conf:

[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = <PRIVATE_KEY_HOST_A>

[Peer]
PublicKey = <PUBLIC_KEY_HOST_B>
AllowedIPs = 10.10.0.2/32

On host B, the same file, mirrored:

[Interface]
Address = 10.10.0.2/24
PrivateKey = <PRIVATE_KEY_HOST_B>

[Peer]
PublicKey = <PUBLIC_KEY_HOST_A>
Endpoint = vpn.example.com:51820
AllowedIPs = 10.10.0.1/32
PersistentKeepalive = 25

Replace the angle-bracket placeholders with the actual base64 keys. Three differences matter. Host A sets a fixed ListenPort so host B can address it; without that setting, WireGuard picks a random port. Only host B has an Endpoint, because only it knows where the other side lives – host A learns B's address automatically from the first authenticated packet. And AllowedIPs is set to a single /32 address here: only traffic for the respective tunnel IP should go through the link, not all of your internet traffic.

Then set the permissions – the file contains a private key:

sudo chmod 600 /etc/wireguard/wg0.conf

Bringing the Tunnel Up and Verifying It

wg-quick reads the file, creates the interface, assigns the address and installs the routes. On both hosts:

sudo wg-quick up wg0

On host A the UDP port has to be reachable; with ufw, for example:

sudo ufw allow 51820/udp

Now the test. From host B, ping the far end and inspect the status:

ping -c 3 10.10.0.1
sudo wg show

wg show is your most important diagnostic tool. For each peer it prints the public key, endpoint, allowed ips, latest handshake and transfer. The decisive line is latest handshake: if it shows a timestamp, the cryptography works and the tunnel is up. If it is missing entirely, no handshake has completed – the problem is then either on the path (UDP port, firewall, endpoint) or in the key assignment. For permanent operation, enable the bundled systemd unit, which brings the tunnel up at boot:

sudo systemctl enable --now wg-quick@wg0

Three Common Pitfalls

1. Treating AllowedIPs as a pure filter. The name suggests an access list, but it is the routing decision as well. wg-quick derives the system routes from all AllowedIPs: "It infers all routes from the list of peers' allowed IPs, and automatically adds them to the system routing table." Anyone who reflexively enters 0.0.0.0/0 because it means "allow everything" is routing their entire outbound traffic into the tunnel – and on a server whose far end is not answering, that locks out the SSH session. For a point-to-point tunnel, only the /32 addresses of the other side belong there. Conversely: do not list the same IP under more than one peer – when sending, the list acts like a routing table, so a destination IP always belongs to exactly one peer.

2. The UDP port is closed. WireGuard speaks UDP exclusively. Rules that only permit tcp have no effect, and in cloud environments the provider's security group sits in front of the host firewall. What makes this worse: WireGuard never responds to unauthenticated packets – there is no "connection refused", no timeout with an error message, no log line. A blocked port looks exactly like a wrong key. That is why wg show, and specifically a missing latest handshake, is the only reliable indicator.

3. Forgetting PersistentKeepalive behind NAT. According to the man page, the option is off by default. Host B establishes the connection from the inside, but the NAT router only remembers that mapping for a few minutes of inactivity. Once it expires, host A can no longer reach host B on its own initiative – the tunnel appears "dead sometimes" until B sends something again. An interval of 25 seconds on the NAT side keeps the mapping open; that is exactly the value the documentation cites as an example. On the publicly reachable host A, the setting is unnecessary.

And a security note that too often gets left out: private keys do not belong in a Git repository, not in plaintext in your configuration management, and not in a chat message. Your wg0.conf contains one – so chmod 600 on the file and umask 077 when generating, otherwise the whole exercise is pointless.

Where to Go From Here

You now have an encrypted point-to-point tunnel that survives a reboot. The obvious next step is to make not just the two tunnel IPs but an entire subnet behind a peer reachable. To do that, extend that peer's AllowedIPs with the network and enable net.ipv4.ip_forward on the forwarding host, which the kernel leaves at 0 by default. Also worth considering: a PresharedKey as an additional symmetric layer for post-quantum resistance, and further [Peer] blocks for a third and fourth host. For more depth, see the WireGuard quickstart, wg(8) and wg-quick(8).

Publication note: This article was scheduled for 30 July 2026. Because of a technical fault in our publishing automation, it did not go live until 11 August 2026. All information was re-checked for accuracy before publication.

Note: The articles on this blog are produced with the help of AI and are editorially reviewed before publication. Editorial responsibility lies with Emre Yurtbay (see the Impressum).

Discuss your project