Skip to content

The media-nas VM

media-nas.services.homelab.muehlena.de is a Debian 13 Proxmox VM: an NFSv4.1 server and NZBGet, all behind a Proton WireGuard full tunnel. Everything runs as uid/gid 1000 — identical to the pods and to the NFS all_squash identity — so ownership lines up and hardlink-import works.

tf/infrastructure/media-nas.services.homelab.muehlena.de/ (provider bpg/proxmox):

  • Clones the debian-13-ci template (VMID 9503) on node pve-02.
  • Attaches a dedicated second disk (scsi1/dev/sdb) for /data.
  • Cloud-init sets a dual-stack static address on VLAN 50 and the ansible user.
  • No Netbox record and no Vault AppRole — deliberately stripped down. Because there’s no Netbox entry, Ansible targets a static inventory.yaml, not the dynamic Netbox inventory.
Terminal window
cd tf/infrastructure/media-nas.services.homelab.muehlena.de
# terraform.tfvars (gitignored) holds proxmox_api_secret + vm_ip/vm_ip6/vm_gw6
terraform init && terraform plan && terraform apply

vm_ip / vm_ip6 become VM_IP / VM_IP6 in the k8s manifests (the NFS PV and the NZBGet EndpointSlice).

ansible/media-nas.services.homelab.muehlena.de/ is role-based:

RoleDoes
linux_setup (shared)base packages, users, SSH
wireguard_protonwg0.conf, brings up wg-quick@wg0, fails the run if egress doesn’t exit via Proton
data_diskone ext4 filesystem on /dev/sdb, mounted at /data, builds the usenet/ + media/ tree 1000:1000
nfs_serverNFSv4.1-only, exports /data
nzbgetnzbget.conf (categories tv/movies) + systemd unit as uid/gid 1000
nftables + nftables_overrideinbound NFS/NZBGet allows and the VPN killswitch
Terminal window
cd ansible/media-nas.services.homelab.muehlena.de
ansible-playbook media-nas.services.homelab.muehlena.de.yaml \
-i ./inventory.yaml -e "@../vault.yaml" --vault-password-file ../../.vault-pass

Secrets in ansible/vault.yaml (only the truly secret values): media_nas_wg_private_key, media_nas_nzbget_control_password, media_nas_usenet_username, media_nas_usenet_password. The rest of the Proton config (Address/DNS/PublicKey/Endpoint) is public and lives in the role defaults.

Two CIDR sets: nfs_clients vs offtunnel_routes

Section titled “Two CIDR sets: nfs_clients vs offtunnel_routes”

The playbook keeps two concerns separate (both dual-stack):

  • nfs_clients — who may mount NFS / reach the NZBGet API. Kept narrow: the services LAN + the Cilium pod CIDRs. Cilium runs native routing with masquerade off and advertises the PodCIDRs (10.200.0.0/16, 2a0c:9a40:8e50:7::/108) via BGP, so pods hit the VM with their own pod IPs — hence the pod CIDRs are here. Drives /etc/exports and the inbound firewall allows.
  • offtunnel_routes — what stays off the Proton tunnel: all private/homelab space (10/8, 172.16/12, 192.168/16, the homelab v6 /48, ULA). Drives the WireGuard PostUp routes (via the LAN gateway) and the killswitch’s egress allows.

wg0 is full-tunnel (AllowedIPs = 0.0.0.0/0, ::/0) so public internet egress (NZBGet ↔ news server = the privacy goal) goes through Proton. But a naive full tunnel also reroutes the VM’s replies to your SSH/Ansible client into the tunnel and black-holes management the moment wg0 comes up. The offtunnel_routes PostUp rules route all private/homelab space via the LAN gateway instead, so SSH/Ansible from anywhere in the homelab — and replies to pods — stay direct. The killswitch mirrors the same allow-list.

The killswitch (and why it’s not a wg-quick table)

Section titled “The killswitch (and why it’s not a wg-quick table)”

The shipped upstream design loads the killswitch as a separate nftables table via wg-quick PostUp. That is unsafe on this repo: the shared nftables service does flush ruleset on every reload, which would silently wipe that table and drop egress wide open. Instead the killswitch lives in the managed ruleset (nftables_override’s custom_output.nft), so it is loaded atomically with every nft -f and stays on even if wg0 drops. It permits only wg0, the LAN, the pod CIDRs, and the Proton handshake, and drops the rest — and because it lets the handshake through, wg0 can still come up after a reboot with the killswitch active.

The killswitch is whole-box: once it’s applied, all non-LAN VM egress (including apt) goes through Proton. Ansible applies it after wg0 is up, so the first provisioning run’s package installs still work.