Sam Meador.

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.

01

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.
02

Topology

Highlighted chips answer on the public internet. Everything else is mesh-only.

03

Hosts

cloud01onprem01
WhereLinode (Akamai cloud)My apartment
HardwareShared 2 vCPU, 4 GB RAM, 80 GB12th-gen Intel i7, 16 GB RAM, 500 GB
OSDebian 13Debian 13, headless
RuntimeDocker ComposeDocker Compose
Public IPYes: 80, 443, 3478/udpNone. No inbound rules except the mesh
RoleMesh control plane, public edge, monitoringGame servers, media
Break-glassLinode Lish browser consoleKeyboard and monitor
04

Services

ServiceHostReachable fromWhat it does
Traefik v3.7bothPublicReverse proxy, TLS from Let's Encrypt wildcard certs via Cloudflare DNS-01
NetBirdcloud01PublicSelf-hosted mesh: management, signal, relay, STUN, dashboard, built-in identity provider
Vaultwardencloud01PublicBitwarden-compatible password manager
CrowdSeccloud01localhostReads proxy and Vaultwarden logs, bans attackers at Traefik
Grafana + Prometheuscloud01Mesh · adminsMetrics and dashboards for both boxes, 90-day retention
node-exporter, cAdvisorbothMesh · adminsHost and container metrics
Uptime Kumacloud01Mesh · adminsUp/down checks
Dozzlecloud01Mesh · adminsLive container logs in the browser
Homepage + Filebrowsercloud01Mesh · adminsStart page with live status and stats for both servers, editable from the browser through a sidecar that can only see its config
Pelican Panel + Wingsonprem01Mesh · adminsGame server management, both in Docker
Minecraft (Fabric)onprem01Mesh · everyoneJava server for friends, whitelist enforced, ~25 performance and technical mods
AIOStreamsonprem01Mesh · mediaStream search and filtering, served to the Apple TV as a Jellyfin-compatible server (being replaced, see roadmap)
AIOMetadataonprem01Mesh · mediaCatalogs and artwork from TMDB, with a Redis cache
WatchtowerbothnoneNightly image updates for containers that opt in
05

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.

06

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.

PolicyFrom → ToAllows
admins-everythingadmins → AllEverything, both ways
servers-to-serversservers → serversEverything (metrics scraping)
everyone-minecraftAll → game-host (onprem01)TCP 25565, one way
media-to-onprem01media → game-hostTCP 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.

07

Updates

WhatHowWhen
Debian, Docker Engine, NetBird clientunattended-upgradesDaily
cloud01 rebootunattended-upgrades04:30, only if needed
onprem01 rebootCustom timer that checks for connected players firstTue 05:00, only if needed
Containers, incl. the NetBird serverWatchtower, opt-in by label04:00 nightly
CrowdSec plugin for TraefikBy hand; plugins never update themselvesEvery few months
08

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.

09

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

PortServiceWhy it's open
80/tcpTraefikRedirects to HTTPS. Serves nothing.
443/tcp+udpTraefikNetBird control plane and relay, Vaultwarden. Every other route rejects non-mesh sources.
3478/udpNetBird STUNLets peers discover their NAT mapping for direct connections.
22/tcpSSHClosed on both boxes. Mesh only.
anythingonprem01Closed to the internet and the LAN. Only the mesh interface is allowed.

Layers, outside in

  1. 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.
  2. 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.
  3. 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, no Server header.
    • TLS on every hop, inside the mesh too.
  4. 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.
  5. 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.
  6. 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 /admin is 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.
  7. 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.
  8. Secrets

    • Tokens and passwords live in chmod 600 files 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.

Accepted risks

RiskWhy I accept itMitigation
Vaultwarden is publicIt has to work when the mesh is downClient-side encryption, closed signups, 2FA, CrowdSec, admin panel on the mesh only
The NetBird control plane is publicDevices have to reach it to joinSSO allow-list with 2FA, CrowdSec on its routes
Docker socket mounted in a few containersNeeded for routing, updates, logs and game serversRead-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-updatesMissed patches are the bigger riskOpt-in labels, backups before the update window
One admin, one regionIt's a homelabEncrypted off-box backups and a documented rebuild
10

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/16 instead of the 172.20 in daemon.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.
11

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.
12

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.