Skip to content

Proxmox Host Utilities

Scripts for the Proxmox side of the fleet. They split by where they run:

ScriptRuns onWhat it does
pve-list-snapshots.shthe PVE nodeLists every VM and LXC snapshot, read from /etc/pve
pve-snapshots.pyanywhere, via APIThe same listing, over the HTTP API
auto-extend-lxc.shthe PVE nodeGrows any container rootfs that crosses 75% usage
pve-trim-storage.shthe PVE nodeRuns fstrim across every running container and VM
pve-add-ssh-key-to-lxcs.shthe PVE nodeInstalls a public SSH key into every running container

The shell scripts are installed to /usr/local/bin/ on the node. pve-snapshots.py uses the HTTP API instead and runs from a laptop, the Ansible controller or CI.

pve-add-ssh-key-to-lxcs.sh writes a public key into ~/.ssh/authorized_keys inside every running LXC, going through pct exec rather than SSH. That is what makes it useful: it reaches containers whose sshd rejects every login, so it works on a fleet you cannot SSH into yet — the bootstrap problem that direct key distribution cannot solve.

Terminal window
./pve-add-ssh-key-to-lxcs.sh --key-file ~/.ssh/id_ed25519.pub # dry run
./pve-add-ssh-key-to-lxcs.sh --key-file ~/.ssh/id_ed25519.pub --apply # write it
./pve-add-ssh-key-to-lxcs.sh --key 'ssh-ed25519 AAAA... me@host' --user deploy --apply
OK 203 metube (key already present)
WOULD 204 plex-us-24 (/root/.ssh/authorized_keys)
SKIP 216 huntarr (not running)
==========================================
Already present: 11
Would add: 54
Skipped: 3 huntarr(216) radarr-anime(229) nx-tempo(263)
Failed: 0
==========================================

Dry run is the default — it reports and changes nothing until --apply. It is idempotent in both modes, so a re-run only fills gaps.

  • Presence is tested on the key body, not the whole line. The trailing comment field differs between machines; comparing whole lines would reinstall the same key forever.
  • Every write is read back with a second pct exec before it counts as added. The exit status of the write alone is not taken as proof.
  • An existing authorized_keys is copied to authorized_keys.bak-<timestamp> beside itself first. sshd reads only authorized_keys, so the backup is inert where it lands.
  • A file with no trailing newline gets one before the append, so the new key cannot be glued onto the end of the previous one.
  • Stopped containers are skipped, never started, and are named in the summary.
  • --user targets any account; containers lacking it are reported as NOUSER rather than having it created. Only root is guaranteed to exist across this fleet.

Exit status is 0 clean, 1 bad usage, 2 not a Proxmox node, 3 at least one container failed — a failure is recorded and the run continues rather than stopping half way through the fleet.

Snapshots are cheap to take and easy to forget, and a forgotten one with a saved RAM state can pin far more storage than its name suggests. Two scripts answer “what exists and how stale is it”, with the same columns, filters and summary — pick by where you are:

  • In the node shell → pve-list-snapshots.sh. No token, no network, no dependencies.
  • From a workstation → pve-snapshots.py. Needs an API token.

Both are read-only. Neither creates, rolls back or deletes a snapshot.

Terminal window
./pve-list-snapshots.sh # everything
./pve-list-snapshots.sh --older-than 30 # stale enough to review
./pve-list-snapshots.sh --type lxc --sort age
./pve-list-snapshots.sh --format csv > snapshots.csv

It reads the guest configs under /etc/pve/nodes/*/ rather than shelling out to pct/qm per guest. That is the cluster filesystem, so one node sees every node’s guests — and it is a handful of file reads rather than a process per guest.

NODE TYPE VMID GUEST SNAPSHOT CREATED (UTC) AGE RAM PARENT DESCRIPTION
pve-dell qemu 217 prod-vm-us pre-traefik-v3 2026-05-19 23:28 91d 0h yes - before the provider plugin bump
pve-dell qemu 217 prod-vm-us clean 2026-08-18 22:51 1h 2m - pre-traefik-v3 -

A STATE column appears only when a snapshot has a leftover snapstate, which means an interrupted create or delete that needs cleaning up; the summary warns about those separately. PARENT is hidden when no snapshot in the output has one.

Options are --type, --node, --vmid, --name, --older-than, --sort, --format and --quiet — the same meanings as the table under Options below. Exit status is 0 fine, 1 bad usage, 2 not a Proxmox node.

To install it on the node alongside the other two:

Terminal window
install -m 0755 pve-list-snapshots.sh /usr/local/bin/

Nothing to install beyond that: it uses only awk, sort and date. Dates are converted to UTC in integer arithmetic rather than with strftime, because Debian’s awk is mawk and mawk has no strftime — verified against date(1) across 413 timestamps including leap days and the 2100 non-leap century.

pve-snapshots.py reaches the same data over the API.

Terminal window
cd utils/pve
export $(cat ../../ansible/.env | grep -v '^#' | xargs)
./pve-snapshots.py --insecure # everything
./pve-snapshots.py --insecure --older-than 30 # stale enough to review
./pve-snapshots.py --insecure --type lxc --sort age
./pve-snapshots.py --insecure --format json | jq .
NODE TYPE VMID GUEST SNAPSHOT CREATED (UTC) AGE RAM DESCRIPTION
pve-dell qemu 217 prod-vm-us pre-traefik-v3 2026-05-19 23:28 91d 0h yes before the provider plugin bump

RAM marks a snapshot that saved the guest’s memory (vmstate) — QEMU only, and the usual reason a snapshot is unexpectedly large. The summary line, guest count and any per-guest warnings go to stderr, so --format json and --format csv pipe cleanly.

The same variables the Ansible dynamic inventory reads, so one export covers both:

VariableDefaultNotes
PROXMOX_URLhttps://192.168.1.131:8006The node’s only address; pve-dell does not resolve in DNS
PROXMOX_TOKEN_SECRET—Preferred
PROXMOX_USERroot@pamRealm stays on the user
PROXMOX_TOKEN_IDansibleSent as PVEAPIToken=<user>!<id>=<secret>
PROXMOX_PASSWORD—Fallback; used only when no token secret is set
PROXMOX_VERIFY_SSLtruefalse is the same as --insecure

--insecure is required against this cluster. The node presents a certificate that is not valid for 192.168.1.131, so verification fails by default — deliberately, rather than silently accepting any certificate. The script says exactly that when it happens:

Error: TLS verification failed for https://192.168.1.131:8006: [SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: self-signed certificate. Proxmox ships a self-signed
certificate - re-run with --insecure (or PROXMOX_VERIFY_SSL=false) if that is expected.

A read-only API token is enough. PVEAuditor on / covers both calls the script makes.

Shared by both scripts, with the same meanings:

FlagDefaultMeaning
--node NAMEallOne node
--type {qemu,lxc,all}allVMs, containers, or both
--vmid IDallOne VMID
--name SUBSTRallGuests whose name contains SUBSTR, case-insensitive
--older-than DAYSoffOnly snapshots older than DAYS
--sort {node,age,guest}nodeage is oldest first
--format {table,csv}tablecsv carries the full description and parent
--quietoffNo summary on stderr

The API client adds --format json, --insecure, --timeout, --workers, and accepts --node and --vmid more than once.

Exit status differs, because the two fail in different ways:

Codepve-list-snapshots.shpve-snapshots.py
0clean runclean run
1bad usagebad usage
2not a Proxmox nodeAPI unusable — unreachable, rejected, or bad TLS
3—some guest could not be read; the list is short

3 matters in automation: the listing is real but incomplete, and the guest is named in a warning on stderr.

Proxmox details both scripts have to work around, and that are easy to trip over again:

  • /cluster/resources?type=lxc is not valid — it returns 400 Parameter verification failed. type=vm returns QEMU guests and containers, which are then split on the type field.
  • The API reports a snapshot named current on every guest. It is the live state rather than a snapshot, has no snaptime, and cannot be rolled back to or deleted. It is dropped. The config files have no such entry, so the shell script never sees it.
  • A guest config repeats the guest’s name inside every [snapshot] section, as it was when that snapshot was taken. The shell parser only trusts name:/hostname: from the live section above the first [, so a rename does not produce two names for one guest. nameserver: in an LXC config is not a name.
  • A snapshot can have no snaptime. An interrupted one carries snapstate: prepare instead; it shows as - for created and age rather than as 1970.

pve-snapshots.py is stdlib only — no requirements.txt, nothing beyond python3. pve-list-snapshots.sh needs only awk, sort and date.