WireGuard tunnel: VPS to homelab
Build the encrypted private link between your public VPS and the home server it fronts: two WireGuard peers on a 10.8.0.0/24 tunnel, with the NAT'd homelab dialing out to the VPS and keeping the line open.
In this series
Expose your homelab to the internetlevel:Intermediate
ℹ️ Hands-on — you will run commands on two machines: the public Hetzner VPS from Part 3, and your home server behind your router. Every code block is labelled with which machine it belongs to. Keep an SSH session open to each.
In Part 3 you stood up a hardened public VPS with a stable IP and an open UDP port. It is reachable from the internet, but it cannot yet reach back into your home network — and your home server, sitting behind your router's NAT, has no public address to be reached at. This post builds the bridge between them: a WireGuard tunnel, an encrypted private link that makes the VPS and the homelab behave as if they were two machines on the same small LAN, no matter where they actually sit on the internet.
This is the technical heart of the series. Get the addressing and the direction of initiation right here, and everything downstream — Caddy terminating HTTPS on the VPS and forwarding requests down this tunnel — becomes simple plumbing. Get it wrong, and you will stare at a tunnel that refuses to come up. So we will go slowly and explain every line.
What WireGuard is
WireGuard is a VPN, but it does not look like the VPNs you may have met before. It is small — the original implementation is a few thousand lines of code, against the hundreds of thousands in OpenVPN or IPsec — and it lives inside the Linux kernel, so traffic does not bounce up to a userspace daemon and back for every packet. That smallness is the point: less code is less to audit, less to misconfigure, and less to slow you down.
The model is deliberately stripped down. There is no notion of "client" and "server" baked into the protocol — only peers. Each peer has a private key and a public key (the same idea as SSH keys), and you tell each peer which public keys it is allowed to talk to and what tunnel addresses sit behind them. Encrypted traffic flows over a single UDP port. There is no session negotiation chatter, no certificate authority, no daemon to babysit — just two endpoints that recognise each other's keys.
Compared with older VPNs, the practical wins are: a far simpler config (two short files, below), much faster throughput and lower latency (kernel datapath, modern crypto), and near-instant connect/reconnect because there is no heavy handshake to renegotiate. For our job — joining exactly two machines over an encrypted link — it is close to ideal.
ℹ️ Note — "peer" is the honest WireGuard term, but it helps to keep informal roles in mind. The VPS has a stable public IP and listens; the homelab is behind NAT and dials out. WireGuard does not care which is which, but as you will see, NAT does.
Install WireGuard on both machines
WireGuard ships in the Debian repositories. Install it on both the VPS and the homelab server — the tunnel needs both ends.
On the VPS — install WireGuard:
sudo apt update
sudo apt install -y wireguard
On the homelab server — install WireGuard:
sudo apt update
sudo apt install -y wireguard
This pulls in the wg command (low-level control) and wg-quick (a wrapper that brings an interface up from a config file). On any modern kernel the WireGuard module itself is already built in, so there is nothing to compile.
Generate a keypair on each machine
Each peer needs its own keypair. The private key is the secret that proves a machine's identity; it must never leave the machine it was generated on. The public key is derived from the private key and is meant to be shared — you hand it to the other peer so it knows who it is allowed to talk to. Two keypairs total: one per machine.
Run this on each box. It generates a private key, saves it to privatekey, derives the matching public key, and saves that to publickey:
On the VPS — generate the VPS keypair:
wg genkey | tee privatekey | wg pubkey > publickey
On the homelab server — generate the homelab keypair:
wg genkey | tee privatekey | wg pubkey > publickey
Read those files with cat privatekey and cat publickey — each is a single line of base64. You will paste:
- The VPS private key into the VPS config, and the homelab public key into the VPS config.
- The homelab private key into the homelab config, and the VPS public key into the homelab config.
In other words: each config holds its own private key and the other peer's public key. Keep the two public keys handy (copy them somewhere you can reach from both SSH sessions); never copy a private key off its machine.
⚠️ Warning —privatekeyis as sensitive as an SSH private key. Anyone holding it can impersonate that peer. Do not commit it to git, paste it into chat, or email it to yourself. It only ever belongs in/etc/wireguard/on the machine that generated it.
Plan the addressing
Before writing config, decide on the numbers — and use them consistently on both ends, because a typo here is the single most common reason a tunnel never connects.
| What | Value |
|---|---|
| Tunnel interface | wg0 |
| Tunnel subnet | 10.8.0.0/24 |
| VPS tunnel address | 10.8.0.1 |
| Homelab tunnel address | 10.8.0.2 |
| VPS public IP | 203.0.113.10 (use your real one) |
| UDP listen port (on VPS) | 51820 |
The tunnel subnet 10.8.0.0/24 is a small private network that exists only inside the tunnel — it has nothing to do with your home LAN's 192.168.x.x range or anything on the public internet. Inside it, the VPS answers to 10.8.0.1 and the homelab answers to 10.8.0.2. Once the tunnel is up, the VPS can ping 10.8.0.2 to reach the homelab and vice versa, regardless of the real networks underneath.
flowchart LR
subgraph internet["Public internet"]
VPS["VPS<br/><i>203.0.113.10</i><br/>tunnel: 10.8.0.1<br/>listens on UDP 51820"]
end
subgraph home["Home network (behind NAT)"]
HL["Homelab server<br/>tunnel: 10.8.0.2<br/><i>dynamic / private IP</i>"]
end
HL -- "initiates handshake →<br/>PersistentKeepalive 25s" --> VPS
VPS -. "encrypted UDP tunnel<br/>10.8.0.0/24" .- HL
The homelab dials out to the VPS's fixed public IP and keeps the line open; the VPS only listens. The 10.8.0.0/24 subnet lives entirely inside the encrypted link.
The VPS config: listen and wait
The VPS has a stable, public IP, so its job is the simple one: listen on UDP 51820 and accept the homelab when it calls. WireGuard reads its config from /etc/wireguard/<interface>.conf; our interface is wg0.
Read the file below first, then create it. Replace the placeholder keys with real ones: <VPS private key> is the contents of privatekey on the VPS, and <homelab public key> is the contents of publickey from the homelab.
/etc/wireguard/wg0.conf (on the VPS):
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = <VPS private key>
[Peer]
PublicKey = <homelab public key>
AllowedIPs = 10.8.0.2/32
Line by line:
[Interface]describes this machine's end of the tunnel.Address = 10.8.0.1/24— this peer's address inside the tunnel, and the/24tells it the whole10.8.0.0/24subnet is reachable overwg0.ListenPort = 51820— the UDP port WireGuard binds to. The homelab will send its handshake here. This is the port you opened on the firewall in Part 3.PrivateKey— the VPS's own secret key. This is the only secret in the file.[Peer]describes the other machine — the homelab.PublicKey = <homelab public key>— only handshakes signed by the matching homelab private key are accepted. This is how the VPS knows it is talking to your homelab and nothing else.AllowedIPs = 10.8.0.2/32— a tight rule: from this peer, accept (and to this peer, route) only traffic for10.8.0.2. The/32means exactly that one address. There is deliberately noEndpointhere — the VPS does not know where the homelab is and does not need to; it simply replies to wherever the handshake came from.
Lock the file down before bringing it up, since it holds the private key:
On the VPS — protect the config:
sudo chmod 600 /etc/wireguard/wg0.conf
The homelab config: dial out and stay up
The homelab is behind your router's NAT. It has no stable public address, and the VPS cannot start a conversation with it — incoming packets to your home network are dropped by the router unless something inside asked for them first. So the homelab takes the active role: it knows the VPS's public IP, dials out to it, and keeps the connection warm.
Two settings make that work, and they are the key insight of this whole post. Read the file, then create it. <homelab private key> is privatekey on the homelab; <VPS public key> is publickey from the VPS; 203.0.113.10 is your VPS's real public IP.
/etc/wireguard/wg0.conf (on the homelab server):
[Interface]
Address = 10.8.0.2/24
PrivateKey = <homelab private key>
[Peer]
PublicKey = <VPS public key>
Endpoint = 203.0.113.10:51820
AllowedIPs = 10.8.0.1/32
PersistentKeepalive = 25
Line by line, with attention to the two that differ from the VPS side:
Address = 10.8.0.2/24— the homelab's address inside the tunnel.PrivateKey— the homelab's own secret. Note there is noListenPort: this side does not wait for anyone, it reaches out.[Peer]— describes the VPS.PublicKey = <VPS public key>— only the genuine VPS is accepted as the far end.Endpoint = 203.0.113.10:51820— this is why the homelab side initiates. It tells WireGuard the real public address and port to send the handshake to. The VPS has no equivalent line because it has no idea where the homelab lives; the homelab, by contrast, can always find the VPS at its fixed IP.AllowedIPs = 10.8.0.1/32— accept/route only the VPS's tunnel address.PersistentKeepalive = 25— this is why the tunnel stays up. Every 25 seconds the homelab sends a tiny keepalive packet to the VPS. Your router's NAT only keeps a path open for return traffic for a short while after the last outbound packet; without keepalives the mapping expires, and the VPS — which cannot initiate — would be unable to reach the homelab until the homelab next spoke. The 25-second drumbeat holds the NAT hole open and survives a changing home IP, because each keepalive re-asserts the homelab's current location to the VPS.
💡 Tip — Only the peer behind NAT needs PersistentKeepalive. Setting it on the VPS too is harmless but pointless: the VPS is not behind NAT and is never the one keeping the path alive.Protect this file as well:
On the homelab server — protect the config:
sudo chmod 600 /etc/wireguard/wg0.conf
Bring the tunnel up
wg-quick reads wg0.conf, creates the wg0 interface, and applies the config. Wrapping it in a systemd unit (wg-quick@wg0) makes it start on boot too. Bring it up on both ends — order does not strictly matter, but starting the listening VPS first is tidy.
On the VPS — enable and start the tunnel:
sudo systemctl enable --now wg-quick@wg0
On the homelab server — enable and start the tunnel:
sudo systemctl enable --now wg-quick@wg0
enable --now does two things: enable registers the unit to start at boot, and --now starts it immediately. If a config has a typo, the command fails loudly — check sudo systemctl status wg-quick@wg0 and sudo journalctl -u wg-quick@wg0 for the reason.
Verify the link
First, inspect the interface on each side with wg show. This reports the peer, the latest handshake time, and bytes transferred.
On the VPS — inspect the tunnel:
sudo wg show
A healthy VPS end looks roughly like this — note the homelab's public key, an endpoint (the address the homelab dialled in from, which the VPS learned automatically), and a recent handshake:
interface: wg0
public key: <VPS public key>
private key: (hidden)
listening port: 51820
peer: <homelab public key>
endpoint: 198.51.100.7:38214
allowed ips: 10.8.0.2/32
latest handshake: 18 seconds ago
transfer: 1.85 KiB received, 1.21 KiB sent
On the homelab server — inspect the tunnel:
sudo wg show
The homelab end shows the VPS as its peer with the endpoint you configured (203.0.113.10:51820) and persistent keepalive: every 25 seconds. The line that tells you it is actually working on either side is latest handshake with a recent timestamp — if you only ever see transfer: 0 B received, no handshake has completed and something is wrong (almost always keys or the firewall, see below).
Now prove traffic flows. From each machine, ping the other peer's tunnel address:
On the homelab server — ping the VPS across the tunnel:
ping -c 3 10.8.0.1
On the VPS — ping the homelab across the tunnel:
ping -c 3 10.8.0.2
Both should reply, with latency roughly matching your home connection's round-trip to the VPS:
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=11.4 ms
64 bytes from 10.8.0.1: icmp_seq=2 ttl=64 time=10.9 ms
64 bytes from 10.8.0.1: icmp_seq=3 ttl=64 time=11.1 ms
If both pings succeed, the encrypted link is live. The two machines now share a private network and can talk over 10.8.0.1 and 10.8.0.2 exactly as if they were on the same switch.
The firewall must allow the UDP port
The tunnel rides on UDP 51820, and the handshake arrives at the VPS. In Part 3 you opened that port — both in the VPS's own firewall (ufw) and, on Hetzner, in the Cloud Firewall in the console. Confirm it on the VPS:
On the VPS — confirm the WireGuard port is allowed:
sudo ufw status
You should see a line allowing 51820/udp. If you use Hetzner's Cloud Firewall, double-check the inbound rule there too — both layers must permit the port.
⚠️ Warning — If UDP 51820 is blocked anywhere between the homelab and the VPS — the VPS firewall, the Hetzner Cloud Firewall, or in rare cases your home ISP — the handshake never reaches the VPS,latest handshakestays blank, andwg showreports0 B received. There is no error message; the tunnel simply never comes up. When a freshly configured tunnel is silent, the firewall is the first thing to check, not the config.
What this tunnel reaches — and what it does not
ℹ️ Note — This tunnel reaches services running on the homelab host itself, addressed as10.8.0.2. If a service listens on the homelab server (or, as in Part 6, the k3s cluster's ingress runs on that host), the VPS can reach it at10.8.0.2:<port>and that is all this series needs.
Reaching other machines on your home LAN through the tunnel — a NAS on192.168.1.50, say — is a bigger job: you would enable IP forwarding on the homelab, broaden the VPS'sAllowedIPsto include the LAN subnet, and add a NAT or routing rule so return traffic finds its way back. That is a worthwhile extension but a different topic; we leave it out to keep the series focused on the edge. For everything ahead,10.8.0.2is enough.
Where this goes next
You now have an encrypted private link: the VPS at 10.8.0.1 and the homelab at 10.8.0.2, with the homelab dialling out through NAT and a keepalive holding the line open. The VPS can reach your home services, but the outside world still cannot — and it should not reach them raw. In Caddy: reverse proxy and automatic HTTPS we put a front door on the VPS: Caddy will terminate TLS on your real domain and forward each request down this very tunnel to 10.8.0.2, turning the private link you just built into a public, HTTPS-served service. Back to the series hub if you need the map.