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.
Terraform
Section titled “Terraform”tf/infrastructure/media-nas.services.homelab.muehlena.de/ (provider bpg/proxmox):
- Clones the debian-13-ci template (VMID
9503) on nodepve-02. - Attaches a dedicated second disk (
scsi1→/dev/sdb) for/data. - Cloud-init sets a dual-stack static address on VLAN 50 and the
ansibleuser. - 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.
cd tf/infrastructure/media-nas.services.homelab.muehlena.de# terraform.tfvars (gitignored) holds proxmox_api_secret + vm_ip/vm_ip6/vm_gw6terraform init && terraform plan && terraform applyvm_ip / vm_ip6 become VM_IP / VM_IP6 in the k8s manifests (the NFS PV and the
NZBGet EndpointSlice).
Ansible
Section titled “Ansible”ansible/media-nas.services.homelab.muehlena.de/ is role-based:
| Role | Does |
|---|---|
linux_setup (shared) | base packages, users, SSH |
wireguard_proton | wg0.conf, brings up wg-quick@wg0, fails the run if egress doesn’t exit via Proton |
data_disk | one ext4 filesystem on /dev/sdb, mounted at /data, builds the usenet/ + media/ tree 1000:1000 |
nfs_server | NFSv4.1-only, exports /data |
nzbget | nzbget.conf (categories tv/movies) + systemd unit as uid/gid 1000 |
nftables + nftables_override | inbound NFS/NZBGet allows and the VPN killswitch |
cd ansible/media-nas.services.homelab.muehlena.deansible-playbook media-nas.services.homelab.muehlena.de.yaml \ -i ./inventory.yaml -e "@../vault.yaml" --vault-password-file ../../.vault-passSecrets 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/exportsand 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 WireGuardPostUproutes (via the LAN gateway) and the killswitch’s egress allows.
Full-tunnel without the lockout
Section titled “Full-tunnel without the lockout”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 afterwg0is up, so the first provisioning run’s package installs still work.