Skip to content

Role: proxmox

Proxmox VE host baseline — everything the installer does not set up for us. APT repos, kernel pin, nouveau blacklist, sysctl migration, and storage registration.

Hosts: proxfold

Tasks

Task Tag
Render pve-no-subscription.sources (deb822) proxmox, repos
Remove legacy .list-format no-subscription files proxmox, repos
Disable pve-enterprise repo (deb822 + legacy .list) proxmox, repos
Disable ceph enterprise repo — legacy .list (absent) and deb822 .sources (Enabled: false injected) proxmox, repos
Pin kernel via proxmox-boot-tool kernel pin proxmox, kernel
Blacklist nouveau in /etc/modprobe.d/ proxmox, nouveau
Migrate custom /etc/sysctl.conf values to /etc/sysctl.d/99-homelab.conf proxmox, sysctl
Import ZFS pool (stash) with -f if not already imported proxmox, storage
Register nasbackup CIFS storage via pvesm add cifs proxmox, storage
Include pbs_client.yml (PBS storage + backup job) — gated on pbs_client is defined proxmox, storage, pbs
Include host_backup.yml (file-level backup of host config to PBS) — gated on pbs_host_backup is defined AND vault_pbs_host_token_secret is defined proxmox, pbs, host_backup
Include notifications.yml (PVE 9 webhook endpoint + match-all matcher) — gated on pve_discord_endpoint_name is defined proxmox, notifications

Key variables

Variable Source Value on proxfold
proxmox_repo_suite group_vars → host_vars trixie
proxmox_repo_components group_vars → host_vars pve-no-subscription
proxmox_enterprise_disabled defaults true
proxmox_ceph_repo_disabled defaults true
proxmox_kernel_pin proxfold host_vars 6.14.11-6-pve
proxmox_nouveau_blacklist proxfold host_vars true
nasbackup proxfold host_vars {id: nasbackup, server: 192.168.1.253, share: backup, username: admin, smbversion: "2.0"}
vault_nasbackup_password vault CIFS password (no_log on the task)
pbs_client proxfold host_vars {id: nas-primary, server: 192.168.1.246, datastore: nas-primary}
pbs_backup_job proxfold host_vars {id: pbs-daily, schedule: "02:00", storage: nas-primary, all: 1, mode: snapshot, ...}
vault_pbs_token_id / vault_pbs_token_secret / vault_pbs_fingerprint vault PBS API token + cert fingerprint (no_log on the pvesm add task)
pbs_host_backup proxfold host_vars {repository, fingerprint, token_secret, namespace: "host/proxfold", schedule: "02:30", paths: [...]} — see Phase 5E
vault_pbs_host_token_secret vault PBS host-backup token secret (separate user from pbs_client); gates the role include
pve_discord_endpoint_name proxfold host_vars discord-ops (webhook endpoint name)
pve_discord_matcher_name proxfold host_vars ops-all (match-all matcher, routes to endpoint)
vault_discord_webhook_homelab_ops vault Discord webhook URL, shared with Beszel + ZED
zfs_pool group_vars stash (reused from the zfs role)

Templates

Template Deploys to
pve-no-subscription.sources.j2 /etc/apt/sources.list.d/pve-no-subscription.sources
blacklist-nouveau.conf.j2 /etc/modprobe.d/blacklist-nouveau.conf
pbs-host-backup.env.j2 /etc/pbs-host-backup.env (mode 0600, no_log)
pbs-host-backup.sh.j2 /usr/local/sbin/pbs-host-backup (mode 0750)
pbs-host-backup.service.j2 /etc/systemd/system/pbs-host-backup.service
pbs-host-backup.timer.j2 /etc/systemd/system/pbs-host-backup.timer

Handlers

  • apt update — runs after any repo file changes
  • reload sysctl — runs sysctl --system after migrating values to /etc/sysctl.d/
  • rebuild initramfs — shared with the zfs role; fires after nouveau blacklist changes

Why each piece exists

deb822 repos. PVE 9 shipped with a mix of old .list and new deb822 repo files. The role standardises on deb822 and cleans up legacy files so drift detection stays quiet.

PVE 9's fresh install also places the enterprise ceph repo in deb822 form at /etc/apt/sources.list.d/ceph.sources. The role disables it by injecting Enabled: false at BOF rather than removing the file, because apt install of any ceph meta-package recreates it.

Kernel pin. The Nvidia 550 DKMS build fails to compile on kernel 6.17. Pinning to 6.14.11-6-pve keeps the GPU working until the driver catches up. See PVE 9 Upgrade — Lessons for the incident that prompted this.

sysctl migration. PVE 9 no longer reads /etc/sysctl.conf. The migration task copies any non-comment lines from the old file into /etc/sysctl.d/99-homelab.conf so custom values survive the upgrade. Idempotent — if /etc/sysctl.conf is empty the task is a no-op.

Storage. ZFS pool import handles the case where stash was exported (post-rebuild, manual admin actions). The import uses -f because a fresh PVE install has a new hostid that won't match the one the pool last saw — plain zpool import refuses in that case. Safe in this homelab (one host per pool, no dual-owner scenario). The CIFS registration writes the password from vault and runs with no_log: true.

PBS client (pbs_client.yml). Loaded via include_tasks when pbs_client is defined on the host. Registers the PBS datastore as PVE storage (pvesm add pbs) using the API token from vault, then creates the vzdump backup job (pvesh create /cluster/backup). Both tasks are idempotent — storage check via pvesm status -storage, job check via pvesh get /cluster/backup. Secrets are no_log. The subfile layout (rather than inlining) keeps main.yml readable and lets a future non-PBS PVE host skip this block cleanly by not defining pbs_client.

Host-level file backup (host_backup.yml). Loaded when pbs_host_backup is defined AND vault_pbs_host_token_secret is defined. Renders a credentials env file (mode 0600), a wrapper script invoking proxmox-backup-client backup against the host's host/<hostname> namespace with --crypt-mode none, plus a systemd oneshot service + daily timer at 02:30. The dual-gate is deliberate: the host_vars block can land before the PBS-side bootstrap completes, and the role stays inert (drift --check --diff continues to pass) until the vault entry is appended. PBS-side bootstrap (separate user, token, namespace, ACL, namespace-scoped prune-job) is one-shot manual — see the backup-restore runbook. Closes the gap left by pbs_client.yml, which only captures guest images via vzdump, never the host's own /etc, /root, or /var/lib/pve-cluster.

PVE 9 notifications (notifications.yml). Loaded when pve_discord_endpoint_name is defined. Creates a webhook endpoint pointing at the shared #homelab-ops Discord channel plus a match-all matcher routing all events through it. Default mail-to-root matcher stays in place; events fan out to both paths. Body template is stored in roles/proxmox/files/discord-notification-body.json (uses PVE's Handlebars templating with {{ escape ... }} and severity-based color conditionals) and loaded via lookup('file', ...) so Ansible's Jinja engine doesn't clash with the Handlebars syntax. Two gotchas captured during execution:

  • pvesh requires --body, --header value, and --secret value as base64-encoded strings (per pvesh usage .../webhook schema output); raw values silently store but fail at delivery with "could not decode base64 value". The role passes these through | b64encode.
  • PVE 9's template language has no truncation helper (only url-encode, escape, json per the official docs). Embedding {{ message }} works for synthetic test events (short) but real backup summaries overflow Discord's 4096-char embed description limit and the webhook returns HTTP 400. The body template uses title + severity-colored embed + hostname footer only — 256-char title always fits, full task log remains one click away in the PBS UI.

Idempotency via pvesh get /cluster/notifications/endpoints/webhook and .../matchers.

The role also disables the built-in default-matcher (which targets mail-to-root with mode=all). Without disabling, every PVE event hits both default-matcher and ops-all. The default-matcher writes to /var/mail/root, which proxmox-mail-forward (PVE's legacy-mail-to-new-notifications bridge, installed by default) intercepts and re-fires through the notification system — ops-all matches the re-fire too, producing a duplicate Discord delivery on every event. Disabling default-matcher breaks the loop. System mails (cron, smartd) still reach Discord via proxmox-mail-forward → ops-all → webhook as a single fire, so legacy mail sources aren't lost.