~/projects / doof-homelab

drwxr-xr-x · #infra · started 2026-04-08

doof-homelab/

A Proxmox server at home that runs my DNS, reverse proxy, photo backups, remote access and monitoring. My ISP puts me behind CGNAT, so everything is reachable without opening a single inbound port.

  • Proxmox VE
  • LXC
  • ZFS
  • LVM-thin
  • VLANs
  • Pi-hole
  • Nginx Proxy Manager
  • Cloudflare Tunnel
  • Zero Trust
  • Tailscale
  • Prometheus
  • Grafana

$ cat overview

doof started as an old desktop, a small TP-Link router and a switch. The first note in my lab docs says it will be "tiny at first," and it was. Before Proxmox it was a Windows and Hyper-V dual boot. Before that, the data disk belonged to a TrueNAS install I'd abandoned.

I wanted to run my own services instead of renting them, and to secure my own network. Mostly, though, I wanted to know where my data was going. Almost everything below came out of that.

$ cat architecture

doof homelab architecture Remote users reach the lab over outbound-only connections: SSH through Cloudflare Access and a tunnel, or everything through Tailscale. Both lead to the doof Proxmox host, which runs Pi-hole, Nginx Proxy Manager, CasaOS with Immich, the cloudflared connector, the Tailscale router and the monitoring container. LAN clients on separate VLANs use Pi-hole for DNS and reach services through Nginx Proxy Manager. [internet] you, remote Cloudflare edge Access · one-time PIN Tailscale tailnet DNS → Pi-hole ssh ISP CGNAT · no public IP · zero inbound ports outbound 443 WireGuard [doof:proxmox] · server VLAN Pi-hole DNS · split horizon NPM wildcard TLS cloudflared connector Tailscale subnet router CasaOS · Immich photos on ZFS doof-watcher Prometheus · Grafana · alerts storage NVMe · ZFS · LVM-thin [lan] · ACLs between VLANs admin VLAN guest VLAN IoT VLAN DNS :53 only
ServiceRuns asWhy it's there
Pi-hole v6LXCDNS for every VLAN, ~760k blocked domains, and a log of what's talking to what
Nginx Proxy ManagerLXC (Docker)One wildcard certificate; routes each *.doof.quest name to its backend
cloudflaredLXCOutbound tunnel for SSH when Tailscale is blocked
TailscaleLXCSubnet router so my devices reach the LAN from anywhere
CasaOS + ImmichLXC (Docker)My own Google Photos, with the library on ZFS
doof-watcherLXCPrometheus, exporters, Grafana and Discord alerts

$ cat network

The network runs on an Omada stack (router, managed switch, wall AP) split into Admin, Server, Guest, and IoT VLANs. The default VLAN only holds the router.

The ACLs needed a full rebuild. I'd written them assuming the router tracked connections between LAN segments, and it doesn't: LAN-to-LAN rules on the router are stateless, so a one-way permit silently broke return traffic. The rebuilt set has explicit permits in both directions where needed, lets guest and IoT devices reach Pi-hole on port 53 and nothing else, and ends with a blanket guest-to-LAN deny. Pi-hole is targeted with a /32 IP group, not a whole subnet.

I learned one DNS lesson from my own laptop. The router was handing out Pi-hole plus 1.1.1.1 as a fallback, and macOS quietly preferred the fallback, so its queries never touched Pi-hole. DHCP now hands out Pi-hole only.

$ cat internal-https

Every service gets a real address like https://<service>.doof.quest with a valid padlock, on the LAN and away from home, and nothing listens on the internet. Three pieces make that work.

  • One Let's Encrypt wildcard certificate for *.doof.quest, validated through a Cloudflare DNS-01 challenge. The API token can edit DNS on that one zone and nothing else.
  • Pi-hole answers every *.doof.quest lookup with the proxy's address (split-horizon DNS), so public DNS never holds internal records.
  • Tailscale advertises the LAN and uses Pi-hole as the tailnet's resolver, which is why the same addresses work on cellular.

$ cat remote-ssh

Tailscale is blocked on my school's Wi-Fi, and CGNAT rules out port forwarding. So SSH goes through a Cloudflare Tunnel: a small connector container makes an outbound HTTPS connection, and SSH rides back through it on its own hostname.

  • A Cloudflare Access app sits in front. It sends a one-time PIN, and the policy allows exactly one email address. I created the Access app before the route existed, so the hostname was never open even for a minute.
  • The Proxmox web UI isn't published through the tunnel. Only SSH is.
  • The host accepts keys only. When I checked with sshd -T, password login was still on: the line in sshd_config was commented out, which means OpenSSH's default of "yes" applied. A drop-in file fixed it, and I verified the effective config, not just the file.
  • At home, Pi-hole's wildcard sent the SSH hostname to the proxy, which can't carry SSH. A more specific rule now sends that one name upstream.

$ cat storage

  • The NVMe holds Proxmox, every service container, and the databases that need speed.
  • A ZFS pool on a 1 TB HDD has LZ4 compression and one dataset per service. Immich's photo library lives there, while its Postgres database stays on the SSD. I take a snapshot before every upgrade.
  • A 2 TB SMR drive is split into an ext4 directory and an LVM-thin pool for VMs I rarely run. SMR drives handle heavy random writes badly, so nothing busy goes there.

Two things confused me for a while. Unprivileged containers map their root user to UID 100000 on the host, so a dataset created by host root looks read-only from inside until you chown it into that range. And ZFS shows about 899 GiB on a 930 GiB disk because it keeps space for metadata and a safety reserve. That's also why my dataset quotas are 445G instead of 450G.

$ cat monitoring

A single container runs Prometheus, which scrapes every 15 seconds: node_exporter for the host's hardware and OS, and pve-exporter for per-guest stats through a read-only Proxmox API user. Grafana sits behind the proxy with HTTPS. Everything else listens on localhost, and the host's exporter port is firewalled to the monitoring container alone. ss -tlnp confirms it.

Alerts go to a private Discord channel:

AlertFires when
Target downany scrape target is down for 2 min
Storage fillingany Proxmox storage is over 85%
Root disk fillingroot filesystem is over 85%
CPU too hotCPU is over 85°C for 5 min
Memory pressureRAM is over 90% for 10 min
Guest downa VM or container set to autostart isn't running

I gave the messages a wizard theme ("A disturbance stirs in the realm of doof") because plain alerts are boring, and each one's description doubles as a short runbook. I tested every rule by stopping an exporter and watching it go pending, firing, then resolved. The dashboards already caught real things: one storage pool sitting near 80%, HDD I/O saturation spikes, and guests promised more RAM than the host has.

$ cat incidents

  • Three reboots in one day. I suspected an intrusion first. SSH logs, cron, scheduled jobs and sessions were all clean, and so were RAM, SMART and kernel errors. The logs just stopped mid-write with no shutdown, which points at hardware. The cause was a faulty UPS that caused brownouts instead of covering them. The fixes also turned up containers without autostart and a firewall rule that existed but was never enabled.
  • Every web console stuck on "Connecting…". A DNS record I'd added skipped the proxy and broke the console's WebSocket. Full postmortem →
  • The server wouldn't boot. An HDD mount in fstab had no nofail. I recovered through an init=/bin/bash shell and added a device timeout.

$ cat lessons

  • Check the effective state, not the config file: sshd -T, ss -tlnp, promtool check config.
  • A firewall rule isn't done until it's enabled and tested.
  • Least privilege everywhere: one-zone API tokens, a read-only monitoring user, a one-address access policy.
  • systemctl status tells you that something failed. journalctl tells you why.
  • I document everything. Each part of doof has its own doc with numbered troubleshooting tickets (symptom, cause, fix).

$ cat todo

  • Replace the UPS.
  • Ship SSH logs to Loki and alert on them.
  • Finish the IoT VLAN rollout, and stand up a shared Proxmox box for friends on its own isolated VLAN.
lab up — · 9 notes ·