// my lab | nodes, containers & virtual machines, zero trust
This is not a tutorial. It is a record of what is running, why it is running, and what it took to get there. The lab changes. This note changes with it.
This note is updated as the lab changes. Last updated: 03 Jun 2026.
the hardware decision#
I am not going to go back to the beginning. What matters is where things are now, and what led to the current shape.
The previous setup was a full-size amd machine with more ram, more noise, more power draw, and a physical footprint that made relocation a serious problem. I may need to move. The calculus changed.
Size, power consumption, and portability collapsed into a single conclusion: two Minisforum MS-A2 units, each running an AMD Ryzen 7, each sitting quietly on a shelf drawing a fraction of what the old rig consumed. This aligned with something I have been working through more broadly, a digital decluttering strategy I will write about separately. Less surface, more intentional. The lab reflects that now.
The two-node architecture is standalone for the moment. I have not yet decided how to handle the split-brain scenario, whether to cluster or keep them independent with separate management. That decision is pending. In the meantime, both nodes run Proxmox VE, and the primary load lives on agc-node1.
agc-node1 | primary#
hardware
host: agc-node1
cpu: amd ryzen 7 7745hx | 8 cores | 16 threads
ram: 32gb | upgrade to 64gb planned (ram is expensive right now)
storage: 2x samsung 980 pro nvme pcie4 1tb | os + lxc root partitions
1x 2tb | larger workload partitions (xmr, btc)
os: proxmox ve 9.2.2 | kernel 7.0.2-6-pve
uptime: running clean
storage pools
local-lvm lvmthin 852gb total | 64gb used (7.5%) lxc/vm root partitions
local-data zfspool 1.86tb total | 1.1tb used (62%) bulk workload data
local dir 100gb | 14gb used isos, templates, backups
pbs-backup pbs 10.4tb | shared proxmox backup server target
The ZFS pool is carrying the weight. Most of that 62% is the Monero blockchain. The NVMe handling XMR reads is working harder than everything else combined. That is expected and by design.
snapshot from the bench
[root@agc-node1 ~]# pvesh get /nodes/$(hostname)/status --output-format json | python3 -c \
"import sys,json; s=json.load(sys.stdin); \
print(f'cpu idle: {s[\"cpu\"]*100:.1f}% used | load: {\" \".join(s[\"loadavg\"])}'); \
m=s['memory']; print(f'memory: {m[\"used\"]//1024//1024//1024}gb used of {m[\"total\"]//1024//1024//1024}gb')"
cpu idle: 93.3% | load: 1.57 1.62 1.62
memory: 11gb used of 29gb
[root@agc-node1 ~]# iostat -x 1 1 | grep nvme
nvme0n1 8.18 277.08 0.39 4.58 0.19 113.67 3822.26 94.67 45.44 2.35
nvme1n1 133.30 16758.08 0.00 0.00 0.15 69.86 6342.54 0.00 0.00 0.51
nvme1n1 is doing most of the read throughput. That is the XMR node, still syncing history from a large dataset. It will settle once fully caught up.
the network model#
Every container lives on its own VLAN. VLAN id matches the LXC vmid. Inter-VLAN routing is handled on the firewall, which also manages the WAF and SSL termination. Nothing moves between VLANs unless it has an explicit reason to. This is the zero trust posture at the network layer: default deny, explicit permit.
Cloudflare handles DNS only. Nothing is proxied. The actual traffic hits the firewall directly.
A few things still in motion: distro standardisation across containers is planned, one OS across the board, not decided yet. Each node is at 32gb RAM, the plan is to add another 32gb to each when prices allow.
containers | agc-node1#
Twelve LXC containers, all running. No VMs currently active on this node.
name vcpu ram storage pool role
----------- ---- ------ ------------- ----
web-server 2 4gb local-lvm web hosting | landing pages
wg-vpn 1 1gb local-lvm wireguard vpn | fwknop spa
hugo 2 512mb local-lvm taz blog | nginx | goaccess
tailscale-r 2 512mb local-lvm headscale relay node
xmpp 2 1gb local-lvm xmpp server | federated e2ee messaging
headscale 2 512mb local-lvm self-hosted tailscale control plane
xmr-node 4 4gb local-data monero full node
btc-node 4 8gb local-data bitcoin full node
grafana-host 2 4gb local-lvm monitoring | metrics | dashboards
ansible 2 4gb local-lvm automation | configuration management
silverbullet 1 512mb local-lvm personal knowledge base | notes | journals
etebase 1 1gb local-lvm contacts + calendar sync
workload notes#
Each container gets its own section. I add to these as things happen. That is the point.
wg-vpn | wireguard + fwknop#
WireGuard runs on Debian. The port is not open. Before any client can handshake, a single packet authorisation knock is required via fwknop. The server runs fwknopd listening passively via libpcap on the SPA port. No socket, nothing to fingerprint. The iptables policy is default drop. A successful knock inserts a temporary accept rule for udp/51820 from the client IP with a 60-second expiry. After that the window closes and the port is dark again.
The wgup script on the Fedora client handles the full sequence. Knock, wait two seconds, bring the tunnel up. One command.
| |
Getting there was not clean. The fwknop rc file parser on Fedora 2.6.11 rejects values with spaces around the = sign, silently, with a misleading error message about trailing spaces that do not exist. The fix was letting fwknop write its own rc file via --save-rc-stanza --force-stanza. Once that was done it worked. The iptables lockout during initial setup was also a reminder that setting a default drop policy without an SSH accept rule first is the kind of mistake you make once.
The proof that it works: WireGuard brought up without the knock produces no handshake. The wg show watch shows the endpoint responding with no latest handshake line. The port is genuinely dark.
hugo | taz blog#
This container is serving what you are reading. Nginx on Ubuntu Noble. Hugo static site compiled from source on every git push to Codeberg. The deploy pipeline runs through an HMAC-SHA256 verified webhook listener. GoAccess handles analytics from Nginx logs, served on an internal port via WebSocket, no external tracking of any kind.
xmpp | federated messaging#
Prosody adapted from a FreeBSD guide to Debian. Fully federated, E2EE. This is a working experiment in decentralised communications infrastructure. The goal is a messaging layer that does not route through any commercial provider and can interoperate with the broader XMPP network. It is a project as much as a service.
headscale + tailscale-r | overlay vpn#
Headscale is the self-hosted control plane for a Tailscale mesh. tailscale-r is the relay node. It works. The asterisk is that the Tailscale clients are not open source. That is a known tension. The overlay serves specific use cases, including workloads I run as a training ground for clients in my security and privacy consulting work. For my own connectivity the WireGuard setup is the preferred path. Headscale remains because some contexts require it and because understanding the tool is part of the work.
xmr-node | monero full node#
A full Monero node. Still completing its initial sync. The disk read throughput on nvme1n1 reflects this. Once synced it will settle. Running a full node is not optional if Monero is part of the sovereignty stack. Light wallets connect to someone else’s node. That someone else sees your transaction queries. This removes that dependency.
btc-node | bitcoin full node#
Full Bitcoin node on the 2TB pool. Same reasoning as XMR. You do not validate the chain by trusting someone else to validate it for you.
grafana-host | monitoring#
Grafana with node exporters across both hosts and all running containers. Everything that matters has a dashboard. The intent is full visibility into the lab without exporting any telemetry externally.
ansible | automation#
Configuration management and automation for the lab. Anything done more than twice gets a playbook. This is also the layer through which new containers get bootstrapped consistently.
silverbullet | knowledge base#
Silverbullet is a self-hosted markdown-based personal knowledge tool. What drew me to it was not the feature list. It was the coherence of the model. Everything is a page, pages link to pages, the space is yours to structure however you think. Journaling, project tracking, wikis, references, running notes from builds like this one. The customisation is done in the same markdown syntax you write in. It feels like writing code without the friction of writing code.
It runs on 512mb of ram and stays out of the way.
etebase | contacts + calendar#
Self-hosted encrypted contacts and calendar sync. Etebase handles the encryption before anything hits the server, so the server never sees plaintext. Syncs across my device fleet, all profiles, without routing through Google or Apple infrastructure.
agc-node2 | in transition#
The second node is currently the old full-size AMD machine. It is running while the second Minisforum MS-A2 arrives and is prepared. The transition is pending. I will update this section when the new hardware is in place and the workload split is decided.
what is working#
Everything listed above is running. Uptime is clean. The zero trust VLAN model is holding. The disk pressure from the XMR sync is expected and temporary.
what is unresolved#
The split-brain question for the two-node architecture. Cluster or independent. That decision shapes how failover and backup scheduling works between the nodes. Currently both are standalone with PBS handling backups to a shared target.
The Headscale open-source client tension. Noted, not resolved.
Distro standardisation across containers. Not decided yet.