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.
Claus Munch
Mar 10, 2026 · 1 min read
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
A udev rule watches for any block device appearing on a specific physical USB port
It fires a systemd unit (via
systemd-run) that runs the backup script in the backgroundThe backup script mounts each partition read-only, rsyncs the contents to your NAS, and logs everything
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,udevadminstalled (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 |
|---|---|
| Recursive |
| Preserve symlinks |
| Preserve timestamps |
| Skip ownership — NAS shares (NFS/SMB) don't support chown and will error without these |
| 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 |
|---|---|
| Only fires on plug-in, not removal |
| Block devices only |
| Excludes |
| Matches the whole disk, not individual partitions — the script handles partitions itself |
| Ensures it's a USB device, not an internal SATA disk that happened to get |
| Matches the physical port — anything plugged into this port triggers the rule |
Note:
sd[b-z]protects against accidentally matchingsda, but if your system has no internal disks and USB shows up assda, 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-existingmeans re-plugging the same disk will only copy new files, never overwrite existing ones on the NAS.NTFS/exFAT support — the host needs
ntfs-3gfor 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 persist —
systemctl list-units "usb-backup-*"will show failed runs from previous sessions. This is useful for debugging but you can clear them withsystemctl 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.