Backups are like umbrellas: boring until the sky opens. If you have ever run a “quick update”, watched the screen go quiet, then felt your stomach drop, this guide is for you. We’ll set up snapshots that behave like an undo button for your server, with zero faff & no scary tooling.
What You’ll Build
→ A tidy snapshot script that grabs your OS configs, users, app bits & helper files
→ Dated restore points you can browse like save games
→ A “latest” pointer that works on exFAT, plus a nightly schedule & monthly clean-up
Why This Rocks
→ Human-readable, no black-box backups
→ Fast to test, easy to restore single files or the whole lot
→ Works with a plain external drive, no vendor ties
Who This Helps
→ Anyone who likes sleep, hates data loss, & wants a backup that is simple, predictable & repeatable
Promise
Clear steps, copy-paste commands (delete the placeholders), no machine-specific details. By the end you will have a calm, boring backup routine, which is exactly what you want from backups.
What To Change For Your Environment
→ Replace <BACKUP_LABEL> with your chosen neutral label
→ Replace <HOST_TAG> with a neutral host tag, for example host
→ If your desktop auto-mounts under /media/$USER/<BACKUP_LABEL>, adjust paths in the helper
01. What You Get
→ A script that archives key system paths into a compressed snapshot
→ A dated folder per run for multiple restore points
→ A LATEST.txt pointer when symlinks are not supported
→ A nightly cron job at 02:15 local time
→ Monthly pruning on the 1st, default keep ≈35 days→ Simple commands for single-file restore & full restore
02. Prerequisites
→ Ubuntu 22.04 or 24.04 server with sudo
→ External drive mounted or mountable, any filesystem. exFAT works
→ Packages: zstd, rsync (usually installed)
sudo apt-get update
sudo apt-get install -y zstd
03. Pick Your Placeholders
Choose names that do not reveal your environment.
<BACKUP_LABEL> the drive label, example: BackupDrive
<HOST_TAG> a short tag for the machine, example: host
If your drive is exFAT & unlabelled, label it once, replace sdX1 with your device:
sudo apt-get install -y exfatprogs
sudo umount /dev/sdX1
sudo exfatlabel /dev/sdX1 <BACKUP_LABEL>
Mount by label when needed:
sudo mkdir -p /mnt/<BACKUP_LABEL>sudo mount /dev/disk/by-label/<BACKUP_LABEL> /mnt/<BACKUP_LABEL>
04. Install The Snapshot Script
This script detects the drive by label, creates the folder tree if missing, writes a timestamped snapshot, saves helper files, writes LATEST.txt when symlinks fail, & prunes monthly.
sudo tee /usr/local/sbin/system-snapshot.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
# ===== Settings, change to your placeholders =====
LABEL="${LABEL:-<BACKUP_LABEL>}" # drive label
HOST_TAG="${HOST_TAG:-<HOST_TAG>}" # host tag used in the backup path
RETAIN_DAYS="${RETAIN_DAYS:-35}" # keep ≈1 month
FREE_GIB_MIN="${FREE_GIB_MIN:-5}" # require at least this much free space
INCLUDE_DIRS=(/etc /root /usr/local /opt /home)
# ===== Resolve & mount the backup drive by label =====
LABEL_MOUNT="/mnt/${LABEL}"
if ! findmnt -rno TARGET -S LABEL="${LABEL}" >/dev/null 2>&1; then
sudo mkdir -p "${LABEL_MOUNT}"
sudo mount "/dev/disk/by-label/${LABEL}" "${LABEL_MOUNT}"
fi
DRIVE="$(findmnt -rno TARGET -S LABEL=${LABEL})"
[ -n "${DRIVE}" ] || { echo "Backup drive with label ${LABEL} not mounted"; exit 1; }
# ===== Ensure free space =====
FREE_KB=$(df -Pk "${DRIVE}" | awk 'NR==2{print $4}')
if [ "${FREE_KB:-0}" -lt $((FREE_GIB_MIN*1024*1024)) ]; then
echo "Not enough free space on ${DRIVE}. Need >= ${FREE_GIB_MIN} GiB. Skipping."
exit 0
fi
# ===== Paths =====
SNAPS_DIR="${DRIVE}/backups/${HOST_TAG}/snapshots"
TS="$(date +%F_%H%M)"
DEST="${SNAPS_DIR}/${TS}"
LOG="/var/log/system-snapshot.log"
sudo mkdir -p "${DEST}"
# ===== Create archive =====
# Preserve owners, perms, xattrs, ACLs. Exclude volatile paths & the backup drive itself.
sudo tar --zstd -cpf "${DEST}/system.tar.zst" \
--numeric-owner --xattrs --acls --one-file-system \
--exclude=/proc --exclude=/sys --exclude=/dev --exclude=/run \
--exclude=/tmp --exclude=/var/tmp --exclude=/var/cache/apt/archives \
--exclude="${DRIVE}" \
"${INCLUDE_DIRS[@]}"
# Quick integrity check
zstd -t "${DEST}/system.tar.zst"
# ===== Helpers =====
dpkg --get-selections | sudo tee "${DEST}/_package_list.txt" >/dev/null || true
sudo crontab -l | sudo tee "${DEST}/_crontab_root.txt" >/dev/null || true
sudo cp -a /etc/fstab "${DEST}/fstab" 2>/dev/null || true
sudo cp -a /etc/apt/sources.list "${DEST}/sources.list" 2>/dev/null || true
# ===== Latest pointer: try symlink, else LATEST.txt for exFAT =====
if ln -sfn "${DEST}" "${SNAPS_DIR}/latest" 2>/dev/null; then
echo "Updated latest symlink"
else
echo "${DEST}" | sudo tee "${SNAPS_DIR}/LATEST.txt" >/dev/null
echo "Wrote latest pointer: ${SNAPS_DIR}/LATEST.txt"
fi
# ===== Monthly prune on day 01, older than RETAIN_DAYS =====
if [ "$(date +%d)" = "01" ]; then
find "${SNAPS_DIR}" -maxdepth 1 -type d -name "20*" -mtime +"${RETAIN_DAYS}" -print -exec rm -rf {} \;
echo "Pruned snapshots older than ${RETAIN_DAYS} days"
else
echo "Skipping prune, not month start"
fi
# ===== README with restore notes =====
sudo tee "${DEST}/RESTORE_README.txt" >/dev/null <<EOF2
Restore checklist:
1) Reinstall Ubuntu & boot.
2) Mount the backup drive:
sudo mkdir -p /mnt/${LABEL}
sudo mount /dev/disk/by-label/${LABEL} /mnt/${LABEL}
3) Choose a snapshot:
SNAP="/mnt/${LABEL}/backups/${HOST_TAG}/snapshots/${TS}"
4) Full extract:
sudo tar -xpf "\$SNAP/system.tar.zst" -C / --numeric-owner --xattrs --acls
5) Reinstall packages:
sudo apt-get update
sudo dpkg --set-selections < "\$SNAP/_package_list.txt"
sudo apt-get -y dselect-upgrade
EOF2
echo "Snapshot complete at: ${DEST}"
EOF
sudo chmod +x /usr/local/sbin/system-snapshot.sh
05. Create A Simple Helper To Get The Latest Snapshot
sudo tee /usr/local/bin/snapshot-latest >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
d="/media/<BACKUP_LABEL>/backups/<HOST_TAG>/snapshots"
if [ -L "$d/latest" ]; then
readlink -f "$d/latest"
elif [ -f "$d/LATEST.txt" ]; then
cat "$d/LATEST.txt"
else
ls -1 "$d" | grep -E '^[0-9]{4}-' | sort | tail -1 | sed "s|^|$d/|"
fi
EOF
sudo chmod +x /usr/local/bin/snapshot-latest
Tip: if your system auto-mounts the drive under /media/$USER/<BACKUP_LABEL>, adjust the d=... path accordingly.
06. Run A First Snapshot & Verify
# optional: set timezone to local
# sudo timedatectl set-timezone Europe/London
sudo /usr/local/sbin/system-snapshot.sh
# Show newest snapshot path
SNAP="$(snapshot-latest)"; echo "$SNAP"
# Peek inside the archive
tar -tf "$SNAP/system.tar.zst" | head
# Verify archive integrity
zstd -t "$SNAP/system.tar.zst" && echo "Archive OK"
07. Schedule Nightly at 02:15
# create a log file owned by root
sudo install -o root -g root -m 644 /dev/null /var/log/system-snapshot.log
# add cron line for root
sudo crontab -e
# paste this line, then save
15 2 * * * /usr/local/sbin/system-snapshot.sh >> /var/log/system-snapshot.log 2>&1
# ensure cron is enabled & running
sudo systemctl enable --now cron
sudo systemctl status cron
Check logs:
sudo tail -n 50 /var/log/system-snapshot.log
08. Restore, Two Common Cases
A) Restore a single file from the latest snapshot
SNAP="$(snapshot-latest)"
sudo tar -xpf "$SNAP/system.tar.zst" -C / etc/ssh/sshd_config --numeric-owner --xattrs --acls
B) Full system restore after a clean install
# 1. Mount the backup drive
sudo mkdir -p /mnt/<BACKUP_LABEL>
sudo mount /dev/disk/by-label/<BACKUP_LABEL> /mnt/<BACKUP_LABEL>
# 2. Pick a snapshot
SNAP="$(/usr/local/bin/snapshot-latest | sed "s|^/media/<BACKUP_LABEL>|/mnt/<BACKUP_LABEL>|")"
# 3. Extract to root
sudo tar -xpf "$SNAP/system.tar.zst" -C / --numeric-owner --xattrs --acls
# 4. Reinstall packages
sudo apt-get update
sudo dpkg --set-selections < "$SNAP/_package_list.txt"
sudo apt-get -y dselect-upgrade
# 5. Reboot when ready
sudo reboot
09. Optional Additions
Prefer a specific device by UUID
Useful if two drives share a label & might be connected at the same time.
sudo blkid | grep <BACKUP_LABEL>
Then mount by UUID in /etc/fstab:
UUID=<PUT-UUID-HERE> /mnt/<BACKUP_LABEL> exfat uid=1000,gid=1000,defaults 0 0
Adjust the script’s mount section to mount /mnt/<BACKUP_LABEL> directly if you use fstab.
Exclude container or VM data
Add these to the tar excludes in the script if relevant:
--exclude=/var/lib/docker --exclude=/var/lib/containers --exclude=/var/lib/libvirt/images
Database dumps
Place before the tar command in the script, or write them into ${DEST} after.
# MariaDB or MySQL
sudo mysqldump --single-transaction --all-databases | zstd > "${DEST}/mysql_all.sql.zst" || true
# PostgreSQL
sudo -u postgres pg_dumpall | zstd > "${DEST}/pg_all.sql.zst" || true
10. Troubleshooting
→ Permission denied when appending to log: redirection happens before sudo. Use sudo bash -c '... >> /var/log/system-snapshot.log 2>&1' or let cron write it.
→ Cannot create symlink on the backup drive: exFAT does not support Unix symlinks. The script writes LATEST.txt instead. Use snapshot-latest to resolve the newest snapshot.
→ No space left: raise FREE_GIB_MIN or prune snapshots.
Manual prune, example:
SNAPS_DIR="/media/<BACKUP_LABEL>/backups/<HOST_TAG>/snapshots"
find "$SNAPS_DIR" -maxdepth 1 -type d -name "20*" -mtime +35 -print -exec rm -rf {} \;
That is it. You now have automated, date-stamped Ubuntu system snapshots to an external drive, with clean restore steps.