An Engineer's Blog

Back

A single-box homelab on Proxmox VE 9Blur image

Overview#

The whole homelab runs on one desktop-class box under the stairs: Proxmox VE 9, an OPNsense VM as the network edge, and one unprivileged LXC container per service. It exists for my family first: photos, documents, passwords, media, all of it on this one box, and nobody else in the house ever has to think about it. Better digital life for the family, without adding a visible machine room to the house, was the quiet goal from day one.

The usage numbers still surprise me, though. The most-used service is not the media stack; it is Vaultwarden, the family password manager. My wife approved of it, and it saw the biggest uptake of anything I have ever deployed: the household’s logins now live in a vault on our own hardware, backed up with everything else. That one service did more for the family’s digital life than every other service on the box combined.

The design goal was never “datacenter at home”; it was “one machine I can rebuild from notes, where every service is isolated, disposable and backed up”.

Two conventions carry most of the design:

  • One box, one service. Each app gets its own container with its own IP, config mount and backup. An upgrade of one service can never break its neighbour.
  • Everything is a mount point. Container root disks stay tiny and disposable on the NVMe pool; the actual data lives in host directories bind-mounted into the guest, so a container can be destroyed and recreated in minutes with zero data loss.

Homelab architecture: internet, OPNsense edge VM, container groups on the Proxmox host, and the ZFS pools underneath

Hardware#

PartChoice
BoardGigabyte B760M Gaming Plus WiFi (DDR4)
CPUIntel i5-13400, 10 cores / 16 threads
RAM64 GB DDR4-2667
Boot / VM disk2 x 960 GB Micron 7450 NVMe (ZFS mirror)
Bulk disk14 TB Seagate Exos (ZFS, single disk)
NICsIntel I226-V 2.5 GbE PCIe card (LAN), Realtek RTL8111 1 GbE (WAN)
iGPUIntel UHD Graphics 730

The 13400 with its UHD 730 handles Jellyfin transcoding and Scrypted camera streams through /dev/dri passthrough; no discrete GPU needed. The two NICs split the network roles: the Intel I226-V PCIe card bridges to the LAN at 2.5 GbE, the Realtek port bridges to WAN. How the box went together, part by part with photos: Assembling the homelab box.

Storage#

Two ZFS pools, both created with ashift=12:

zpool create -o ashift=12 rpool mirror nvme0n1p3 nvme1n1p3
zpool create -o ashift=12 utank /dev/sda1
bash
  • rpool (mirror, ~890 GB) holds the OS, every guest root disk, and an appdata dataset mounted at /data. Guest disks are cheap here; they are small and snapshotted.
  • utank (~12.7 TB, single disk) holds the bulk: the media library under HTPC/, the photo library under LIBRARY/, and backups under BKP/. The disk itself is a shucked Seagate Expansion Desktop; what turned out to be inside is its own post: Shucking the Seagate Expansion Desktop 14TB.

Scrubs run monthly. Both pools came back clean last scrub, 0 errors.

One service, one container#

All 48 containers are unprivileged LXC guests provisioned from community-scripts ↗. VMIDs are allocated in ranges by purpose, and the ranges double as an address plan (more on that below):

VMID rangePurposeExamples
10xxNetwork infrastructureAdGuard Home, Caddy, PBS
2001Base templateDebian + zsh + pyenv, cloned for new guests
40xxMedia servingJellyfin, Audiobookshelf, Komga
50xxSelf-hosted appsVaultwarden, Immich, Paperless-ngx, Gitea
60xxMiscellaneousLubelogger, travel planning, file conversion
8xxxHome automationScrypted NVR, go2rtc, plus the HAOS and OPNsense VMs

The address plan falls out of the VMID scheme. The LAN is a private /23 split in two ranges: one for core network devices, one for service guests, and each service guest’s last octet comes from its VMID. The Proxmox tags field carries that octet so the mapping stays visible in the web UI. All guests get their addresses from DHCP; the reservations live in OPNsense’s dnsmasq with a description per host.

A few example services per group One container per service. Each group shows only a few examples; the pattern holds for all 48.

Every service container follows the same shape:

  • a small root disk (1 to 20 GB) on rpool/data
  • its persistent state bind-mounted from /data/lxc/<service>/
  • read-only media mounts from utank where it needs them

Because the state is outside the container, rebuilding a guest is: clone the template, re-add the mount, done. That low cost is what makes the one-service rule livable; a guest I can rebuild in minutes is a guest I am not afraid to touch. Guests that need hardware get it explicitly: the media and camera guests have /dev/dri bound in for the iGPU, Scrypted also gets /dev/kfd for compute, and nothing else sees the host devices.

The edge: OPNsense in a VM#

The router runs as a VM in the 8xxx range above, started first on boot (order 1, 30 s boot delay), with 24 GB of RAM and two virtio NICs:

  • net0 on vmbr0, the LAN bridge (2.5 GbE port), a static address in the core range
  • net1 on vmbr1, the WAN bridge (1 GbE port), DHCP from the ISP

Running the firewall as a guest keeps it in the same backup and snapshot pipeline as everything else. The tradeoff is obvious and accepted: no host, no network, so the Proxmox host itself is managed from the LAN side only.

DNS and DHCP#

The resolver chain is split across three components on purpose:

clients
  -> AdGuard Home        :53   filtering, per-client stats, home.arpa rewrites
  -> Unbound (OPNsense)  :53   DNSSEC, recursion, DoT upstream to 1.1.1.1
  -> dnsmasq (OPNsense)  :53053  authoritative for home.arpa, DHCP lease names
text

AdGuard Home is the only resolver clients see; its sole upstream is Unbound on the OPNsense LAN IP, so nothing on the LAN can bypass filtering by pointing elsewhere. Unbound does the recursive work with DNSSEC on, encrypts upstream queries with DoT (verified against cloudflare-dns.com), and forwards home.arpa lookups to dnsmasq on 127.0.0.1:53053, which knows every DHCP lease and static entry. About a hundred hosts have static entries with descriptions; pve.home.arpa, hass.home.arpa, scrypted.home.arpa and friends resolve without touching public DNS.

DHCP uses tags to hand different resolvers to different hosts. A tag groups one DHCP option with one range; a static host entry then opts a client into it:

  • default range: option 6 = AdGuard Home (filtering for everyone)
  • a tag per tunnel: option 6 = that tunnel provider’s DNS, so a host routed through a tunnel also resolves through it

The DNS tag matters because of the next section: a host policy-routed through a VPN must resolve through that VPN’s DNS too, or queries leak out the wrong path.

Selective routing over WireGuard#

Three always-on WireGuard tunnels terminate on OPNsense as additional WAN interfaces, each to a commercial VPN provider, each with its own peer, keepalive of 25 s, and routes disabled so nothing uses them by default. Each exists so a defined group of hosts can pin its egress to a single provider.

Policy routing is one repeated pattern. A firewall alias lists the hosts, a LAN rule sends them to the tunnel gateway unless the destination is local, and a hybrid outbound NAT rule masquerades them on that interface:

action: Pass, quick
interface: LAN, direction: in
source: <tunnel host alias>
destination: !( RFC1918 networks )
gateway: <tunnel gateway>
text

Result: LAN traffic between guests never hairpins through a VPN, everything those aliases talk to the internet for exits through the assigned tunnel, and the DHCP DNS tags from the previous section keep name resolution on the same path. The peer configs are regenerable from each provider’s API with a small script, so rotating servers is a re-run of the script, not a rewire.

Remote access#

No inbound ports are forwarded. Remote access runs through a Cloudflare Tunnel from the cloudflared container to a reverse proxy chain: Caddy terminates TLS per service, Authelia sits in front for SSO and two-factor auth, and the tunnel carries SSH to the Proxmox host the same way. Services are published as service.home.arpa internally and a personal domain externally, both names pointing at the same Caddy.

Media, photos and documents#

Media serving is where guests lean on the iGPU: Jellyfin transcodes through /dev/dri, with its library mounted read-only from utank. Photos run on Immich with the library on utank, documents on Paperless-ngx, comics on Komga, audiobooks and podcasts on Audiobookshelf.

Home automation and cameras#

  • Home Assistant runs as a HAOS VM (the one case where an appliance image beats a container), with Mosquitto and the Matter server as add-ons.
  • Scrypted runs in an LXC with /dev/dri bound in for camera transcoding, mounted volumes on rpool, plus optional device mounts (/dev/kfd, Coral-style apex) wired up for hardware I have not added yet.

Backups#

Two layered jobs, all snapshot mode where possible:

  1. Nightly: vzdump of every guest except PBS itself, into the PBS container’s datastore on utank, which also moves the guest state off the SSD pool onto the bulk HDD.
  2. Nightly: the PBS container backs itself up (keep-last 10) to a plain directory dataset, so the backup server is itself backed up.

Inside the guests, Backrest (restic) covers the app data in /data separately from the whole-guest vzdumps, which makes single-file restores cheap, and pushes it to a remote repository, which is the offsite leg of the story.

Closing#

What I would keep: the VMID-to-IP convention, config-outside-the-container mounts, and running the firewall as a guest. What is still open: zpool upgrade for both pools (feature flags are pending since the Proxmox 9 upgrade) and a Coral TPU for Scrypted. The whole stack fits in one desktop box that idles quiet enough to live under the stairs, which was the actual requirement. Of everything I have built for this house, this is the piece I am proudest of: it carries the family’s digital life, and the family never has to think about it.

A single-box homelab on Proxmox VE 9
https://tin.ng/blog/2025-08-31--proxmox-9
Author Tin Nguyen
Published at August 31, 2025
Comment seems to stuck. Try to refresh?✨