Originally published on DEV Community in July 2026. This version is updated with the current state of the lab and a new section on what happened when I finally tested my backups.
I'm an IT Systems Administrator / Service Desk Team Lead L2 in Torino, Italy. During the day I manage endpoints and infrastructure for a large public-sector IT provider. At night I run PolarisCore, a home lab built with the same standards I use at work: VLAN segmentation, a real reverse proxy, internal DNS, ZFS storage with redundancy, and monitoring. Not a pile of Docker containers on a single box.
Here's the current state, pulled live via SSH an hour before publishing.
The compute layer — Proxmox
When I wrote the original version of this article, the lab ran on a single Proxmox node with 7.5 GiB of RAM. I had assigned about 10 GiB across the containers. Linux lets you over-commit memory, so it worked until it didn't, and the OOM killer took a container roughly once a month.
A mainboard swap fixed that this week:
pve-manager/9.2.11 (kernel 7.0.14-12-pve)
Host: HP EliteDesk 800 G5, i7-8700T (12 threads)
Uptime: 4 hours (fresh install)
RAM: 12 / 31 GiB used
ZFS rpool (NVMe mirror): 74.3 GB / 236 GB (31%)
Guests: 14 LXC running / 17 total, plus 1 VM
Those containers handle a Caddy reverse proxy, internal DNS (PowerDNS + Pi-hole), a self-built deployment tool, a Next.js search frontend, a job scheduler, an asset inventory, and a handful of smaller services. VLANs separate management, services, IoT, gaming, guest and VPN traffic, with the services VLAN carrying most of what faces the internet.
One detail worth stealing: the node has a single physical NIC. Every VLAN reaches it through one VLAN-aware Linux bridge with 802.1Q tagging, and the switch port is a trunk.
iface nic0 inet manual
auto vmbr0
bridge-ports nic0
bridge-vlan-aware yes
bridge-vids 2-4094
I spent a day convinced I needed a second network card before I checked how the old node was actually wired and found it had been doing exactly this all along. Read your own configs before you buy hardware.
The storage layer — TrueNAS SCALE
TrueNAS 25.10.6
Uptime: 9 hours
Pool DATA (RAIDZ1, 4x8TB): 14.1 TB / 21.8 TB used (64%)
Pool APPS (mirror): 93.8 GB / 460 GB used (20%)
Pool boot: 10.2 GB / 236 GB used (4%)
All pools: ONLINE
22 TB isn't there for the sake of it. It holds a media library (Jellyfin and the *arr stack, running as native TrueNAS apps rather than VMs), project data, and a dedicated encrypted share. RAIDZ1 across four drives gives me a single-drive failure buffer, which for a setup holding real data matters more to me than raw speed.
The part I got wrong: backups
This is the section that wasn't in the original article, and it's the one worth reading.
While preparing the container migration to the new node, I opened the backup storage to confirm I had a rollback path. The most recent archive was dated 19 April. It covered 4 containers out of 14.
For four months I believed I had a safety net. It covered less than a third of the machines and was four months stale. Nothing had alerted me. No email, no red icon in the interface, no failing job. The backup job was configured against an explicit list of container IDs, that list had been written once, and the lab had grown past it. A job that backs up four containers successfully reports success.
So I ran a full backup of everything. It failed on all seventeen guests with the same error:
tar: failed to return to initial working directory: Permission denied
The cause is a two-layer UID translation. Unprivileged LXC containers map their internal users to a host range starting at 100000. The NFS server the backups were written to applies its own remapping on top. The result is a tar process whose working directory becomes unreachable to it partway through, so it can't chdir back when it finishes.
The fix is one flag:
vzdump --all 1 --storage <target> --mode snapshot --tmpdir /var/tmp/vzdump
--tmpdir keeps the working directory on local disk instead of the network share. The archive still lands on the share; only the scratch path moves.
Seventeen archives, then seventeen restores onto the new node. Sixteen of sixteen running containers came back on the first attempt, and the seventeenth was already powered off by design. That was the first time those archives had ever been read by anyone.
The lesson isn't "make backups." Everyone makes backups. The lesson is that a backup job reports on what you told it to do, not on what you needed it to do, and the only way to learn the difference is to restore.
The backup layer — Proxmox Backup Server
The old node didn't get retired. It became the backup target:
Proxmox Backup Server 4.2.5
Host: HP EliteDesk 800 G4, i5-8500T (6 cores), 7.53 GiB RAM
Datastore backup-ct: 40.20 GB / 245.55 GB used (16.37%)
Backup count: 17 CT groups, 1 VM group — 18 snapshots
The first full run finished in ten minutes for eighteen guests. It now runs nightly at 02:30 against every guest, with deduplication, a weekly integrity verification job, and a retention policy that keeps 7 daily, 4 weekly and 6 monthly snapshots.
Three properties make this better than what I had before:
- It backs up
--all, not a list. A new container is protected the night it's created, with no config change. That single flag is the direct fix for the failure above.
- Deduplication is content-addressed at the chunk level. Seventeen Debian containers share most of their filesystem, so they cost far less than seventeen times one container.
- Verification is a separate scheduled job. Restoring is still the only real test, but a weekly checksum pass catches silent corruption without me remembering to look.
One thing I have not solved: the backup server sits in the same cabinet as the machine it backs up. That protects me against disk failure and my own mistakes. It does nothing against fire or theft. An off-site copy of the critical subset is the next job on the list, and I'd rather say that out loud than let the diagram imply I'm covered.
The alerting trap, again
I configured email notifications and sent a test. It bounced:
550-5.7.1 Messages missing a valid Message-ID header are not accepted
Gmail doesn't accept mail sent directly from a residential IP address. The fix was routing through an authenticated SMTP relay on port 587 with an app password instead of delivering directly.
Notice the shape of this. If I hadn't sent that test, the only channel that tells me a backup failed would have been silently discarding every message. I would have rebuilt exactly the situation I had just spent a day fixing: a system that looks healthy because nothing is reporting.
Test the alerting path, not only the thing being alerted on.
The network layer
FTTH 10 Gbps from TIM into a UniFi Cloud Gateway Max, distributed over a 2.5 GbE backbone to switches and clients. The 10 Gbps WAN isn't about single-stream throughput to the internet. It's headroom, so the internal backbone never bottlenecks against the uplink when backups, streaming and syncs run in parallel. TLS is a wildcard certificate issued via DNS-01, auto-renewing, with zero inbound ports opened for issuance.
A debugging note from this week that cost me an hour. The new node negotiated its link at 100 Mb/s while everything else ran at 1000 or 2500. My first theory was a bad cable, and I was wrong: the cable was a perfectly good Cat 7. ethtool showed the real answer, that the machine advertised 1000baseT/Full while the link partner advertised only up to 100baseT/Full. Autonegotiation had settled on 100 during one of the many power cycles that day and never retried, because it doesn't. Forcing the speed once and then re-enabling autoneg fixed it, and a small boot service now re-negotiates if it ever finds itself at 100 again.
ethtool <iface> | grep -A2 "Link partner advertised link modes"
That one command separates "the cable is broken" from "the link is stuck", and they have completely different fixes.
The lightweight cloud piece — Oracle Always Free
Not everything lives on-prem. Site analytics run on Umami, self-hosted, replacing GA4 across five sites, on an Oracle Cloud Always Free VM:
Ubuntu 20.04.6 LTS
VM.Standard.A1.Flex — 1 OCPU / 6 GB RAM
RAM used: 694 MiB (11%)
Uptime: 6 weeks
Stack: Umami + Postgres 15 + Caddy, Docker Compose
Six weeks of uptime and it barely registers on CPU. For a low-traffic analytics backend the free tier is more than enough, and there's no reason to pay for a VPS to do this.
A side project that came out of daily frustration
The one piece of software here that isn't infrastructure glue is NovaSCM, an open-source self-hosted alternative to Microsoft SCCM for zero-touch Windows deployment: PXE boot, autounattend generation, network scanning, WiFi 802.1X certificate management, and workflow-based software deployment. It's MIT-licensed, lives on GitHub, and runs as one of the containers above.
Why bother with all this at home
Building it the enterprise way, with segmentation, real certificate automation, redundant storage and a reverse proxy instead of exposed ports, is the best way I know to understand those patterns rather than read about them.
The backup story above is the argument in miniature. I have written backup procedures at work. I have reviewed them. I still had a four-month gap in my own lab, because reviewing a procedure and restoring from it are different activities, and only one of them tells you the truth. When something breaks at 11 pm on my own network, nobody is going to open a ticket about it, and there's no second line to escalate to. That's a different kind of pressure, and it teaches differently.
More on the project and the services I haven't written up yet at polariscore.it.