🧩 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:

Bash
# /etc/vzdump.conf
script: /mnt/pve/gluster-system/snippets/vzdump/hook-dispatcher.sh

Everything 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 vzdump
    • async (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:

Bash
#!/bin/bash
# mode: async
# phases: log-end
# mk-job

In plain terms, this says:

Run this hook during the log-end phase,
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

Bash
#!/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:

  1. copies the backup to a second storage
  2. encrypts it using GPG
  3. copies companion files (.log, .notes)
  4. applies retentions to clean up old backup sets

Hook Script

Bash
#!/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