The homelab runs on private RFC1918 addresses behind a consumer router. The VPS sits on the public internet with a static IP. Getting them to talk to each other β reliably, securely, and without exposing anything unnecessarily β is exactly what a site-to-site VPN is for.
I use WireGuard for this. It is fast, has a minimal attack surface (roughly 4,000 lines of code versus OpenVPN's ~100,000), and the configuration is simple enough to understand completely. No certificate authority, no complex PKI, just key pairs and allowed IP ranges.
With the tunnel in place:
flowchart TD
classDef internet fill:#f9f9f9,stroke:#333,stroke-width:2px;
classDef vps fill:#e1f5fe,stroke:#01579b,stroke-width:2px;
classDef homelab fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
classDef tunnel fill:#fff3e0,stroke:#ef6c00,stroke-width:2px;
Internet([Internet])
subgraph VPS [VPS β Public IP]
VPS_WG["WireGuard\n10.10.0.1/24"]
VPS_NGINX["NGINX"]
VPS_App["Rust app\nPort 9080"]
end
subgraph Tunnel [WireGuard Tunnel\n10.10.0.0/24 β encrypted UDP]
direction LR
TL[" "]
end
subgraph Homelab [Homelab β Private LAN]
HL_WG["WireGuard\n10.10.0.2/24"]
Forgejo["Forgejo\n192.168.10.x"]
Runner["Forgejo runner"]
Staging["Staging container"]
end
Internet -->|HTTPS| VPS_NGINX
VPS_NGINX --> VPS_App
VPS_WG <-->|encrypted UDP 51820| HL_WG
Runner -->|deploy via tunnel| VPS_App
Runner --> Staging
class Internet internet;
class VPS_WG,VPS_NGINX,VPS_App vps;
class HL_WG,Forgejo,Runner,Staging homelab;
On the VPS, WireGuard listens on UDP port 51820. The VPS has a static public IP, so it is the stable endpoint the homelab connects to:
# /etc/wireguard/wg0.conf β VPS
[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = <vps-private-key>
# Homelab peer
[Peer]
PublicKey = <homelab-public-key>
AllowedIPs = 10.10.0.2/32, 192.168.10.0/24
AllowedIPs on the VPS side includes the homelab's WireGuard address (10.10.0.2) and the homelab's LAN subnet (192.168.10.0/24). This tells the VPS kernel to route packets destined for the homelab LAN through the tunnel β the VPS can reach internal homelab hosts directly.
Enable IP forwarding on the VPS so traffic can be routed:
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf && sysctl -p
The homelab peer has a dynamic public IP (a residential connection), so it initiates the connection to the VPS and maintains it with a keepalive:
# /etc/wireguard/wg0.conf β Homelab (pfSense or Linux)
[Interface]
Address = 10.10.0.2/24
PrivateKey = <homelab-private-key>
# VPS peer
[Peer]
PublicKey = <vps-public-key>
Endpoint = <vps-public-ip>:51820
AllowedIPs = 10.10.0.1/32
PersistentKeepalive = 25
PersistentKeepalive = 25 sends a handshake packet every 25 seconds. This keeps the tunnel alive through NAT and consumer routers that would otherwise drop idle UDP connections.
On pfSense the configuration lives under VPN β WireGuard rather than a flat config file, but the parameters are identical.
Generate key pairs on each side β never transfer private keys over the network:
# On each machine
wg genkey | tee privatekey | wg pubkey > publickey
Exchange only the public keys. Each side keeps its private key local.
# On either side
wg show
# Test reachability
ping 10.10.0.1 # from homelab β VPS tunnel endpoint
ping 192.168.10.5 # from VPS β homelab host
A successful handshake shows latest handshake in wg show output. If it is absent, check that UDP 51820 is open on the VPS firewall and that the public keys match what each side has configured for its peer.
The Forgejo runner on the homelab deploys to the VPS over the tunnel. The deploy script connects via SSH to 10.10.0.1 (the VPS's WireGuard address) rather than the public IP β traffic stays inside the encrypted tunnel, and the VPS SSH port does not need to be publicly accessible.
ssh -i /home/runner/.ssh/deploy_key [email protected] \
"podman pull registry.home.internal/app:latest && \
podman restart app"
WireGuard Β· pfSense Β· Linux Β· Podman Β· Forgejo CI