How my homelab is set up

Everything I self-host runs on two Proxmox nodes at home, as a few dozen guests split between containers and VMs: my mail, my Matrix server, my password manager, DNS, media, this site, a local LLM, and the lab I used for xdp-bfd. This is how it all fits together.

Here’s all of it in one picture, with each guest on the node it’s running on today:

Isometric overview of the homelab: node-a and node-b as two planes, with the OPNsense firewall, Matrix, mail, media, code-server and a test-cluster node on node-a, and the nginx reverse proxy, auth service, Vaultwarden, Guacamole, Stirling-PDF, dnsdist, two Pi-holes, two stunnels, monitoring, this site, iperf3, a local LLM, a test-cluster node and the eBPF lab runner on node-b. The three eBPF lab VMs sit on isolated guest-only networks. Arrows show web requests from the internet through the firewall to the proxy and out to the services, DNS through dnsdist and the Pi-holes, monitoring alerts into Matrix, media traffic leaving through Cloudflare WARP, backups to an offsite peer over WireGuard, and both nodes on shared iSCSI storage
The main guests, and where they run. Click for the full size.

The two nodes

Both nodes are in one Proxmox cluster. Each one has its own ZFS pool for the host, but most guests live on storage that both nodes can see: one LUN over iSCSI, with a shared LVM volume group on it. That’s what lets me move a running guest from one node to the other. I do that by hand, though. The HA manager is running, but I haven’t set any guest up for HA.

Each node talks to the storage over four iSCSI paths, each on its own network. Multipath uses two of them at a time and keeps the other two as standby, and if every path drops for a moment, the guests’ disk I/O just waits instead of erroring out. I don’t bond any links at all. The storage paths are where the redundancy is.

Diagram of the cluster: node-a and node-b, each with a local ZFS root pool and its containers and VMs, joined by the cluster network with two votes and no tiebreaker, and by manual live migration over the shared storage. Each node has two active and two standby iSCSI paths to the shared block storage, which carries a shared LVM with no snapshots
Two nodes, one shared LUN, four paths from each node.

It’s not perfect. With only two nodes there’s no tiebreaker, so if one node goes down, the cluster loses quorum. And plain LVM on shared storage can’t do snapshots, so the guests on it don’t get one before I change something.

Containers or VMs

If something only needs a userspace, it’s a container: the reverse proxy, the whole DNS layer, Vaultwarden, Guacamole, monitoring and this site. If it needs its own kernel or a different OS, it’s a VM: the firewall, mail, Matrix, the media box, code-server, the LLM host and the eBPF lab. Two more VMs are a small test cluster of their own, on the same bridges as the real one.

A lot of the guests run their software in Docker. Synapse and the DNS layer don’t. They run directly on their guests.

The firewall is one of the guests

My edge firewall is OPNsense, and it runs as a VM on the same cluster it routes for. The internet comes in as a tagged VLAN on the hosts’ uplink bridge, and OPNsense routes five internal networks from there: the main LAN, the cluster interconnect, a second private network, a VLAN for testing, and WireGuard, which is how my own devices and the offsite backup box get in. It also hands out DHCP, serves NTP, runs its own resolver, and keeps an OpenVPN tunnel open going out.

Diagram of the network: the internet reaches the OPNsense edge firewall, which runs as a VM on the cluster, over the WAN. The firewall routes the main LAN, the cluster interconnect, a secondary private segment, a VLAN test segment and the WireGuard tunnel network, and runs an outbound OpenVPN client tunnel
The firewall and the segments behind it.

Two kinds of traffic never go through it. The iSCSI paths go straight from each node to the storage, and the eBPF lab has two bridges with no physical ports at all, so its traffic only moves between VMs. The lab VMs are on the main LAN as well, though, so it’s the lab networks that are cut off, not the machines.

One proxy in front of everything

Every web service sits behind one nginx reverse proxy, which has about 70 hostnames on it now. It runs my own nginx image with a JWT module built in.

Most of my services have their own login, so the proxy just passes those through. A few admin pages don’t have one, and those need a valid token. One map over the client’s address, the hostname and the user agent decides which requests need a token, so the same page can be open from home and locked from outside. Getting that decision to happen at runtime meant changing the JWT module itself.

Diagram of the request path: a client goes through the edge firewall to the reverse proxy, nginx in my own image with a JWT auth module, with certificates from certbot validated over DNS. Services with their own login, such as Matrix, the password vault, remote desktop, media and dashboards, are proxied directly. Admin pages with no login go through a JWT check first. The hypervisor web UI is one upstream with ip_hash and failover across both nodes
What happens to a request once it reaches the proxy.

The certificates are wildcards from Let’s Encrypt, and certbot gets them over DNS, so nothing has to be open to the internet for a renewal. The Proxmox web UI is behind the proxy too, as one upstream across both nodes, so if one node stops answering, I end up on the other.

DNS

I wanted filtering and full recursion for my own devices, and encrypted DNS with a different filter for anyone outside, so there are two Pi-holes. The internal one has unbound running on the same guest for recursion, and my devices just query it directly.

Encrypted DNS comes in through the proxy instead. A DNS over HTTPS request hits nginx, which picks one of two dnsdist listeners depending on where the request came from, and each listener hands it to one of the Pi-holes. DNS over TLS goes through two stunnel instances, one in front of each Pi-hole.

Diagram of the DNS chain: internal clients query the internal Pi-hole directly, which uses unbound for recursion. DNS over HTTPS clients reach the reverse proxy, whose geo and map split picks one of two dnsdist listeners, each forwarding to the internal or the public Pi-hole. DoT clients go through two stunnel instances, one in front of each Pi-hole
Plain queries go straight to the filtering resolver. Encrypted ones come in at the proxy, which decides which policy they get.

Mail, chat and the rest

Mail is Mailcow in its own VM, which is 18 containers by itself: Postfix and Dovecot, rspamd and ClamAV, a scanner for Office macros, and SOGo for webmail. Matrix is Synapse with PostgreSQL, plus coturn for calls, all installed straight on the VM, and registration is closed.

The rest is Vaultwarden, Guacamole, Stirling-PDF, code-server, an iperf3 endpoint, a local LLM on llama.cpp, and the media stack, which is Jellyfin, the *arr apps and qBittorrent. The media VM’s traffic goes out through a Cloudflare WARP tunnel with a kill switch, so if the tunnel drops, that traffic stops instead of going out directly.

Monitoring and backups

I only added monitoring on 10 September, long after most of what it watches. It’s Prometheus, Loki, Grafana and Alertmanager in one container, with agents on 19 hosts. Prometheus pulls from the agents instead of having them push, so if a host goes quiet, it shows up as down instead of just going silent. Blackbox probes check things from the outside too, and an alert fires three weeks before any certificate expires.

The alerts go to a private room on my own Matrix server. The obvious problem is that if the cluster goes down, the alerts go down with it.

The backups are a nightly vzdump on each node, then an rsync to an offsite box over WireGuard. That box isn’t always online, so the alert only fires when the last good sync is more than 48 hours old. The first thing the new monitoring found was that one node’s sync had been failing every night for 23 days, and that turned into a much longer story about TCP congestion control.

Diagram of the backup flow: the guests in the backup job are backed up nightly by vzdump in snapshot mode with zstd to a local store on each node, then rsynced with four workers and BBR over WireGuard to an offsite peer behind a NAT. Each run writes metrics through the textfile collector to Prometheus, which has seven alert rules that send alerts to a Matrix room on my own server
The nightly backups, and how I'd find out if they stopped.

Back to writing