Homelab docs · updated October 2026
Two boxes, one private mesh
A small cloud VPS is the only thing on the public internet. A server at home joins it over a self-hosted WireGuard mesh and accepts no inbound connections at all. This page covers what runs where, how it's reached, and how it's secured.
Goals
- Nothing at home is exposed. I live on a managed apartment network with no port forwarding, and I don't trust it anyway. The home server only ever dials out.
- It survives a move. No static IPs, no DHCP reservations, no router settings. Plug the home box in anywhere with internet and it comes back on the same mesh address.
- Set and forget. The OS, the containers and the mesh client patch themselves. Reboots are scheduled around people playing.
- As little attack surface as possible. One public VM, four ports, two public apps. Everything else needs a mesh connection first.
Topology
cloud01 Linode VPS · Debian 13
My devices group: admins
Laptop, desktop, phone. Reach everything.
Friends group: All
Log in with an allow-listed Google account. Reach Minecraft only.
onprem01 home · Debian 13
Highlighted chips answer on the public internet. Everything else is mesh-only.
Hosts
| cloud01 | onprem01 | |
|---|---|---|
| Where | Linode (Akamai cloud) | My apartment |
| Hardware | Shared 2 vCPU, 4 GB RAM, 80 GB | 12th-gen Intel i7, 16 GB RAM, 500 GB |
| OS | Debian 13 | Debian 13, headless |
| Runtime | Docker Compose | Docker Compose |
| Public IP | Yes: 80, 443, 3478/udp | None. No inbound rules except the mesh |
| Role | Mesh control plane, public edge, monitoring | Game servers, media |
| Break-glass | Linode Lish browser console | Keyboard and monitor |
Services
| Service | Host | Reachable from | What it does |
|---|---|---|---|
| Traefik v3.7 | both | Public | Reverse proxy, TLS from Let's Encrypt wildcard certs via Cloudflare DNS-01 |
| NetBird | cloud01 | Public | Self-hosted mesh: management, signal, relay, STUN, dashboard, built-in identity provider |
| Vaultwarden | cloud01 | Public | Bitwarden-compatible password manager |
| CrowdSec | cloud01 | localhost | Reads proxy and Vaultwarden logs, bans attackers at Traefik |
| Grafana + Prometheus | cloud01 | Mesh · admins | Metrics and dashboards for both boxes, 90-day retention |
| node-exporter, cAdvisor | both | Mesh · admins | Host and container metrics |
| Uptime Kuma | cloud01 | Mesh · admins | Up/down checks |
| Dozzle | cloud01 | Mesh · admins | Live container logs in the browser |
| Homepage + Filebrowser | cloud01 | Mesh · admins | Start page with live status and stats for both servers, editable from the browser through a sidecar that can only see its config |
| Pelican Panel + Wings | onprem01 | Mesh · admins | Game server management, both in Docker |
| Minecraft (Fabric) | onprem01 | Mesh · everyone | Java server for friends, whitelist enforced, ~25 performance and technical mods |
| AIOStreams | onprem01 | Mesh · media | Stream search and filtering, served to the Apple TV as a Jellyfin-compatible server (being replaced, see roadmap) |
| AIOMetadata | onprem01 | Mesh · media | Catalogs and artwork from TMDB, with a Redis cache |
| Watchtower | both | none | Nightly image updates for containers that opt in |
Network & DNS
The mesh
NetBird builds a WireGuard mesh between every device. The control plane runs on cloud01; peers connect outbound to it and then talk to each other directly when they can. At home my laptop reaches onprem01 peer-to-peer over the LAN at around 5 ms. On networks that block it, such as campus Wi-Fi, traffic falls back to the relay on cloud01, still end-to-end encrypted.
Mesh-only hostnames without split DNS
Every admin hostname has an ordinary public DNS record, but it points at a mesh address in 100.64.0.0/10. Anyone can look it up; only a mesh peer can route to it. There's no local resolver to run and nothing to configure per device or per network.
Traefik then checks the source address of every request with an ipAllowList middleware. For that to work Traefik runs with network_mode: host. With normal published ports Docker rewrites every source to its bridge gateway, and the allow-list would see one internal IP for the whole internet.
Certificates
Each box gets one wildcard certificate through a Cloudflare DNS-01 challenge, so services inside the mesh still have real, trusted TLS. The Cloudflare token can edit DNS on one zone and nothing else. One wildcard instead of a cert per hostname keeps restarts from tripping Cloudflare's rate limits.
Home box specifics
Game servers, SFTP and the metrics exporters bind to onprem01's mesh IP rather than 0.0.0.0, because Docker-published ports go around the host firewall. Docker is set to wait for the mesh interface at boot so those binds never race it. The Panel reaches Wings through Traefik over the Docker bridge, which needs both an INPUT and a FORWARD firewall rule for the bridge subnet.
Identity & access
Logging in to the mesh goes through Google. The OAuth app is kept in testing mode, so only accounts on its test-user list can sign in at all. That list is the allow-list, and adding a friend is one entry. Every allowed account has 2-Step Verification turned on. A local admin account in NetBird's own store is the fallback if Google is unavailable.
What each device can reach is decided by NetBird groups and policies. The default allow-all policy is deleted.
| Policy | From → To | Allows |
|---|---|---|
| admins-everything | admins → All | Everything, both ways |
| servers-to-servers | servers → servers | Everything (metrics scraping) |
| everyone-minecraft | All → game-host (onprem01) | TCP 25565, one way |
| media-to-onprem01 | media → game-host | TCP 443, one way |
I'm the only member of admins. Anyone else who logs in lands in All and gets exactly one port. Shell access on the home server uses NetBird SSH, so it's tied to the same login and policies as everything else.
Updates
| What | How | When |
|---|---|---|
| Debian, Docker Engine, NetBird client | unattended-upgrades | Daily |
| cloud01 reboot | unattended-upgrades | 04:30, only if needed |
| onprem01 reboot | Custom timer that checks for connected players first | Tue 05:00, only if needed |
| Containers, incl. the NetBird server | Watchtower, opt-in by label | 04:00 nightly |
| CrowdSec plugin for Traefik | By hand; plugins never update themselves | Every few months |
Monitoring & backups
Prometheus on cloud01 scrapes node-exporter and cAdvisor on both machines, plus Traefik and CrowdSec, reaching the home box over the mesh. Grafana holds 90 days of history and emails me when a disk passes 85 %, memory or CPU stays above 90 %, or a target stops reporting. Uptime Kuma checks every service each minute and alerts on its own. Dozzle shows container logs for both boxes in one page. Homepage is the start page that ties it together, with live CPU, RAM and disk for each server pulled from Prometheus.
Because all of that runs on cloud01, cloud01 also checks in with an outside service every five minutes. If the check-ins stop, or the public front door stops answering, I get an alert from outside my own infrastructure.
Each box backs up nightly at 03:30 with restic to its own Backblaze B2 bucket, a different provider from the VPS, before Watchtower's update window. Backups are encrypted before they leave the machine and kept for 14 days, 8 weeks and 12 months, with a weekly integrity check and a dead-man ping that alerts if a night is missed. They include the Vaultwarden database, NetBird's encryption key together with its data, the Pelican database, the Wings node token and every game server's files.
Security rundown
Threat model
I'm defending against the background noise of the internet (scanners, credential stuffing, CVE probes), an untrusted apartment network, a friend's account being compromised, and my own mistakes. A well-resourced targeted attacker, physical access to the home box, and a breach of Linode, Cloudflare or Google are out of scope.
Attack surface
| Port | Service | Why it's open |
|---|---|---|
| 80/tcp | Traefik | Redirects to HTTPS. Serves nothing. |
| 443/tcp+udp | Traefik | NetBird control plane and relay, Vaultwarden. Every other route rejects non-mesh sources. |
| 3478/udp | NetBird STUN | Lets peers discover their NAT mapping for direct connections. |
| 22/tcp | SSH | Closed on both boxes. Mesh only. |
| anything | onprem01 | Closed to the internet and the LAN. Only the mesh interface is allowed. |
Layers, outside in
Network edge
- Linode Cloud Firewall, default drop, outside the VM, so Docker's iptables rules can't open anything behind my back.
- UFW default-deny on both hosts. The home box allows only the mesh interface plus container-to-proxy traffic.
Mesh
- Self-hosted control plane, no third-party coordination server.
- Google SSO with an allow-list and required 2-Step Verification.
- Least-privilege policies: friends get one TCP port.
Reverse proxy
- Containers are only routed if they opt in (
exposedByDefault: false). - Mesh-only allow-list on every admin route, enforced with real client IPs.
- Security headers everywhere: HSTS,
nosniff, frame deny, strict referrer, noServerheader. - TLS on every hop, inside the mesh too.
- Containers are only routed if they opt in (
Active blocking
- CrowdSec parses Traefik's JSON access log and Vaultwarden's login log for CVE probes, scanning and brute force, and pulls the community blocklist. Bans are enforced by a Traefik plugin placed first on every public route. In its first weeks it banned around 50 addresses.
- fail2ban on SSH with escalating ban times, as a backstop.
Host access
- No root login, a single allowed user, three auth attempts, no forwarding.
- SSH only over the mesh; NetBird SSH on the home server.
- Out-of-band recovery that doesn't depend on the network: Lish console and a physical console.
Applications
- Vaultwarden: signups closed, no password hints, 2FA on. The admin panel uses an Argon2id-hashed token and is served only on a mesh-only hostname, so
/adminis a 404 on the public one. The vault is encrypted client-side regardless. - Most containers run with
no-new-privileges; the proxy and vault drop all Linux capabilities; configs are mounted read-only. - Minecraft: online mode, enforced whitelist, no RCON.
- Vaultwarden: signups closed, no password hints, 2FA on. The admin panel uses an Argon2id-hashed token and is served only on a mesh-only hostname, so
Patching
- OS, Docker and mesh client update daily; containers nightly. Patch lag is a bigger risk for a one-person lab than a bad update, and backups run first.
Secrets
- Tokens and passwords live in
chmod 600files on each host, never in compose files or the public repo. - Each credential has the narrowest scope available: one DNS zone, one storage bucket per box.
- The backup password is also kept offline, since the backups exist partly to restore the password vault.
- Tokens and passwords live in
Accepted risks
| Risk | Why I accept it | Mitigation |
|---|---|---|
| Vaultwarden is public | It has to work when the mesh is down | Client-side encryption, closed signups, 2FA, CrowdSec, admin panel on the mesh only |
| The NetBird control plane is public | Devices have to reach it to join | SSO allow-list with 2FA, CrowdSec on its routes |
| Docker socket mounted in a few containers | Needed for routing, updates, logs and game servers | Read-only where possible; the dashboard goes through a socket proxy that refuses anything but reading container status; only the proxy is on a public route |
| Everything auto-updates | Missed patches are the bigger risk | Opt-in labels, backups before the update window |
| One admin, one region | It's a homelab | Encrypted off-box backups and a documented rebuild |
Lessons learned
The full list is in the repo. These cost the most time:
- A Docker network you create by hand ignores your configured address pool. Mine came up as
172.18.0.0/16instead of the172.20indaemon.json, and every firewall rule I'd written silently matched nothing. Inspect, don't assume. - Containers talking to a host-networked proxy need two firewall rules, one for INPUT and one for FORWARD. With one, you get timeouts that look like a routing bug.
- "does not match node token_id" was a CORS problem. The Pelican console talks to Wings straight from the browser, so Wings needs the Panel in
allowed_origins, and the Panel has to be stopped from overwriting that file. - NetBird's apt repository calls itself "Artifactory". With the obvious name in unattended-upgrades, clients never updated.
- Make Docker wait for the mesh on the home box, never on the cloud box. On cloud01 the mesh server itself runs in Docker, and the boot deadlocks.
- Turn on the firewall only after a second way in exists. I locked myself out of the home box once and fixed it with a monitor.
Roadmap
- Run Ethernet to the home server; it's on a Wi-Fi extender today.
- Move from debrid streaming to local media: Proxmox on the home box plus a second Proxmox storage server, mergerfs and SnapRAID over XFS data disks, a ZFS mirror for VMs, Jellyfin with Intel Quick Sync, downloads behind a VPN container. All mesh-only.
Source
Every compose file, proxy config, firewall script and update policy described here is in the homelab repo on GitHub. Domains are replaced with example.com, and keys, tokens and IP addresses are left out; each secret has an .example file showing its shape.