Homelab the Right Way

Home › Posts

Delete old Proxmox VMs without regret

The backup, verify, then destroy-with-purge routine for clearing out stopped guests safely, including the linked-clone check that stops you deleting something a running VM depends on.

· 3 min read · proxmoxcleanupvzdumpbackups

Delete old Proxmox VMs without regret
Watch: Delete old Proxmox VMs without regret on YouTube
The video for this post. Watch on YouTube

Every homelab accumulates guests. The container you built to try something, the VM from a tutorial, the old version of a service you migrated away from. Eventually most of the guest list is grey stopped icons, and the nightly backup job is dutifully copying all of them.

Cleaning house is easy to put off because deleting things is scary. The way to make it not scary is a routine where every single guest you delete is recoverable: back it up, verify the backup, then destroy with purge.

The trap: stopped is not the same as unused

A template is always stopped. A container that was turned into the base of linked clones is always stopped. Delete the base and the clones keep running until the next reboot, and then they do not.

So before you delete anything, find out what is actually unused.

Step 0: check for linked clones

For every stopped guest, check whether any config references its disks as a base:

for id in $(pct list | awk 'NR>1 && $2=="stopped"{print $1}') \
          $(qm list | awk 'NR>1 && $3=="stopped"{print $1}'); do
  if grep -lq "base-${id}-" /etc/pve/nodes/*/*/*.conf 2>/dev/null; then
    echo "KEEP $id: base for linked clones"
  else
    echo "candidate $id"
  fi
done

Tag anything marked KEEP as keep in the GUI and it is out of the discussion. Then walk the candidates with a human eye: the name, the notes, the last-modified date of its config. If you are not sure what a guest is, start it, look, stop it. Ten minutes per mystery guest is cheaper than one wrong destroy.

Step 1: back it up, in stop mode

A stopped guest does not need snapshot mode, and stop mode gives the most consistent archive. Write it to your backup storage with vzdump so the result is one portable file, and give it a note that will mean something in a year:

vzdump 212 --storage nas-backup --mode stop --compress zstd --bwlimit 60000 \
  --notes-template 'DELETED {{guestname}} ({{vmid}}) cleanup 2026-10'

Watch the backup storage's free space as you go. Work in order of "would be saddest to lose", and if the share gets tight, prune old nightly backups to make room before carrying on. The bandwidth limit keeps your busy guests from lagging while the dumps run; it costs a few minutes per guest.

Step 2: verify the archive

A backup you have not at least integrity-checked is a rumour. For a container archive:

zstd -t /mnt/pve/nas-backup/dump/vzdump-lxc-212-2026_10_09-21_14_03.tar.zst

VM archives are .vma.zst files and the same zstd -t works. This proves the file is whole and decompresses. It does not prove the guest boots. For anything you would truly mourn, go further: restore it to a new ID and start it with the network unplugged.

Then mark the archive protected, so the nightly prune cannot eat it:

F=vzdump-lxc-212-2026_10_09-21_14_03.tar.zst
pvesh set /nodes/pve1/storage/nas-backup/content/nas-backup:backup/$F \
  --protected 1

Step 3: destroy, with purge

pct destroy 212 --purge
# or
qm destroy 212 --purge

--purge removes the guest from every backup job, the replication config and HA. Without it, the nightly backup job keeps a reference to a guest that no longer exists and fails with an error about that ID every night until you edit the job by hand.

The loop

For more than a handful of guests, put the IDs in a file (one per line, after the human pass) and let a script do the three steps:

#!/bin/bash
set -u
STORE=nas-backup
DUMP=/mnt/pve/$STORE/dump
NOTE="DELETED {{guestname}} ({{vmid}}) cleanup $(date +%Y-%m)"
while read -r id; do
  if pct config "$id" >/dev/null 2>&1; then kind=lxc; cmd=pct
  else kind=qemu; cmd=qm; fi
  echo "== $id ($kind)"
  vzdump "$id" --storage $STORE --mode stop --compress zstd \
    --bwlimit 60000 --notes-template "$NOTE" \
    || { echo "backup FAILED $id"; continue; }
  f=$(ls -t $DUMP/vzdump-$kind-$id-*.zst | head -1)
  zstd -t "$f" || { echo "verify FAILED $id"; continue; }
  vol="$STORE:backup/$(basename "$f")"
  pvesh set /nodes/$(hostname)/storage/$STORE/content/$vol --protected 1
  $cmd destroy "$id" --purge || echo "destroy FAILED $id (retry later)"
done < /root/cleanup-ids.txt

Every continue means a failure leaves the guest in place. Nothing is ever destroyed without a verified archive ahead of it.

Two gotchas

Cluster quorum. On a two-node cluster, if one node drops off, destroys on the cluster fail with a quorum error while it is out. Re-run the failed IDs once the cluster is quorate again. The proper fix is a QDevice, so the cluster has a third vote.

Leftover locks. Proxmox will not back up or destroy a guest while a lock from an earlier interrupted job is set. pct unlock 212 or qm unlock 212 clears it, once you have checked nothing is actually running against the guest.

Afterwards: the orphans

With the guests gone, there is a second layer of junk:

  • Orphan disks: entries in pvesm list local-zfs that no config references. Compare against grep -h 'local-zfs:' /etc/pve/nodes/*/*/*.conf. Back each one up as a raw image before removing it, same rule.
  • Old ISOs and container templates in local. They download again in minutes.
  • Year-old snapshots on running guests (qm listsnapshot <id>, pct listsnapshot <id>). A snapshot that old is a backup you forgot, on the same disk as the data, slowing every write.

Checklist

  • Every stopped guest checked for linked clones (base-<id>- in any config); templates tagged keep
  • A stop-mode vzdump of each candidate with a "DELETED" note, bandwidth-limited
  • zstd -t on every archive, and the archive marked protected
  • destroy --purge, never plain destroy
  • Failed steps leave the guest in place
  • Orphan disks, old ISOs and year-old snapshots cleaned in a second pass

More articles