Hybrid home lab
Two nodes, one logical system. A Pi at home runs the LAN and home services; a $5/month VPS gives you a public IP you control. They’re joined over a self-hosted Headscale mesh and federated so policy lives in one place. The VPS doubles as a reverse proxy, a federation peer, and a bridge for the home Pi.
A hobbyist runs services at home — a Jellyfin server, a Home Assistant instance, a Git server — and wants:
- Remote access to all of them when away from home, without exposing anything to the public internet.
- A stable inbound point for sharing a link with a friend, even though the home IP is behind CGNAT.
- A second egress via a rented VPS for when home ISP routing is bad for a specific destination.
- One web UI to manage both nodes.
Topology
Section titled “Topology” Home LAN (CGNAT) Internet VPS (public IP) ───────────────── ──────────────── bridge-node :443 Jellyfin ┐ HA │ ╔══ WireGuard tunnel (mesh) ══╗ Git ├── Pi 4 ───╠═════════════════════════════╣── VPS gateway etc. │ ╚═ WireGuard tunnel (fed) ════╝ Laptop ──┘ Pi & VPS form a federation (Pi pushes routing/dns/devices)Install
Section titled “Install”Pi at home (profile rpi):
sudo ./scripts/install-gateway.sh --profile rpiVPS (profile vps + optional bridge-node binary):
sudo ./scripts/install-gateway.sh --profile vps# plus, separately, the bridge-node:sudo apt install gateway-bridge-nodeBoth nodes join the same mesh — Headscale on the VPS, both nodes as clients:
# VPS bootstrapmesh: enabled: true provider: headscale server_url: "https://mesh.example.invalid:8085" advertise_routes: ["198.51.100.0/24"]
# Pi bootstrapmesh: enabled: true provider: headscale server_url: "https://mesh.example.invalid:8085" advertise_routes: ["10.0.0.0/24"]Subnet routes that the Headscale coordinator approves for this node are accepted automatically — there’s no per-client accept_routes flag on the bootstrap side.
Now any device joined to the mesh (operator’s laptop, phone) can reach 10.0.0.x (home LAN) and 198.51.100.x (VPS subnet), regardless of where it is.
Federation
Section titled “Federation”Pair the two gateway nodes via the Federation → Peers UI page (which exchanges signing keys and approves the pending entry), or via the REST API:
# On the Pi: register the VPS as a peercurl -X POST https://pi.lan:8080/api/v1/nodes \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"vps","address":"https://vps.mesh:8080"}'
# Exchange signing keyscurl -X POST https://pi.lan:8080/api/v1/nodes/exchange \ -H "Authorization: Bearer $API_KEY"
# Approve the resulting nodecurl -X POST https://pi.lan:8080/api/v1/nodes/<id>/approve \ -H "Authorization: Bearer $API_KEY"Set the per-peer sync allowlist so the Pi pushes routing + DNS + devices to the VPS, while leaving each peer’s own tunnels config alone:
# On the Pi — push routing/dns/devices to the VPS, no inbound from VPScurl -X PUT https://pi.lan:8080/api/v1/nodes/<vps-id> \ -H "Authorization: Bearer $API_KEY" \ -d '{ "sync_policy": "push", "sync_resources": "routing,dns,devices", "allowed_receive_resources": "" }'
# On the VPS — pull from the Pi, accept only routing+dns+devicescurl -X PUT https://vps.mesh:8080/api/v1/nodes/<pi-id> \ -H "Authorization: Bearer $API_KEY" \ -d '{ "sync_policy": "pull", "sync_resources": "", "allowed_receive_resources": "routing,dns,devices" }'After this, gwctl federation sync --peer https://vps.mesh:8080 (or the Sync now button in the UI) triggers an immediate push to that peer. The auto-sync prober keeps the VPS fresh on the configured cadence between manual syncs.
Remote access to home services
Section titled “Remote access to home services”The operator’s laptop, when joined to the mesh, can reach:
jellyfin.lan→ resolved by the Pi’s DNS plane to10.0.0.5homeassistant.lan→10.0.0.6git.lan→10.0.0.7
Nothing is exposed to the public internet. The mesh tunnel handles auth.
Stable inbound link sharing
Section titled “Stable inbound link sharing”To share a Jellyfin link with a friend without putting Jellyfin on the public internet, run a reverse proxy on the VPS (Caddy, nginx, or similar — The Gateway doesn’t bundle one) that proxies over the mesh tunnel to the Pi:
# /etc/caddy/Caddyfile on the VPSwatch.example.invalid { reverse_proxy http://pi.mesh:8096 # Jellyfin on the Pi, reachable over the mesh}The friend hits https://watch.example.invalid; Caddy terminates TLS (Let’s Encrypt cert via DNS-01) and proxies the request to the Pi over the mesh tunnel. The Pi never has an open port to the internet.
Egress diversity
Section titled “Egress diversity”A common annoyance: a specific destination has poor routing from the home ISP but great routing from the VPS. Add a policy that sends that destination through the VPS:
wg-to-vps is a regular WireGuard tunnel you create through the UI / API — pointing at the VPS, with the VPS as the exit. The mesh tunnel can’t be repurposed for egress (it’s reserved for mesh control + tunneled L3 between peers). Then one routing policy on the Pi sends just that destination through it:
# Created on the Pi; the VPS pulls it on the next federation synccurl -X POST https://pi.lan:8080/api/v1/routing/policies \ -H "Authorization: Bearer $API_KEY" -d '{ "name": "gameserver-via-vps", "priority": 100, "enabled": true, "source_type": "domain", "source_value": "*.gameserver.example", "hops": [{"type":"vpn","target":"wg-to-vps"}], "fail_policy": "fail-open" }'
curl -X POST https://pi.lan:8080/api/v1/routing/policies/apply \ -H "Authorization: Bearer $API_KEY"Now home-LAN traffic to *.gameserver.example exits via the VPS’s better-routed link.
Egress through the bridge-node
Section titled “Egress through the bridge-node”If you also run bridge-node on the same VPS, you can give the Pi a censorship-resistant egress for destinations that are intermittently blocked, with the same bridge-eu setup described in the family router page.
One VPS now plays three roles: mesh coordinator (Headscale), federation peer, and bridge. They coexist cleanly because each is a different listener on a different port.
What happens when
Section titled “What happens when”| Scenario | Result |
|---|---|
Laptop at café, opens jellyfin.lan |
Mesh tunnel to home Pi → request to 10.0.0.5 → Jellyfin responds. Café WiFi sees only WireGuard. |
| Home ISP goes down | Mesh tunnel drops. Operator’s remote access dies; VPS reverse-proxied link returns 502 until home is back. Federation auto-recovers when ISP returns. |
| VPS reboots | The Pi loses its mesh peer briefly; the bridge becomes unhealthy briefly. Pi’s home LAN keeps working — nothing about home LAN routing depends on the VPS. |
| Edit a policy on the Pi | The change applies on the Pi immediately. The VPS picks it up on the next gwctl federation sync --peer https://vps.mesh:8080 or its auto-sync prober tick. If the VPS edits the same policy locally, the next push overwrites it and the conflict is logged. |
Why hybrid beats single-node
Section titled “Why hybrid beats single-node”- Single home Pi: no remote inbound link sharing without dynamic DNS or a tunnel service.
- Single VPS: no LAN, no home-device control plane.
- Hybrid: each node does what it’s best at, federation keeps the policy single-source-of-truth, mesh is the connective tissue.
Next steps
Section titled “Next steps”When the two nodes are healthy:
- Lock in the role table. Decide upfront which kinds are owned by the Pi vs the VPS. Surprises happen when a federation sync overwrites an operator’s local edit. See Federation → roles.
- Set up a bridge on the VPS. It has a stable public IP and a real TLS cert — perfect for SNI-fronted access from networks with active DPI.
- Rotate backups off the VPS. SCP/SFTP push to a third location so a compromised VPS doesn’t take the only backup with it. See Backup.
- Add a paging webhook so the home Pi’s
tunnel-downalert reaches you when you’re not at home and the mesh tunnel itself has gone dark. See Alerts.
Related
Section titled “Related”- Federation — per-kind roles in detail.
- Mesh networking — Headscale self-hosting that this use-case relies on.
- Bridge proxies — the bridge-node binary the VPS can also serve.