🧩 Proxmox vzdump Hooks, Done Right
Proxmox offers a powerful but often underused feature: vzdump hooks.
They allow you to run custom logic at well-defined points during the backup lifecycle — before, during, and after a backup. In practice, however, most setups either:
- use a single monolithic hook script, or
- avoid hooks altogether because they become brittle quickly.
I wanted something different:
- modular
- predictable
- easy to extend
- safe to run in production
This post describes the hook setup I’m using today.
🧠 The Idea
Instead of putting all logic into one giant vzdump hook, I built a dispatcher:
- Proxmox calls one hook script
- that script discovers and executes multiple small hook programs
- each hook declares:
- which phases it cares about
- whether it runs synchronously or asynchronously
- whether it should be wrapped as a managed job
Think of it as a tiny, declarative hook framework — without inventing a new one.
🛠️ vzdump Configuration
Proxmox is told to call exactly one script:
# /etc/vzdump.conf
script: /mnt/pve/gluster-system/snippets/vzdump/hook-dispatcher.shEverything else happens below that line.
🔀 The Hook Dispatcher
The dispatcher is responsible for:
- parsing the vzdump phase
- scanning a
hooks.d/directory - reading a small configuration header from each hook script
- deciding if and how to run it
How hook headers work
Each hook script starts with a short, structured comment header.
This header is not documentation — it is machine-readable configuration for the dispatcher.
Only the initial comment block is read. As soon as the first non-comment line is reached, parsing stops. This keeps the mechanism simple, predictable, and independent of the script body.
A hook may declare:
# mode: sync | async
Controls execution behavior:sync: run inline, may block vzdumpasync(default): detached background execution
# phases: <phase> [<phase> …]
Lists the vzdump phases in which the hook should run (for example:log-end)# mk-job
Optional flag that wraps the hook execution in a managed check_mk job
Example header:
#!/bin/bash
# mode: async
# phases: log-end
# mk-jobIn plain terms, this says:
Run this hook during the
log-endphase,
execute it asynchronously,
and register it as a check_mk job.
Nothing else is inferred.
If a field is missing, safe defaults apply.
Dispatcher Script
#!/bin/bash
set -euo pipefail
shopt -s nullglob
PHASE="${1:-}"
[[ -n "$PHASE" ]] || exit 0
SCRIPT_PATH="$(readlink -f "$0")"
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
HOOK_DIR="$SCRIPT_DIR/hooks.d"
for HOOK in "$HOOK_DIR"/*; do
[[ -x "$HOOK" ]] || continue
MODE="async"
PHASES=""
USE_MK_JOB=false
# ----------------------------
# Read header (comments)
# ----------------------------
while IFS= read -r line; do
case "$line" in
'# mode:'*)
MODE="${line#\# mode:}"
MODE="${MODE//[[:space:]]/}"
;;
'# phases:'*)
PHASES="${line#\# phases:}"
PHASES="${PHASES#"${PHASES%%[![:space:]]*}"}"
;;
'# mk-job')
USE_MK_JOB=true
;;
esac
[[ "$line" != \#* ]] && break
done < "$HOOK"
# ----------------------------
# Match phase
# ----------------------------
match=false
for p in $PHASES; do
[[ "$p" == "$PHASE" ]] && match=true && break
done
$match || continue
# ----------------------------
# Assemble job name
# ----------------------------
hook_file="$(basename "$HOOK")"
hook_name="${hook_file%.*}"
VMID="${3:-}"
if [[ -n "$VMID" ]]; then
job_name="vzdump-${hook_name}-${VMID}"
else
job_name="vzdump-${hook_name}"
fi
# ----------------------------
# Assemble command
# ----------------------------
if $USE_MK_JOB; then
CMD=( mk-job "$job_name" "$HOOK" "$@" )
else
CMD=( "$HOOK" "$@" )
fi
# ----------------------------
# Execute
# ----------------------------
if [[ "$MODE" == "sync" ]]; then
"${CMD[@]}"
else
setsid "${CMD[@]}" >/dev/null 2>&1 &
fi
done
exit 0🧩 A Real Hook: Copy, Encrypt, Retain
One concrete hook I use runs at log-end, after a successful backup.
What it does:
- copies the backup to a second storage
- encrypts it using GPG
- copies companion files (
.log,.notes) - applies retentions to clean up old backup sets
Hook Script
#!/bin/bash
# mode: async
# phases: log-end
# mk-job
set -euo pipefail
umask 077
tmp_rec="/tmp/vzdump-hook-$$.rec"
exec >>"${tmp_rec}" 2>&1
echo "--> Starting copy-and-encrypt"
PHASE="${1:-}"
VMID="${3:-}"
LOG_FILE="${LOGFILE:-}"
BACKUP_DEST=/mnt/pve/nfs-backup/vzdump/${VMID}
GPG_RECIPIENT=backup@thk-systems.de
[[ "$PHASE" == "log-end" ]] || exit 0
[[ -n "$LOG_FILE" && -f "$LOG_FILE" ]] || exit 1
base_path="${LOG_FILE%.log}"
backup_file="${base_path}.vma.zst"
notes_file="${base_path}.vma.zst.notes"
[[ -f "${backup_file}" ]] || exit 1
base_name="$(basename "$base_path")"
mkdir -p "${BACKUP_DEST}"
echo "--> copy and encrypt backup file"
ionice -c2 -n7 nice -n 19 \
gpg --batch --verbose --encrypt \
--recipient "${GPG_RECIPIENT}" \
--output "${BACKUP_DEST}/${base_name}.vma.zst.gpg" \
"${backup_file}"
echo "--> copy companion files"
cp -vf "$LOG_FILE" "$BACKUP_DEST" || true
cp -vf "$notes_file" "$BACKUP_DEST" || true
chmod -v 700 "${BACKUP_DEST}"
chmod -v 600 "${BACKUP_DEST}"/*.{gpg,log,notes} 2>/dev/null || true
echo "--> retention cleanup"
retentions "${BACKUP_DEST}" "vzdump-qemu-*.vma.zst.gpg" \
-l 3 -d 5 -w 3 -m 1 -v info \
--delete-companions \
"suffix:.vma.zst.gpg:.vma.zst.notes,.log,.rec"
echo "--> Finished copy-and-encrypt"
mv "${tmp_rec}" "${BACKUP_DEST}/${base_name}.rec"
chmod 600 "${BACKUP_DEST}"/*.rec 2>/dev/null || true🧭 Why This Design Holds Up
What I like about this setup:
- hooks are self-describing
- behavior is declared where the code lives
- no central configuration file
- adding a hook is just “drop a file and chmod +x”
- async work does not block vzdump
- sync hooks remain possible when needed
Most importantly:
nothing here relies on vzdump doing something “just right”.
It’s boring.
And that’s exactly what you want in backup automation.
🔗 References
The following Perl example script defines the available phases and the variables exposed in each phase:
https://github.com/proxmox/pve-manager/blob/master/vzdump-hook-script.pl ↗