Skip to content

Instantly share code, notes, and snippets.

@jpawlowski
Last active January 11, 2026 01:33
Show Gist options
  • Select an option

  • Save jpawlowski/c8181d4c456563c569ca239ecbd8d143 to your computer and use it in GitHub Desktop.

Select an option

Save jpawlowski/c8181d4c456563c569ca239ecbd8d143 to your computer and use it in GitHub Desktop.
Proxmox Hourly Snapshot Rotation (JSON + Tag-Aware): A lightweight Bash script that creates hourly snapshots for running Proxmox guests (QEMU VMs and LXC containers) and automatically prunes older auto_YYYYMMDDHH snapshots to keep a fixed rotation. It queries the Proxmox API via pvesh in JSON (parsed with jq) for reliable guest discovery across …
#!/bin/bash
set -euo pipefail
PATH="/usr/sbin:/usr/bin:/sbin:/bin"
DATE="$(date +'%Y%m%d%H')"
DEFAULT_KEEP=24
DEFAULT_RETAIN_DAYS=7
DEFAULT_TIMEOUT="30m"
DEFAULT_SKIP_TAGS="nosnap"
DRY_RUN=false
QUIET=true # default quiet for cron
KEEP=$DEFAULT_KEEP
RETAIN_DAYS=$DEFAULT_RETAIN_DAYS
TIMEOUT=$DEFAULT_TIMEOUT
SKIP_TAGS="$DEFAULT_SKIP_TAGS" # comma-separated list
show_help() {
echo "Usage: $0 [-n|--dry-run] [-v|--verbose] [-q|--quiet] [-k|--keep <n>]"
echo " [--retain-days <n>] [--timeout <e.g. 30m>] [--skip-tags <t1,t2,...>] [-h|--help]"
echo
echo "Notes:"
echo " - Skips guests that are not running (no create, no cleanup)."
echo " - Skips guests that have any tag listed in --skip-tags (default: nosnap)."
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--dry-run) DRY_RUN=true ;;
-v|--verbose) QUIET=false ;;
-q|--quiet) QUIET=true ;;
-k|--keep) KEEP="${2:-}"; shift ;;
--retain-days) RETAIN_DAYS="${2:-}"; shift ;;
--timeout) TIMEOUT="${2:-}"; shift ;;
--skip-tags) SKIP_TAGS="${2:-}"; shift ;;
-h|--help) show_help ;;
-*|--*) echo "Unrecognized option: $1"; show_help ;;
esac
shift
done
log() { $QUIET || echo "$@"; }
say() { echo "$@"; } # always prints (dry-run + important info)
err() { echo "ERROR: $*" >&2; }
command -v pvesh >/dev/null 2>&1 || { err "pvesh not found"; exit 1; }
command -v jq >/dev/null 2>&1 || { err "jq not found (install with: apt install -y jq)"; exit 1; }
command -v timeout >/dev/null 2>&1 || { err "timeout not found (coreutils)"; exit 1; }
if ! [[ "$KEEP" =~ ^[0-9]+$ ]] || [[ "$KEEP" -lt 1 ]]; then
err "--keep must be a positive integer (got: $KEEP)"
exit 1
fi
if ! [[ "$RETAIN_DAYS" =~ ^[0-9]+$ ]] || [[ "$RETAIN_DAYS" -lt 0 ]]; then
err "--retain-days must be an integer >= 0 (got: $RETAIN_DAYS)"
exit 1
fi
# Normalize SKIP_TAGS (remove spaces)
SKIP_TAGS="$(echo "$SKIP_TAGS" | tr -d '[:space:]')"
# Prevent overlapping cron runs
LOCKFILE="/var/lock/pve-auto-snapshots.lock"
exec 200>"$LOCKFILE"
flock -n 200 || { log "Another run is active, exiting."; exit 0; }
delete_old_lvm_archives() {
local archive_path="/etc/lvm/archive"
[[ -d "$archive_path" ]] || return 0
log "Cleaning LVM archive files older than $RETAIN_DAYS days..."
if $DRY_RUN; then
find "$archive_path" -type f -mtime +"$RETAIN_DAYS" -print
else
find "$archive_path" -type f -mtime +"$RETAIN_DAYS" -exec rm -f {} +
fi
}
pvesh_json() {
local out
if ! out="$(timeout "$TIMEOUT" pvesh "$@" --output-format json 2>&1)"; then
err "pvesh failed: pvesh $*"
err "$out"
return 1
fi
if ! echo "$out" | jq -e . >/dev/null 2>&1; then
err "pvesh returned non-JSON output for: pvesh $*"
err "$out"
return 1
fi
echo "$out"
}
# Return TSV: TYPE<TAB>VMID<TAB>NODE<TAB>STATUS<TAB>TAGS<TAB>NAME
list_cluster_vms_tsv() {
pvesh_json get /cluster/resources --type vm |
jq -r --arg skip "$SKIP_TAGS" '
# build skip tag set (["nosnap","foo"]):
($skip | split(",") | map(select(length>0))) as $skipTags
| .[]
| select(.type=="qemu" or .type=="lxc")
| (.tags // "") as $tags
| ($tags | split(";") | map(select(length>0))) as $tagList
# if any tag matches skipTags -> skip
| select( ([ $tagList[]? ] | any(. as $t | $skipTags | index($t))) | not )
| [ .type
, (.vmid|tostring)
, (.node // "")
, (.status // "")
, $tags
, (.name // "")
]
| @tsv
'
}
list_auto_snapshots() {
local type="$1" vmid="$2" node="$3"
pvesh_json get "/nodes/$node/$type/$vmid/snapshot" |
jq -r '.[].name | select(test("^auto_[0-9]{10}$"))' |
sort -r
}
snapshot_exists() {
local type="$1" vmid="$2" node="$3" snap="$4"
pvesh_json get "/nodes/$node/$type/$vmid/snapshot" |
jq -e --arg s "$snap" 'any(.[]?; .name == $s)' >/dev/null
}
create_snapshot() {
local type="$1" vmid="$2" node="$3"
local snap="auto_$DATE"
local path="/nodes/$node/$type/$vmid/snapshot"
if snapshot_exists "$type" "$vmid" "$node" "$snap"; then
log "Snapshot already exists: $type/$vmid on $node -> $snap (skipping create)"
return 0
fi
if $DRY_RUN; then
say "[Dry-Run] Would create snapshot: pvesh create $path --snapname $snap"
else
log "Creating snapshot: $type/$vmid on $node -> $snap"
timeout "$TIMEOUT" pvesh create "$path" --snapname "$snap" >/dev/null
fi
}
delete_snapshot() {
local type="$1" vmid="$2" node="$3" snap="$4"
local path="/nodes/$node/$type/$vmid/snapshot/$snap"
if $DRY_RUN; then
say "[Dry-Run] Would delete snapshot: pvesh delete $path"
else
log "Deleting snapshot: $type/$vmid on $node -> $snap"
timeout "$TIMEOUT" pvesh delete "$path" >/dev/null
fi
}
# --- run ---
delete_old_lvm_archives
log "Fetching VM list from pvesh (JSON)..."
log "Skip tags: ${SKIP_TAGS:-<none>}"
while IFS=$'\t' read -r TYPE VMID NODE STATUS TAGS NAME; do
# Requirement: if not running -> neither create nor cleanup
if [[ "$STATUS" != "running" ]]; then
log "Skipping $TYPE/$VMID ($NAME) on $NODE - Status: $STATUS"
continue
fi
if [[ -z "$NODE" ]]; then
log "Skipping $TYPE/$VMID ($NAME) - missing node"
continue
fi
{
create_snapshot "$TYPE" "$VMID" "$NODE"
mapfile -t snaps < <(list_auto_snapshots "$TYPE" "$VMID" "$NODE")
snap_count="${#snaps[@]}"
if (( snap_count > KEEP )); then
for (( i=KEEP; i<snap_count; i++ )); do
delete_snapshot "$TYPE" "$VMID" "$NODE" "${snaps[$i]}"
done
fi
} || {
err "VM task failed for $TYPE/$VMID ($NAME) on $NODE (continuing)"
}
done < <(list_cluster_vms_tsv)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment