Home Lab - "starhaven"

Proxmox VE, ZFS, and an Ansible-driven automation stack - the inventory and the engineering reasoning behind it. Gaming VMs (UT99, UT2004, Minecraft, idle Steam client) are intentionally out of scope for the reasoning sections below.

Overview

Everything below - VM provisioning, OS hardening, monitoring deployment, workstation configuration - runs through Ansible playbooks, not manual clicks in the Proxmox UI.

13 guestsVMs + LXC containers
6 coresIntel Core i5-8500
64GBDDR4 RAM
9TBTotal storage

The engineering-notes sections on this page (hardening, scores, access design, backups, services, workstations, open items) were either read directly from the live Ansible repos and playbooks, or gathered from the fleet itself via read-only ansibleuser facts at the time of writing. Where something couldn't be verified live, that's called out rather than assumed.

Hardware

  • Server: Dell Precision 3630 Tower
  • Hypervisor: Proxmox VE
  • CPU: Intel Core i5-8500 @ 3.00GHz (6 cores, 6 threads)
  • RAM: 64GB DDR4 2666MHz (4×16GB)
  • GPU: NVIDIA GTX 1050 Ti (passthrough to Jellyfin)

Storage

  • NVMe: Crucial P1 1TB - Proxmox OS, LVM-thin
  • ZFS Pool: WD 8TB @ 7200rpm (vm-8tb-hdd0) - VMs/LXC, 7.27T total, 33% allocated, HEALTH: ONLINE
  • Backup HDD: WD Blue 1TB @ 7200rpm - offsite-synced via rclone

NVMe (LVM-thin) holds the Proxmox OS itself - currently 49G of 94G used (55%) on the live host, separate from the ZFS pool so a guest's storage churn can't threaten the hypervisor's own root filesystem.

→ Full ZFS breakdown: pool status, dataset list, tuning (atime/compression/ARC), scrub/TRIM schedule

Memory & CPU tuning

Unlike the rest of this page, this section is applied directly on the host over SSH, not yet codified into ansible-proxmox - flagged honestly rather than implied as automated (see the open-items list at the bottom).

Decision - KSM-only, passive swap

KSM (kernel same-page merging) is forced near-always-on via a raised ksmtuned threshold (~95% of RAM) rather than its default "only kicks in when free memory is critically low" behavior, currently holding ~873k shared pages. A zram swap device (zstd, 75% of RAM, ~47GB) sits ahead of the small disk-swap fallback so swapping is cheap. An earlier attempt at a background daemon that forced cold guest pages into swap ahead of real pressure was built, tuned through three iterations chasing lower CPU/power spikes, then fully reverted: vmstat showed real thrashing (swap-in bursts up to ~96MB/s) and it was evicting pages before KSM's own scanner reached them, dropping pages_sharing from ~374k to ~109k. Forced/blind reclaim fights KSM and evicts hot pages - it's not a substitute for real LRU-aged idle detection. Current strategy is deliberately passive: KSM plus swappiness/watermark tuning only, no forced reclaim of any kind.

Decision - ZFS ARC and VM ballooning are both left elastic, not preemptively shrunk

ARC is capped 1-24GB but left to size itself dynamically rather than pinned low to "free" RAM - Proxmox's own guidance is that unused RAM is wasted RAM, and ARC gives memory back automatically under real pressure. The same logic was applied to VM memory ballooning: three VMs (ut2004-server, fleetwm-dev, the managed workstation) had no balloon device at all - fixed allocation, unreturnable to the host even idle - so balloon devices were added to all three. Several other VMs already had a balloon device configured but with a floor set almost exactly equal to max memory (as little as an 8MB gap), which made them unable to give back anything meaningful once the host daemon actually needed to squeeze them - floors were widened based on live usage on four of them (media-server, remote-desktop, ut99-server, navidrome-bean); the Minecraft server's floor was deliberately left untouched since a JVM heap doesn't like being squeezed mid-session.

Found - a stale sysctl file was silently undoing the swap tuning on every boot

/etc/sysctl.d/99-swappiness.conf, a leftover from months before this tuning work, set vm.swappiness=0 - and because sysctl loads files in lexical order, it sorted after the intended 99-aggressive-reclaim.conf (s > a), silently overriding swappiness=60 back to 0 on every single boot since the zram/swap tuning was first applied. The zram device existed and was correctly sized the whole time, but with swappiness pinned at 0 the kernel was never actually willing to use it. Same lesson as the X11Forwarding bug in the hardening section below: a config-management task reporting success doesn't mean the *effective* runtime value is what you think - only checking /proc/sys/vm/swappiness live caught this. Fixed by removing the stale file and reapplying live.

VM / LXC Inventory

Current lineup as of the last update - vmid, name, and purpose, not the original provisioning plan. Two guests from earlier writeups (dev.server.wow, gitea-dev.server.wow) have since been retired.

ID Name Purpose Resources
100 media-server Jellyfin media server (VM) 4 cores, 3GB, 250GB
101 remote-desktop Nebula - Guacamole + MATE desktop (VM) 2 cores, 2GB, 120GB
103 ut99-server Unreal Tournament 99 server (VM) 2 cores, 2GB, 60GB
105 lxc-prometheus Metrics collection (LXC) 2 cores, 2GB, 8GB
106 navidrome-bean Navidrome music server + FTP (VM) 4 cores, 3GB, 60GB
107 lxc-grafana Monitoring dashboards (LXC) 2 cores, 2GB, 8GB
108 lxc-proxexport Proxmox metrics exporter (LXC) 1 core, 2GB, 8GB
109 mc-server Minecraft server + RCON (VM) 4 cores, 7.9GB, 280GB
110 lxc-optiping Uptime monitoring (LXC) 1 core, 1GB, 8GB
113 ut2004-server Unreal Tournament 2004 server (VM) 3 cores, 2GB, 60GB
114 fleetwm-dev fleetwm project dev box, on-demand (VM) 2 cores, 2GB, 128GB
115 vm-debian-workstation-01 MATE workstation, managed by ansible-stationctl (VM) 2 cores, 8GB, 150GB
116 steamIdle Idle Steam client for playtime/cards (VM) 2 cores, 2GB, 60GB

Non-gaming services - what and why

media-server - Jellyfin (VM 100)

GPU passthrough (GTX 1050 Ti) confirmed live via nvidia-smi and /dev/nvidia* device nodes inside the guest - hardware transcoding instead of burning host CPU cores on software transcode, the standard reason to pass a GPU through rather than share it.

remote-desktop - Apache Guacamole gateway (VM 101)

This VM runs only the Guacamole stack itself (guacd, guacamole, a MySQL backend - all three confirmed live via Docker), not a desktop. It's a pure browser-access gateway: no client software needed on whatever device is connecting, and a single point to apply access control instead of exposing a raw VNC port directly. It's a separate VM from the actual desktop it fronts - the managed MATE desktop is VM 115, a distinct guest that ansible-stationctl calls remote-workstation ("Nebula") and converges to the same environment as the bare-metal ThinkPad (same kernel tuning, hardening, dotfile sync). Guacamole on VM 101 connects to VM 115's VNC server over the LAN; the two are easy to conflate by name but are genuinely separate guests with separate lifecycles.

navidrome-bean - Navidrome + FTP (VM 106)

Navidrome itself actually runs as a Docker container (confirmed live - docker-proxy bound to port 4533), while FTP access runs as a native vsftpd systemd service on the same guest. Mixed native/container services per-host rather than a uniform policy - pragmatic, not dogmatic.

fleetwm-dev - on-demand dev box (VM 114)

Not part of the standard-inventory hosts Ansible manages routinely (it's absent from ansible-proxmox/inventory/hosts.ini's [vms] group) - provisioned and torn down around actual development work on the fleetwm project rather than kept running continuously, which matches its 128GB disk allocation existing for build artifacts without holding RAM/CPU on the host between sessions.

Ansible Automation Stack

Three repos cover the full lifecycle: VM/LXC provisioning on the Proxmox host, day-to-day fleet operations, and workstation configuration on top of a fresh install - each picks up exactly where the previous one leaves off.

Decision - why it's three repos, not one

ansible-proxmox owns everything from "bare Proxmox node" to "a running, hardened Debian guest with SSH access": ISO fetch → preseed injection → VM/LXC shell creation via the Proxmox REST API → unattended install → baseline hardening. ansible-stationctl picks up exactly where that hands off - it never touches provisioning, only converges an already-existing guest (or the bare-metal ThinkPad) to a full desktop environment. Splitting it this way means the provisioning logic doesn't grow desktop-specific special cases, and the desktop logic doesn't need to know anything about the Proxmox API.

A single dedicated ansibleuser automation account - SSH-key-only, NOPASSWD sudo - is used across essentially every managed host in both repos, rather than personal accounts. Every credential it needs (the Proxmox API token, the sudo/become password, per-VM VNC and login passwords) is Ansible Vault-encrypted in group_vars/host_vars, never plaintext - confirmed directly in ansible-proxmox/group_vars/all/main.yml (vault_become_password, pm_api_token_secret) and ansible-stationctl/group_vars/all/main.yml (vm_enduser_password_hash, vnc_password).

Decision - idempotency is verified, not assumed

ansible-stationctl/scripts/run_playbook.py wraps every routine run with an IP-drift preflight and an idempotency postflight (re-run with --check --diff, halt if anything would still change). This exists because of a real incident: cis_preserve_services was originally a play var, and Ansible's precedence puts play vars above host_vars - so a host's declared exception was silently ignored, and a real hardening run purged vsftpd off two production hosts before anyone noticed. The fix (a cis_preserve_services_baseline + cis_preserve_services_extra merge pattern that host_vars can actually reach) is now standard across the shared security-harden.yml. The postflight script exists specifically so the next version of that class of bug gets caught by a rerun, not by a second outage.

Not a curated highlight reel - this is the real toolchain, in the order it touches a host: bare metal → provisioned → hardened → monitored → recorded.

ansible-proxmox

Jinja / Ansible · MIT · active

Full lifecycle automation for the Proxmox host itself: downloads a Debian ISO, injects an unattended preseed answer file, creates the VM shell via the Proxmox REST API, installs the OS with zero interaction, then hardens the running system. Also drives LXC creation, CIS hardening, Wazuh agent rollout, OptiPing/Guacamole deployment, fleet-wide apt/apk/dnf/pacman upgrades in parallel across every VM and LXC, and declarative vzdump backup-job management. Where security-harden.yml - the shared hardening playbook behind every score below - lives (byte-identical copy in ansible-stationctl).

Consolidates three earlier repos - proxmox-ve-vms-ansible, ansible-proxmox-ve-usage-status, and ansible-proxmox-update-upgrade are now archived and folded into this one workspace.

ansible-stationctl

Python / Ansible · active

Zero-manual-labor MATE workstation config - one playbook (site.yml) converges both remote-workstation (a VM on starhaven) and a bare-metal ThinkPad E490 to an identical environment: kernel/performance tuning, security hardening, dev tooling, dotfile sync tray applets, and remote access. Picks up right where ansible-proxmox's setup-debian-base.yml hands off. Also home to the idempotency-postflight and IP-drift-preflight scripts (scripts/run_playbook.py) that wrap every routine run on both workstations.

starhaven-proxmox

data repo · active

Timestamped pveversion / node-status / ZFS-capacity snapshots pulled straight off the host - a running log for spotting drift over time, not a live dashboard.

dotfiles

shell · active

Not homelab-provisioning code, but part of the same automated loop - ansible-stationctl's dotfile-sync tray applet pulls shell config straight from this repo on both workstations, so it's a live dependency of the desktop convergence, not just a personal-config dump.

Hardening: the current state, and what was deliberately left out

Every managed host - both workstations and the whole ansible-proxmox fleet - runs from the same shared security-harden.yml: CIS Level 1 controls plus a curated set of Lynis-suggested extras, kept byte-for-byte identical between ansible-proxmox and ansible-stationctl by hand (no symlink; copied verbatim in the same session whenever either changes). What differs per host is which version of that file was actually last run there - see the scores below.

The layered stack: SSH key-only + no root login + approved cipher/MAC/KEX sets, PAM password-complexity + brute-force lockout (pam_faillock), UFW default-deny-incoming with only SSH and declared per-host ports open, fail2ban on the SSH jail, AppArmor in enforce mode, and auditd with a CIS-mapped ruleset.

What was deliberately declined, and why

Declined - GRUB bootloader password

Would raise the score slightly, but a lost GRUB password turns any future rescue/single-user boot into a real problem with no console fallback on a remote VM. Not worth the recovery risk for the points.

Declined - AIDE file-integrity monitoring

The single largest remaining scoring item (~5 points), and explicitly skipped: periodic full-filesystem AIDE scans cost real CPU/disk I/O, which directly conflicts with the kernel performance-tuning profile these are workstations for (see below).

Declined - USB/firewire storage lockout

Lynis wants USB mass-storage drivers disabled. These are physical/remote desktop workstations people plug drives into - the security gain doesn't justify breaking real daily use.

Declined - audit=0 reversal

auditd is installed but structurally inactive (ConditionKernelCommandLine=!audit=0 unmet) because audit=0 is baked into the GRUB cmdline by setup-kernel-perf-tuning.yml - a deliberate, commented performance-over-compliance tradeoff ("no compliance requirement on these boxes"), not an oversight. Reversing it needs a GRUB edit and a reboot on both hosts, and would give up part of the desktop responsiveness tuning these machines exist for.

A real bug, found and fixed

Found - X11Forwarding was never actually off

Despite the CIS block explicitly setting X11Forwarding no and the hardening summary claiming it was applied, sshd -T showed x11forwarding=yes in production. Root cause: Debian's stock sshd_config ships an uncommented X11Forwarding yes line, and OpenSSH honors the first occurrence of a directive - the stock line, which landed earlier in the file than the managed block, silently won every time. Fixed by moving the managed block to insert at the very top of the file instead of just before the Match block, then manually re-inserting the existing block once per host so it actually relocated (Ansible's blockinfile only honors a new insertbefore when first creating a block - an already-existing one doesn't move on its own). Verified fixed via sshd -T on both hosts afterward. This is the kind of bug that a hardening playbook can carry for a long time without anyone noticing, because every individual task reports success - it only surfaces if you verify the effective config, not just that the config-management task ran.

Found while researching this page, then fixed the same session

The same X11Forwarding bug was found still live on all four monitoring LXCs of the ansible-proxmox fleet plus three service VMs - the corrected security-harden.yml existed in the repo but had only been re-run against ansible-stationctl's two workstations. That fleet was re-converged with the current playbook the same session this page was written (see the before/after table below) - sshd -T confirms x11forwarding=no on all seven hosts now. One real packaging bug surfaced doing it: three of the LXCs (lxc-grafana, lxc-prometheus, lxc-proxexport) turned out to be running Ubuntu apt sources under Debian-labelled hostnames/templates, not actual Debian - apt-listbugs isn't packaged for Ubuntu, so that one install step now fails soft (failed_when: false) instead of blocking the whole run.

Security scores - what they mean, and a real before/after

For anyone not steeped in this: Lynis is an open-source security auditing tool. It inspects a running Linux system - SSH config, firewall state, kernel settings, password policy, installed packages, and dozens of other areas - and reports a single hardening index from 0-100. It is not a vulnerability scanner and the number isn't "percent secure"; it's roughly "how much of Lynis's own hardening checklist does this specific host actually pass," weighted by how relevant each check is to that machine. A stock, freshly-installed Linux box typically scores in the 55-65 range without any hardening applied at all. Anything in the high 70s to 80s reflects real, deliberate configuration work, not defaults.

The "before" column below shows what ad hoc, manually-applied security looks like on this fleet - UFW rules and SSH basics had been set up by hand over time, but never run through the full, current CIS-benchmark playbook. The "after" column is the same seven hosts immediately following a real ansible-playbook security-harden.yml run, same session, both scores from actual Lynis audits rather than an estimate.

HostRoleBefore (manual baseline)After (security-harden.yml)
local-workstationBare-metal ThinkPad, "PeakPulse"not scored before this cycle82 / 100
remote-workstationDesktop VM, "Nebula" (VM 115)75 / 10082 / 100
lxc-prometheusMetrics collection73 / 10080 / 100
lxc-grafanaDashboards73 / 10080 / 100
lxc-proxexportProxmox metrics exporter73 / 10079 / 100
lxc-optipingUptime monitoring72 / 10079 / 100
vm-debian-media-serverJellyfin73 / 10080 / 100
vm-debian-remote-desktopGuacamole gateway (VM 101, not the desktop itself)73 / 10080 / 100
vm-debian-navidrome-beanNavidrome + FTP73 / 10081 / 100

Consistent +7 to +8 point jump across the whole ansible-proxmox fleet from one playbook run, with zero service downtime - ufw status and each service's listening port were checked on every host immediately after, and every UFW rule and every monitored/media/music service was still up. remote-workstation's own history (75 → 76 → 81 → 82 across three separate hardening rounds this project) is the same pattern at smaller scale, and local-workstation converges from the same playbook so it's not a coincidence both workstations land on exactly 82. The remaining gap between the workstations (82) and the rest of the fleet (79-81) wasn't root-caused as part of this pass - auditd is inactive on both sides but for different, unverified-in-depth reasons (the LXCs likely hit the standard unprivileged-container audit-namespace limitation, the workstations have it structurally disabled via audit=0) - worth a closer diff the next time this list gets revisited.

Network exposure and access design

Nothing on the LAN is port-forwarded to the internet. The two workstations connect over Tailscale, with stable Tailnet IPs used in inventory specifically because LAN DHCP leases aren't (a real reboot once moved a host's LAN IP and broke nothing, because the automation never depended on it).

Decision - single bastion, not per-service exposure

setup-rpi-bastion.yml defines a Raspberry Pi as the only externally-reachable host: SSH ProxyJump for anything on the LAN, plus a Tailscale subnet router advertising 192.168.0.0/24 so a laptop on the Tailnet can reach any internal service directly without a second hop, and Cockpit on port 9090 for a browser-based terminal. The design goal stated in the playbook itself: nothing else on the LAN needs to be exposed or modified. As of this writing the [rpi-bastions] inventory group in ansible-proxmox is empty - the playbook is written and ready, but the bastion hasn't been deployed yet.

Decision - TOTP gateway for services with no native MFA

setup-lxc-2fa-master.yml puts Nginx + Authelia in front of services that don't have their own second factor (Grafana, and the Wazuh dashboard used elsewhere in the fleet), enforcing TOTP at the auth_request layer rather than the backend service's own IP/port ever changing or being directly exposed.

Backups - what's actually scheduled

Proxmox-native vzdump backup jobs are managed declaratively via pve-backup-jobs.yml (idempotent, API-first, no SSH to the node required) - this is the actual mechanism, not just "backups happen":

JobScheduleGuestsRetention
backup-wow-devTue, Fri 00:20vmid 104keep-last=6
backup-mc-serverSat 00:10vmid 109, 103keep-last=3
backup-vm-101Mon 01:00vmid 101 (remote-desktop)keep-last=3
backup-vm-102Tue, Fri 02:30vmid 102keep-last=6
backup-vm-111Tue, Fri 09:00vmid 111keep-last=3
Known gap - not every guest is in the schedule

backup_jobs.yml's own comments confirm LXC backups (Prometheus, Grafana, proxexport, OptiPing) are "disabled for now - uncomment when ready," and media-server (100), navidrome-bean (106), fleetwm-dev (114), and the managed workstation VM don't appear in the schedule at all. Worth being honest about rather than implying full coverage: the monitoring stack and several service VMs currently have no automated vzdump job.

Found - a VMID inconsistency between source-of-truth files

ansible-proxmox/group_vars/all/lxcs.yml defines lxc-wazuh at vmid: 115, and group_vars/all/vms.yml separately defines vm-debian-workstation-01 also at vmid: 115. Proxmox VMIDs are a single cluster-wide namespace shared between VMs and LXCs - two guests can't actually hold the same one on a real node. Confirmed which side is live: VM 115 is the real remote-workstation desktop (Tailscale-addressed, actively managed by ansible-stationctl) - so lxc-wazuh's vmid: 115 entry in lxcs.yml is the stale one and needs correcting to whatever VMID Wazuh actually runs on. Not resolved as part of writing this page - flagged for whoever touches that file next.

The "offsite-synced via rclone" backup HDD mentioned above is real (WD Blue 1TB) but its schedule/retention isn't codified in either Ansible repo - it runs outside of what's checked into source control, so this page can't state its actual cadence with the same confidence as the vzdump jobs above.

The two managed workstations, specifically

local-workstation (bare-metal ThinkPad E490, "PeakPulse") and remote-workstation (VM 115, "Nebula") converge to the same environment via one playbook set, but they're desktops first - the concerns here are different from the server-side fleet:

  • Kernel performance tuning trades some compliance surface (the audit=0 tradeoff above) for desktop responsiveness - nowatchdog, noirqdebug, threadirqs, preempt=full, tuned vm.swappiness.
  • VNC restart safety gate - before restarting the VNC server, the playbook checks for an established client connection and pauses for explicit confirmation rather than silently dropping an active session.
  • Dotfile sync via a tray applet, not a login-time script - keeps both machines' shell config converged without a surprising delay at every login.
  • A separate personal SSH trust (bean↔kenny, distinct from ansibleuser) for editing each machine as a human rather than through the automation account, with ControlMaster/ControlPersist multiplexing for fast repeat connections.

Monitoring & Observability

Prometheus/Grafana are the standard pair; the node exporter and OptiPing are custom Python tools rather than off-the-shelf alternatives. The specific reasoning for writing custom over adopting an existing exporter/uptime-checker wasn't found documented in either tool's repo at the time of writing - worth adding a line to one of those READMEs if the reasoning is worth preserving.

Smart Home - Raspberry Pi 4

  • Device: Raspberry Pi 4 (BCM2711, 4GB RAM), 99.9% uptime
  • Stack: Home Assistant, Node-RED, Zigbee2MQTT, Docker, Apache, MQTT
  • Integrations: HACS (Local WebRTC, Local Tuya - cloud-free), 20+ Zigbee devices, custom Node-RED automations

A lighter, separate stack, and not Ansible-managed the same way as the rest of the lab (it isn't present in either repo's inventory). Local-only integrations specifically (HACS Local WebRTC, Local Tuya) so camera/smart-plug control doesn't depend on a vendor's cloud being up.

Planned Upgrades

  • CPU: Intel Core i7-8700 (6C/12T)
  • RAM: Expand to 128GB DDR4 - not treated as urgent: ZFS ARC and VM ballooning are both elastic (see the memory-tuning section above), so adding a second, much larger storage pool doesn't by itself force a RAM purchase, only a possible ARC hit-ratio tradeoff worth revisiting once the new pool is live.
  • Storage: A new mirrored ZFS pool alongside the existing single-disk vm-8tb-hdd0 (which has zero redundancy today) - see the ZFS roadmap for the drive choice and reasoning.
  • Services: Nextcloud VM, Wiki.js, and more

Still open, honestly

  • Backup coverage gaps - several service VMs and all four monitoring LXCs have no scheduled vzdump job (see backups section).
  • The RPi bastion architecture is designed but not deployed - setup-rpi-bastion.yml is written; [rpi-bastions] in inventory is empty.
  • The vmid: 115 collision - confirmed lxc-wazuh's entry in lxcs.yml is the stale one (115 is really remote-workstation) - still needs the actual fix in the repo.
  • MAC addresses aren't yet recorded in ansible-stationctl's host_vars, which blocks the ARP cross-check step of its IP-drift detection - currently skipped with a note on every run rather than verified.
  • Two pre-existing, unrelated issues surfaced while checking services post-hardening (neither caused by the hardening pass - security-harden.yml never touches Docker or VNC): the navidrome Docker container reports unhealthy in docker ps despite serving traffic fine, and lxc-grafana/lxc-prometheus/lxc-proxexport turned out to be running Ubuntu apt sources rather than actual Debian, which is worth reconciling with their hostnames/templates at some point.
  • The auditd gap between the workstations and the rest of the fleet (see the scores section) - both sides have it inactive, for two different and not-yet-confirmed reasons. Worth diffing instead of assuming.
  • The memory/CPU tuning section above isn't in Ansible yet - it's all applied by hand over SSH, which is exactly how the stale-swappiness-file bug it describes was able to sit unnoticed for months. Codifying it (sysctl drop-in, zram config, ksmtuned config, balloon floors) into ansible-proxmox would make it drift-checkable the same way the rest of the fleet is.

© 2026 Tomáš Moždřeň · Privacy