Chapter III · Self-hosted infrastructure

Homelab Topology: The Map

Tested on
Proxmox VE 9.2 (pve-manager 9.2.21 as reported by the API), Intel Core Ultra 9 285 (24 threads), 32GB RAM, ~954GB NVMe, a 16GB workstation GPU. Single node, no cluster.
Last updated
2026-10-17

This is the floor plan of my homelab. One hypervisor, a pile of LXC containers, two VMs, and a backup server that lives on the same box it protects. It covers what runs where, how I split LXC versus VM, how RAM and disk are carved up, the network shape, and how off-host backup wires in. 🦞

If you want the why behind one-service-per-container and the throwaway build-container pattern (now retired, see below), that’s a separate guide: service-isolation.md. This one is the map, not the manifesto.

The Shape

One physical node. Everything is a guest on it. There is no separate NAS box running compute, no second hypervisor in this lab, no Kubernetes. The host does exactly one job: run guests and back them up. All service logic lives inside containers and VMs, never on the host itself.

Hypervisor (PVE 9.2, 24 threads, ~31GiB usable RAM, ~954GB NVMe)
│
├── LXC containers (15, all running, one service each, unprivileged except PBS)
│     ├── 100  DNS filter              DNS ad-blocking
│     ├── 101  zero-trust connector    outbound-only remote access
│     ├── 102  comics / ebook library  read-only mounts from the Windows desktop
│     ├── 103  manga downloader        writes to a share on the Windows desktop
│     ├── 104  game library            ROM library with a 100G data volume
│     ├── 105  SIEM                    security monitoring (the RAM and disk hog)
│     ├── 106  manga downloader        second instance with a different library
│     ├── 107  dashboard               service launcher / status page
│     ├── 108  fleet hub               Brigade fleet coordination hub
│     ├── 109  automation              n8n + Postiz publishing stack
│     ├── 111  photo library           Immich
│     ├── 116  network monitoring      LibreNMS
│     ├── 118  DNS sync sidecar        keeps the second DNS instance in step
│     ├── 120  CI runner               GitHub Actions runner for the SOC stack
│     └── 121  PBS                     Proxmox Backup Server (privileged)
│
└── VMs (the exception, both running)
      ├── 110  full desktop VM         a peer's separate OS, managed independently
      └── 130  agent worker VM         Brigade worker that needs a full OS

The old ephemeral tier of stopped build and smoke-test containers is gone. See The Ephemeral Tier (Retired).

The persistent tier is fifteen always-on containers and two VMs. Nothing sits stopped on a shelf anymore.

LXC by Default, VM by Exception

The single biggest decision in this lab is LXC unless you have a concrete reason for a VM, and I land on LXC the overwhelming majority of the time.

LXC containers share the host kernel. That makes them cheap: a DNS blocker idles at 512MB RAM, boots in under a second, and adds essentially zero virtualization overhead. On a 32GB box that frugality is the whole game. I can run fifteen containers plus two VMs because each container only costs what it actually uses.

I reach for a full VM only when one of these is true:

If none of those hold, it’s a container. The default is not a coin flip, it’s a strong prior.

Privileged vs Unprivileged

Inside the LXC tier there’s a second split. Fourteen of fifteen containers run unprivileged (unprivileged: 1), which maps container root to an unprivileged host UID. That’s the safe default and where every normal service lives.

The exception is the backup server (CT 121), which runs privileged with nesting=1. It needs to chown chunk files to the backup user (UID 34) and, during its original design phase, needed to mount network shares. An unprivileged container can’t do that UID remap cleanly. So PBS gets privileged, and in exchange I keep its attack surface tiny: it talks to the backup datastore and nothing else. Privileged is a deliberate, documented exception for exactly one container, not a convenience I hand out.

Most containers carry features: nesting=1,keyctl=1 so Docker-in-LXC works for the services that ship as compose stacks (the automation and photo stacks both run Docker inside their LXC). The fleet hub runs with nesting=0 because it does not run containers, and it gets /dev/net/tun passed through for its overlay-network daemon.

Container Inventory and Roles

Always-on containers with their configured allocations (from pct config, read 2026-10-17):

CTIDRoleCoresRAMDiskPrivNotes
100DNS filter1512MB16GunprivTiny, always on, first line of the network
101Zero-trust connector11GB3GunprivOutbound tunnel connector, no inbound ports
102Comics / ebook library21GB16GunprivLibrary shares bind-mounted read-only
103Manga downloader22GB16GunprivWrites to a bind-mounted share
104Game library22GB30G + 100GunprivSeparate 100G library volume, excluded from backup
105SIEM48GB100GunprivThe RAM and disk hog, security stack
106Manga downloader22GB16GunprivSecond instance, separate library
107Dashboard22GB8GunprivService launcher
108Fleet hub1512MB4GunprivCoordination hub, reachable only over the overlay network
109Automation23GB64Gunprivn8n + Postiz, Docker compose inside, 2GB swap
111Photo library44GB32GunprivImmich, Docker compose, ML container, NAS bind-mount
116Network monitoring44GB32GunprivLibreNMS, firewall=1 on the NIC
118DNS sync sidecar1256MB4GunprivSmallest guest in the lab
120CI runner24GB30GunprivSelf-hosted runner, onboot off
121Backup server24GB16GprivPBS, datastore bind-mounted from the host

VMs:

VMIDRoleCoresRAMDiskNotes
110Full desktop VM88GB300GAn independently managed desktop VM, hands off during host maintenance
130Agent worker VM46GB64GRuns Brigade worker jobs

A few principles fall out of this table:

The Ephemeral Tier (Retired)

This lab used to keep a tier of build and smoke-test sandboxes (clean-room OSS PR builds, an MCP smoke container, a QEMU smoke template) that sat stopped until a job needed them. Those containers were destroyed in a September 2026 cleanup, and three of the freed IDs now belong to the library containers. A stopped container still holds its disk on the thin pool, which matters now that the pool is two-thirds full. The retired pattern is described as history in service-isolation.md. The one survivor from that era is the CI runner (CT 120), which is now an ordinary long-lived container.

Resource Allocation Reality

The guests request more RAM on paper than the host physically has, and that’s fine because they don’t all peak at once. But “fine” has a ceiling, and I’ve hit it.

The configured RAM for the 15 containers sums to about 38GB, and the two VMs add 14GB more, so roughly 52GB is promised against about 31GiB usable after the hypervisor takes its cut. On 2026-10-17 the node reported about 23GiB in use of 31GiB. That leaves headroom for the always-on tier and not much else. The SIEM (8GB), the automation stack, and the photo stack carry most of the real usage.

The lesson I keep relearning: on a single 32GB node, RAM is the binding constraint, not CPU and not disk. The Ultra 9 has 24 threads and the node idles at about 5% CPU. Disk is no longer comfortable either: the 816GiB LVM-thin pool is 69% used (it was 37% in June), mostly VM 110’s 300GB disk, the game library, and the SIEM. RAM is still the thing I plan around, and disk is the next one to watch. Every time I add a service I ask “what does this push us to at peak?” before I ask anything else.

Storage layout on the node (from pvesm status):

StorageTypeRole
localdirHost root, ISO/template storage (~94GiB, 41% used)
local-lvmlvmthinAll container and VM root disks (~816GiB thin pool, 69% used)
pbs-local-fastpbsThe backup datastore, registered as Proxmox storage (~245GiB, 72% used)

Note the thin pool is over-committed (allocated volumes sum past physical size). That’s normal for LVM-thin and works as long as actual usage stays under physical, but it means I watch the pool usage, not the sum of the disk sizes I handed out.

Networking Shape

Flat and boring on purpose. All guests sit on a single Linux bridge (vmbr0) on the LAN. Most containers take DHCP and register a hostname. I don’t hand-assign IPs unless a service needs a stable address.

The guests that break that pattern do it for a reason:

DNS for the LAN runs inside the lab itself (CT 100, with CT 118 keeping a second instance in sync). A second filter instance runs on separate hardware, which softens the old problem: the primary resolver is a guest on the box, so if the host is down, that instance is down too. For a home lab that’s an acceptable circular dependency, but it’s worth knowing it exists. See adguard-dns-sinkhole.md.

How Backup Wires In

Backup is not bolted on from outside. It is a first-class guest. Proxmox Backup Server runs as CT 121 on the same node it protects. That sounds like putting the lifeboat on the ship, and it would be a problem if PBS were the only copy. The design is one fast local tier plus off-host mirrors:

  1. Local PBS datastore. One scheduled vzdump job (daily-prod, 02:00, snapshot mode, zstd) writes to pbs-local-fast. It covers 12 guests: the DNS pair, the connector, the SIEM, the dashboard, the hub, the automation and photo stacks, network monitoring, the CI runner, the game library CT, and VM 110. It skips the media downloaders, the worker VM (both rebuildable), and PBS itself. PBS does content-addressed chunk dedup, so a daily backup only writes the chunks that actually changed. The datastore lives on its own host volume, bind-mounted into the PBS container, so a full datastore cannot starve the thin pool that holds guest disks. Restores are fast because the data is local.
  2. NAS mirror, weekly. The datastore is rsynced to a CIFS-mounted NAS share. Because PBS chunk filenames are content hashes, unchanged chunks have unchanged names and mtimes, so rsync ships only what changed.
  3. Cloud mirror, weekly, currently disabled. An rclone sync from CT 121 to cloud object storage ran until 2026-08-19, when the shared OAuth client hit its API quota. The timer stays off until the job moves to a private client ID and a full manual sync finishes clean. Until then the cloud tier does not exist, and the NAS copy is the only off-host tier.

So the on-node PBS is the fast tier, and the NAS copy is the “the whole node caught fire” tier. If the hypervisor dies, the backups survive on one independent off-host location, which is one fewer than I designed for. The full restore mechanics and the restic-based backup of the agent host (a separate machine) live in backup-recovery.md. The datastore itself ran past 90% in early September, so I watch its usage with the same attention as the thin pool.

The PBS Deployment That Taught Me Something

The original plan was to put the PBS datastore directly on an NFS export from the NAS, so backups landed off-host immediately with no mirror step. It did not work, and the failure was instructive.

PBS writes a chunk to a temp file and then atomically renames it into place. The NAS is a consumer 2-bay unit with 2016-era firmware, and its NFS server could not commit that write-then-rename pattern reliably. First I got stale readdir results (mkstemp ... failed: ENOENT), which I beat back with aggressive cache-disabling mount options (lookupcache=none,acdirmin=0,acdirmax=0). But even with caching off, the atomic rename itself failed: Atomic rename failed ... No such file or directory. The chunk write would land, the rename would not commit through the NFS server’s metadata layer, and the backup aborted.

There was no mount option that fixed the rename. The constraint was the appliance firmware, not my config. Two more sharp edges along the way: creating the chunkstore over NFS took ~25 minutes because PBS makes 65,536 subdirectories and each mkdir round-trips to the NAS at ~33/sec, and the NAS’s “Guest Permission: Forced” setting mapped every write to nobody, so PBS’s chown 34:34 on chunk files failed with EPERM.

So I abandoned NFS-as-datastore entirely. The working design is the one above: local LVM datastore, weekly rsync to the NAS over CIFS. I gave up “instantly off-host” and got back atomic renames, fast chunkstore creation, and dedup that carries through to the mirror for free. The takeaway: a backup target has to honor the write semantics your backup tool assumes. PBS assumes atomic rename. Verify your storage actually provides it before you build on it, and don’t fight appliance firmware you can’t change.

Verification

Quick read of the whole topology from the node (all read-only):

# What's running, both kinds. CTIDs and VMIDs share one number pool,
# so always check BOTH before assuming an ID is free or missing.
ssh <hypervisor> "pct list"      # LXC containers only (expect 15, all running)
ssh <hypervisor> "qm list"       # VMs only (expect 2)

# Per-guest config (RAM, cores, disk, priv flag, network)
ssh <hypervisor> "pct config 105"

# Proxmox version
ssh <hypervisor> "pveversion"

# Storage pools and headroom
ssh <hypervisor> "pvesm status"

# Scheduled backup jobs
ssh <hypervisor> "pvesh get /cluster/backup --output-format json-pretty"

# Live RAM pressure on the node, the constraint that actually matters
ssh <hypervisor> "free -h"

# Confirm the backup server is up and its datastore is mounted
ssh <hypervisor> "pct exec 121 -- proxmox-backup-manager datastore list"

If pct list shows a guest you expected and qm list shows another, that’s not a bug: LXC and VM share the ID pool, so a VM at 110 happily coexists with containers at 109 and 111.

Gotchas

  1. pct list and qm list are two different worlds. Containers and VMs share one CTID/VMID number pool but show up in different commands. Check both before creating an ID or declaring one missing. I have absolutely confused myself by running only pct list and concluding a guest didn’t exist when it was a VM.

  2. RAM is the budget, not CPU. On a 32GB node the host gives you ~31GiB usable, and real usage sits near 23GiB with everything warm. Plan additions against peak RAM. The 24-thread CPU is never the limit. Disk is now the second constraint.

  3. Privileged containers are an exception you justify, not a default. Only the backup server runs privileged, because it needs UID-remapped chown on chunk files. Everything else is unprivileged. If you find yourself making a container privileged “to make it work,” figure out the actual capability you need first.

  4. Destroyed IDs get reused. After the September cleanup, three freed container IDs were handed to new library containers. Notes, dashboards, and backup selections that reference an ID by number will quietly point at a different service. Re-check ID-based references after any cleanup.

  5. The thin pool is over-committed by design. Allocated disk sizes sum past physical capacity. That’s fine for LVM-thin as long as actual usage stays under physical, but it means you monitor pool usage (pvesm status), not the arithmetic sum of disk sizes you handed out. Tune thin_pool_autoextend_threshold so the pool warns before it fills.

  6. DNS lives inside the lab. The primary resolver is a guest on the same host it serves. If the node is down, that resolver is down with it. A second instance on separate hardware catches most of it, but know the circular dependency is there before you debug a “the internet is broken” panic that’s really “the hypervisor rebooted.”

  7. Backup target write semantics matter more than backup target speed. PBS assumes atomic rename. A consumer NAS over NFS couldn’t deliver it, and no mount option fixed appliance firmware. Local datastore plus a weekly rsync mirror beat a clever-but-broken direct-to-NAS datastore. Match your storage to your backup tool’s assumptions.

  8. A disabled mirror is a missing tier. The cloud mirror timer has been off since 2026-08-19. A backup design with three copies on paper and two in practice should say so in the doc, and the doc should be updated when the third copy returns.

  9. One node has no failover. This is a single hypervisor. If it dies, every service is down until it’s back. The mitigation is not high availability (overkill for home), it’s good off-host backups so a dead node is a restore, not a catastrophe. See backup-recovery.md.