How to Back Up an Ubuntu Server to an External Drive, Step by Step
Backups are like umbrellas: boring until the sky opens. If you've 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 and no scary tooling.
What you'll build
A tidy snapshot script that grabs your OS configs, users, app bits, and helper files
Dated restore points you can browse like save games
A "latest" pointer that works on exFAT, plus a nightly schedule and 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, and wants a backup that is simple, predictable, and repeatable.
Promise
Clear steps, copy-paste commands (delete the placeholders), no machine-specific details. By the end you'll 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 labelReplace
<HOST_TAG>with a neutral host tag, for examplehostIf 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.txtpointer when symlinks are not supportedA nightly cron job at 02:15 local time
Monthly pruning on the 1st, default keep ≈35 days
Simple commands for single-file restore and 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 zstd03. 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: hostIf your drive is exFAT and 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, and 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.sh05. 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-latestTip: if your system auto-mounts the drive under /media/$USER/<BACKUP_LABEL>, adjust the d=... path accordingly.
06. Run a first snapshot and 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 cronCheck logs:
sudo tail -n 50 /var/log/system-snapshot.log08. 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 --aclsB) 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 reboot09. Optional additions
Prefer a specific device by UUID
Useful if two drives share a label and 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 0Adjust 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/imagesDatabase 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" || true10. 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.txtinstead. Usesnapshot-latestto resolve the newest snapshot.No space left: raise
FREE_GIB_MINor 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's it. You now have automated, date-stamped Ubuntu system snapshots to an external drive, with clean restore steps.

