Proxmox USB backup - It's Over 9000!
Scripts & Tools

Proxmox USB backup

Learn how to automatically back up any USB disk plugged into a specific port on Proxmox using udev, systemd-run, and rsync. Supports NTFS, exFAT, and ext4 — with a live status dashboard to monitor progress.

Cl

Claus Munch

Mar 10, 2026 · 1 min read

20 views
Proxmox USB backup

Automatic USB Backup to NAS on Proxmox with udev

When you plug a USB disk into a specific port on your Proxmox host, it automatically backs up all partitions to your NAS — no interaction needed. This guide walks through the full setup: a udev rule that detects the physical USB port, a backup script that handles NTFS/exFAT/ext4 partitions via rsync, and a status dashboard so you can see what's going on at a glance.


How It Works

  1. A udev rule watches for any block device appearing on a specific physical USB port

  2. It fires a systemd unit (via systemd-run) that runs the backup script in the background

  3. The backup script mounts each partition read-only, rsyncs the contents to your NAS, and logs everything

  4. A status script gives you a live at-a-glance dashboard of the whole process

Everything runs directly on the Proxmox host — no LXC container required.


Prerequisites

  • Proxmox VE host (tested on PVE 8)

  • NAS mounted on the Proxmox host (e.g. via NFS/SMB via /etc/fstab)

  • rsync, ntfs-3g, lsblk, udevadm installed (all present by default on Proxmox/Debian)


Step 1 — Find Your USB Port Path

Plug in any USB device to the port you want to dedicate to backups, then run:

lsusb -t

You're looking for the port path in Bus X → Port Y → Port Z notation. Example output:

/:  Bus 04.Port 1: Dev 1, Class=root_hub, Driver=xhci_hcd/2p, 10000M
    |__ Port 2: Dev 2, If 0, Class=Hub, Driver=hub/4p, 5000M
        |__ Port 3: Dev 14, If 0, Class=Mass Storage, Driver=uas, 5000M

This device is on Bus 4, through a hub on Port 2, into Port 3 — giving the path 4-2.3.

Confirm by checking:

ls /sys/bus/usb/devices/ | grep "4-2.3"

Note your port path — you'll use it in the udev rule below.


Step 2 — The Backup Script

Create the file:

nano /usr/local/bin/usb_backup.sh

Paste the following:

#!/bin/bash
# --- CONFIGURATION ---
NAS_ROOT="/mnt/pve/MYNAS"           # ← Change to your NAS mount point
NAS_BACKUP_DIR="$NAS_ROOT/usb-backup"
TEMP_MOUNT="/mnt/usb_tmp_ingest"
LOG_FILE="$NAS_BACKUP_DIR/backup_history.log"
# --- --- --- --- --- ---

sleep 2  # Give kernel time to enumerate partitions after plug-in

PARENT_DEV="$1"

if [ -z "$PARENT_DEV" ]; then
    echo "ERROR: No device name provided (e.g. sdb)."
    exit 1
fi

PARENT_PATH="/dev/$PARENT_DEV"

# Safety check: is NAS available?
if ! /usr/bin/mountpoint -q "$NAS_ROOT"; then
    echo "$(date): ERROR - NAS ROOT ($NAS_ROOT) not found." >> /var/log/usb_backup_error.log
    exit 1
fi

/usr/bin/mkdir -p "$NAS_BACKUP_DIR"

echo "------------------------------------------" >> "$LOG_FILE"
echo "$(date): Starting backup of: $PARENT_PATH" >> "$LOG_FILE"

# Find all partitions on the disk
PARTITIONS=$(/usr/bin/lsblk -ln -o NAME "$PARENT_PATH" | /usr/bin/grep -E "^${PARENT_DEV}[0-9]+")

# If no partitions, try the disk itself (formatted without partition table)
if [ -z "$PARTITIONS" ]; then
    PARTITIONS="$PARENT_DEV"
fi

for PART in $PARTITIONS; do
    DEVICE="/dev/$PART"

    # Use UUID as folder name
    UUID=$(/usr/bin/lsblk -no UUID "$DEVICE" | /usr/bin/tr -d ' ')

    # Fall back to serial + partition name if no UUID
    if [ -z "$UUID" ]; then
        SERIAL=$(/usr/bin/lsblk -no SERIAL "$PARENT_PATH" | /usr/bin/tr -d ' ')
        UUID="${SERIAL:-no-serial}-$PART"
    fi

    TARGET_DIR="$NAS_BACKUP_DIR/$UUID"

    /usr/bin/mkdir -p "$TEMP_MOUNT"

    # Mount read-only and rsync
    if /usr/bin/mount -o ro "$DEVICE" "$TEMP_MOUNT" 2>/dev/null; then
        /usr/bin/mkdir -p "$TARGET_DIR"
        echo "$(date): Copying $DEVICE ($UUID) to $TARGET_DIR" >> "$LOG_FILE"

        /usr/bin/rsync -rlt --no-owner --no-group --no-perms --ignore-existing \
            "$TEMP_MOUNT/" "$TARGET_DIR/" >> "$LOG_FILE" 2>&1

        /usr/bin/sync
        /usr/bin/umount "$TEMP_MOUNT"
        echo "$(date): SUCCESS - $DEVICE done." >> "$LOG_FILE"
    else
        echo "$(date): SKIP - Could not mount $DEVICE (unknown format or empty)." >> "$LOG_FILE"
    fi
done

echo "$(date): Finished $PARENT_DEV." >> "$LOG_FILE"

Make it executable:

chmod +x /usr/local/bin/usb_backup.sh

Key rsync flags explained

Flag

Reason

-r

Recursive

-l

Preserve symlinks

-t

Preserve timestamps

--no-owner --no-group --no-perms

Skip ownership — NAS shares (NFS/SMB) don't support chown and will error without these

--ignore-existing

Never overwrite files already on the NAS — safe re-run behaviour


Step 3 — The udev Rule

Create the rules file:

nano /etc/udev/rules.d/99-usb-backup.rules

Paste (replace 4-2.3 with your port path from Step 1):

ACTION=="add", SUBSYSTEM=="block", KERNEL=="sd[b-z]", ENV{DEVTYPE}=="disk", \
  SUBSYSTEMS=="usb", KERNELS=="4-2.3", \
  RUN+="/usr/bin/systemd-run --no-block --collect --unit=usb-backup-%k /usr/local/bin/usb_backup.sh %k"

Reload udev to pick up the new rule:

udevadm control --reload-rules

What each match condition does

Condition

Purpose

ACTION=="add"

Only fires on plug-in, not removal

SUBSYSTEM=="block"

Block devices only

KERNEL=="sd[b-z]"

Excludes sda (your boot disk)

ENV{DEVTYPE}=="disk"

Matches the whole disk, not individual partitions — the script handles partitions itself

SUBSYSTEMS=="usb"

Ensures it's a USB device, not an internal SATA disk that happened to get sdb

KERNELS=="4-2.3"

Matches the physical port — anything plugged into this port triggers the rule

Note: sd[b-z] protects against accidentally matching sda, but if your system has no internal disks and USB shows up as sda, update the pattern accordingly.


Step 4 — Test Without Physically Unplugging

To retrigger the udev rule without unplugging:

udevadm trigger --action=add /sys/class/block/sdb

To verify the rule matches before running it:

udevadm test $(udevadm info -q path -n /dev/sdb) 2>&1 | grep -E "RUN|KERNELS|matches"

Step 5 — The Status Dashboard

Create the file:

nano /usr/local/bin/usb-backup-status.sh

Paste the following:

#!/bin/bash
# usb-backup-status.sh — At-a-glance view of USB backup state

# ── Configuration ────────────────────────────────────────────────────────────
USB_PORT="4-2.3"                      # ← Change to your USB port path
NAS_ROOT="/mnt/pve/MYNAS"           # ← Change to your NAS mount point
NAS_BACKUP_DIR="$NAS_ROOT/usb-backup"
LOG_FILE="$NAS_BACKUP_DIR/backup_history.log"

# ── Colors ───────────────────────────────────────────────────────────────────
R='\033[0;31m' G='\033[0;32m' Y='\033[0;33m' B='\033[0;34m'
C='\033[0;36m' W='\033[1;37m' DIM='\033[2m' RESET='\033[0m'
BOLD='\033[1m'

hr() { echo -e "${DIM}────────────────────────────────────────────────────────────────${RESET}"; }
header() { echo -e "\n${BOLD}${C}▸ $1${RESET}"; hr; }

clear
echo -e "${BOLD}${W}"
echo "  ██╗   ██╗███████╗██████╗     ██████╗  █████╗  ██████╗██╗  ██╗██╗   ██╗██████╗ "
echo "  ██║   ██║██╔════╝██╔══██╗    ██╔══██╗██╔══██╗██╔════╝██║ ██╔╝██║   ██║██╔══██╗"
echo "  ██║   ██║███████╗██████╔╝    ██████╔╝███████║██║     █████╔╝ ██║   ██║██████╔╝"
echo "  ██║   ██║╚════██║██╔══██╗    ██╔══██╗██╔══██║██║     ██╔═██╗ ██║   ██║██╔═══╝ "
echo "  ╚██████╔╝███████║██████╔╝    ██████╔╝██║  ██║╚██████╗██║  ██╗╚██████╔╝██║     "
echo "   ╚═════╝ ╚══════╝╚═════╝     ╚═════╝ ╚═╝  ╚═╝ ╚═════╝╚═╝  ╚═╝ ╚═════╝ ╚═╝     "
echo -e "${RESET}"
echo -e "  ${DIM}$(date '+%a %d %b %Y  %H:%M:%S')   Port: ${USB_PORT}${RESET}"
hr

# ── 1. USB device on target port ─────────────────────────────────────────────
header "USB Device on port $USB_PORT"
if [ -d "/sys/bus/usb/devices/$USB_PORT" ]; then
    MANUFACTURER=$(cat /sys/bus/usb/devices/$USB_PORT/manufacturer 2>/dev/null || echo "?")
    PRODUCT=$(cat /sys/bus/usb/devices/$USB_PORT/product 2>/dev/null || echo "?")
    echo -e "  ${G}● Connected${RESET}  $MANUFACTURER — $PRODUCT"

    BLOCK=$(find /sys/bus/usb/devices/$USB_PORT/ -name "block" 2>/dev/null | xargs -I{} ls {} 2>/dev/null | head -1)
    if [ -n "$BLOCK" ]; then
        SIZE=$(lsblk -dno SIZE /dev/$BLOCK 2>/dev/null)
        echo -e "  ${W}Block device:${RESET} /dev/$BLOCK  ${DIM}($SIZE)${RESET}"
        echo ""
        lsblk -o NAME,SIZE,FSTYPE,LABEL,UUID /dev/$BLOCK 2>/dev/null | sed 's/^/  /'
    fi
else
    echo -e "  ${R}✗ No device detected on port $USB_PORT${RESET}"
fi

# ── 2. Active backup units ───────────────────────────────────────────────────
header "Systemd Backup Units"
mapfile -t UNITS < <(systemctl list-units "usb-backup-*" --no-legend --no-pager --plain 2>/dev/null | awk 'NF>=3 && $1!="●" {print $1, $3, $4}')
if [ ${#UNITS[@]} -eq 0 ]; then
    echo -e "  ${DIM}No active usb-backup units${RESET}"
else
    for line in "${UNITS[@]}"; do
        UNIT=$(echo "$line" | awk '{print $1}')
        SUB=$(echo "$line" | awk '{print $3}')
        [ -z "$UNIT" ] && continue
        if [ "$SUB" = "running" ]; then COLOR=$G
        elif [ "$SUB" = "failed" ]; then COLOR=$R
        else COLOR=$DIM; fi
        echo -e "  ${COLOR}● $UNIT${RESET}  ${DIM}$SUB${RESET}"
        RUNTIME=$(systemctl show "$UNIT" --property=ActiveEnterTimestamp --value 2>/dev/null)
        [ -n "$RUNTIME" ] && echo -e "    ${DIM}Started: $RUNTIME${RESET}"
    done
fi

# ── 3. rsync processes ───────────────────────────────────────────────────────
header "rsync Processes"
RSYNC_PROCS=$(pgrep -a rsync 2>/dev/null | grep -v grep)
if [ -z "$RSYNC_PROCS" ]; then
    echo -e "  ${DIM}No rsync processes running${RESET}"
else
    while IFS= read -r line; do
        PID=$(echo "$line" | awk '{print $1}')
        CPUTIME=$(ps -p $PID -o etime= 2>/dev/null | tr -d ' ')
        CMD=$(echo "$line" | awk '{$1=""; print $0}' | sed 's/^ *//')
        echo -e "  ${G}● PID $PID${RESET}  ${DIM}runtime: ${CPUTIME}${RESET}"
        echo -e "    ${DIM}$CMD${RESET}"
    done <<< "$RSYNC_PROCS"
fi

# ── 4. NAS backup sizes ──────────────────────────────────────────────────────
header "NAS Backup Sizes  ${DIM}($NAS_BACKUP_DIR)${RESET}"
if mountpoint -q "$NAS_ROOT" 2>/dev/null; then
    if [ -d "$NAS_BACKUP_DIR" ]; then
        DIRS=$(ls -1 "$NAS_BACKUP_DIR" 2>/dev/null | grep -v "backup_history.log")
        if [ -z "$DIRS" ]; then
            echo -e "  ${DIM}No backup folders yet${RESET}"
        else
            while IFS= read -r dir; do
                SIZE=$(du -sh "$NAS_BACKUP_DIR/$dir" 2>/dev/null | awk '{print $1}')
                MTIME=$(stat -c "%y" "$NAS_BACKUP_DIR/$dir" 2>/dev/null | cut -d. -f1)
                echo -e "  ${W}$dir${RESET}  ${G}$SIZE${RESET}  ${DIM}last modified: $MTIME${RESET}"
            done <<< "$DIRS"
        fi
    else
        echo -e "  ${Y}⚠ Backup dir does not exist yet${RESET}"
    fi
else
    echo -e "  ${R}✗ NAS not mounted at $NAS_ROOT${RESET}"
fi

# ── 5. Recent log ────────────────────────────────────────────────────────────
header "Last 8 Log Entries"
if [ -f "$LOG_FILE" ]; then
    grep -v "^rsync:" "$LOG_FILE" | tail -8 | while IFS= read -r line; do
        if echo "$line" | grep -q "ERROR\|error\|FEJL"; then
            echo -e "  ${R}$line${RESET}"
        elif echo "$line" | grep -q "SUCCESS\|done\|Finished"; then
            echo -e "  ${G}$line${RESET}"
        elif echo "$line" | grep -q "^--"; then
            echo -e "  ${DIM}$line${RESET}"
        else
            echo -e "  $line"
        fi
    done
else
    echo -e "  ${DIM}Log file not found${RESET}"
fi

# ── 6. Quick commands ────────────────────────────────────────────────────────
header "Quick Commands"
echo -e "  ${W}Follow logs:${RESET}"
echo -e "    ${C}journalctl -f -u \"usb-backup-*\"${RESET}"
echo -e "    ${C}tail -f $LOG_FILE${RESET}"
echo ""
echo -e "  ${W}Restart backup (adjust device name):${RESET}"
echo -e "    ${C}systemctl restart usb-backup-sdb${RESET}"
echo ""
echo -e "  ${W}Stop backup:${RESET}"
echo -e "    ${C}systemctl stop usb-backup-sdb${RESET}"
echo ""
echo -e "  ${W}Retrigger udev (simulate replug):${RESET}"
echo -e "    ${C}udevadm trigger --action=add /sys/class/block/sdb${RESET}"
echo ""
echo -e "  ${W}Check what's on the port:${RESET}"
echo -e "    ${C}lsusb -t${RESET}"
echo ""
echo -e "  ${W}Monitor NAS growth:${RESET}"
echo -e "    ${C}watch -n10 \"du -sh $NAS_BACKUP_DIR/*\"${RESET}"
echo ""
echo -e "  ${W}Run this dashboard (live):${RESET}"
echo -e "    ${C}watch -c -n15 /usr/local/bin/usb-backup-status.sh${RESET}"
hr
echo ""

Make it executable:

chmod +x /usr/local/bin/usb-backup-status.sh

Step 6 — Full Setup Summary

Here's all the commands in order, from a fresh Proxmox host:

# 1. Find your USB port path
lsusb -t

# 2. Create the backup script
nano /usr/local/bin/usb_backup.sh
# (paste script from Step 2, update NAS_ROOT)
chmod +x /usr/local/bin/usb_backup.sh

# 3. Create the udev rule
nano /etc/udev/rules.d/99-usb-backup.rules
# (paste rule from Step 3, update KERNELS=="4-2.3" to your port)

# 4. Reload udev
udevadm control --reload-rules

# 5. Create the status dashboard
nano /usr/local/bin/usb-backup-status.sh
# (paste script from Step 5, update USB_PORT and NAS_ROOT at the top)
chmod +x /usr/local/bin/usb-backup-status.sh

# 6. Test — trigger the rule without unplugging
udevadm trigger --action=add /sys/class/block/sdb

# 7. Watch it run
watch -c -n15 usb-backup-status.sh

Monitoring

Live dashboard:

watch -c -n15 usb-backup-status.sh

Follow systemd output:

journalctl -f -u "usb-backup-*"

Follow the backup log:

tail -f /mnt/pve/MYNAS/usb-backup/backup_history.log

Check NAS growth while running:

watch -n10 "du -sh /mnt/pve/MYNAS/usb-backup/*"

Notes & Caveats

  • Idempotent by design--ignore-existing means re-plugging the same disk will only copy new files, never overwrite existing ones on the NAS.

  • NTFS/exFAT support — the host needs ntfs-3g for NTFS mounts. On Proxmox/Debian: apt install ntfs-3g.

  • NAS must be mounted — the script checks for this and exits cleanly if the NAS is unavailable.

  • Failed units persistsystemctl list-units "usb-backup-*" will show failed runs from previous sessions. This is useful for debugging but you can clear them with systemctl reset-failed.

  • Port path survives reboots — the KERNELS=="4-2.3" match is tied to the physical USB port on the motherboard/controller, not the device, so it remains stable across reboots and different disks.

Share this article: