A backup that depends on someone remembering to run it will eventually be skipped. A small, well-written Bash script scheduled with cron is often all a single server needs: it archives the important directories, dumps the database, removes old copies and sends everything to a second location. In this tutorial you will write such a script for Ubuntu 24.04, make it fail loudly instead of silently, schedule it with cron, and prove it works by restoring from it.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS (for example a CubePath VPS) with a non-root user with
sudoprivileges. - Enough free disk space for a few compressed copies of the data you back up. Check with
df -h /var. - Optional: MySQL or MariaDB, if you want the script to dump databases.
- Optional: a second server reachable over SSH with key-based login for the offsite copy. The rsync backup guide shows how to prepare a dedicated user and key; this guide assumes the root key
/root/.ssh/backup_ed25519and the remote userrbackupfrom it.
cron is installed and running by default on Ubuntu 24.04. Confirm it:
systemctl is-active cron
active
Step 1 - Deciding what the script must do
Write the requirements down before writing code, because they drive every line of the script:
- Archive
/etc,/home,/rootand/var/wwwinto one compressed file per run. - Dump all MySQL or MariaDB databases consistently, if a database server is installed.
- Write a checksum file so you can detect a damaged archive later.
- Keep 7 days of local copies and delete older ones.
- Copy the local backups to a second server, so a disk failure does not take them all.
- Exit with a non-zero code and log an error when any step fails.
- Never run twice at the same time.
Step 2 - Writing the backup script
Create the script in /usr/local/sbin, the standard location for administrator scripts that only root runs:
sudo nano /usr/local/sbin/server-backup
#!/usr/bin/env bash
# Nightly server backup: files + MySQL dump, local rotation, offsite copy.
set -Eeuo pipefail
umask 077
BACKUP_ROOT="/var/backups/server"
SOURCES=(/etc /home /root /var/www)
KEEP_DAYS=7
# Offsite copy over SSH. Leave REMOTE empty to disable it.
REMOTE="rbackup@your_backup_server_ip"
REMOTE_DIR="/srv/backups/$(hostname -s)-archives"
SSH_KEY="/root/.ssh/backup_ed25519"
# Optional URL to request after a successful run (dead man's switch).
PING_URL=""
STAMP="$(date +%F_%H%M)"
DEST="${BACKUP_ROOT}/${STAMP}"
log() {
logger -t server-backup -- "$*"
printf '%s %s\n' "$(date '+%F %T')" "$*"
}
trap 'log "ERROR: backup failed at line ${LINENO} (exit code $?)"' ERR
log "Starting backup ${STAMP}"
mkdir -p "${DEST}"
# 1. Files. tar exits with 1 when a file changes while it is read; accept that, fail on anything worse.
rc=0
tar --create --gzip --file "${DEST}/files.tar.gz" \
--exclude='home/*/.cache' --exclude='root/.cache' \
--warning=no-file-changed \
-C / "${SOURCES[@]#/}" || rc=$?
if [ "${rc}" -gt 1 ]; then
log "ERROR: tar failed with exit code ${rc}"
exit "${rc}"
fi
# 2. Databases, only if MySQL or MariaDB is installed.
if command -v mysqldump > /dev/null; then
mysqldump --single-transaction --routines --events --all-databases \
| gzip > "${DEST}/mysql-all.sql.gz"
fi
# 3. Verify the archives and record checksums.
gzip --test "${DEST}"/*.gz
(cd "${DEST}" && sha256sum -- *.gz > SHA256SUMS)
# 4. Local rotation.
find "${BACKUP_ROOT}" -mindepth 1 -maxdepth 1 -type d -name '20??-??-??_????' \
-mtime +"${KEEP_DAYS}" -exec rm -rf -- {} +
# 5. Offsite copy. --delete mirrors the local rotation on the remote side.
if [ -n "${REMOTE}" ]; then
rsync -a --delete \
-e "ssh -i ${SSH_KEY} -o BatchMode=yes" \
"${BACKUP_ROOT}/" "${REMOTE}:${REMOTE_DIR}/"
fi
if [ -n "${PING_URL}" ]; then
curl -fsS -m 10 --retry 3 "${PING_URL}" > /dev/null || log "WARNING: ping to monitoring URL failed"
fi
log "Backup ${STAMP} finished: $(du -sh "${DEST}" | cut -f1)"
A few details that make this script safe to run unattended:
set -Eeuo pipefailstops at the first failing command, including a failure in the middle of a pipeline such asmysqldump | gzip, and treats unset variables as errors.-Emakes theERRtrap fire inside functions too.umask 077makes every file the script creates readable only by root, since archives contain/etc/shadowand database contents."${SOURCES[@]#/}"strips the leading slash from each path, sotar -C /storesetc/...instead of/etc/...and restores cleanly into any directory.loggersends each message to the system journal with the tagserver-backup, in addition to standard output.--single-transactiongives a consistent snapshot of InnoDB tables without locking them.
Replace your_backup_server_ip, or set REMOTE="" if you do not have a second server yet.
Make the script executable by root only:
sudo chmod 700 /usr/local/sbin/server-backup
Step 3 - Running the script by hand
Always run a new backup script manually before scheduling it:
sudo /usr/local/sbin/server-backup
2026-09-25 10:40:02 Starting backup 2026-09-25_1040
2026-09-25 10:41:15 Backup 2026-09-25_1040 finished: 824M
Check what was created:
sudo ls -lh /var/backups/server/2026-09-25_1040
total 824M
-rw------- 1 root root 186 Sep 25 10:41 SHA256SUMS
-rw------- 1 root root 812M Sep 25 10:41 files.tar.gz
-rw------- 1 root root 12M Sep 25 10:41 mysql-all.sql.gz
Verify the checksums. The backup directory is only accessible to root, so run the check in a root shell:
sudo sh -c 'cd /var/backups/server/2026-09-25_1040 && sha256sum -c SHA256SUMS'
files.tar.gz: OK
mysql-all.sql.gz: OK
The same start and finish messages are also in the journal:
journalctl -t server-backup -n 5 --no-pager
Cron runs jobs with a minimal environment, which is the most common reason a script works by hand but fails from cron. Simulate that environment to catch missing PATH entries early:
sudo env -i SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin HOME=/root /usr/local/sbin/server-backup
If this run also finishes, the script will behave the same under cron.
Step 4 - Scheduling the script with cron
System-wide jobs belong in /etc/cron.d, where each line has an extra field for the user the job runs as. Create the file:
sudo nano /etc/cron.d/server-backup
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
# m h dom mon dow user command
30 2 * * * root flock -n /run/server-backup.lock /usr/local/sbin/server-backup >> /var/log/server-backup.log 2>&1
This runs the backup every day at 02:30 in the server's time zone. The fields are minute, hour, day of month, month and day of week, followed by the user. A few examples of other schedules:
| Schedule | Expression |
|---|---|
| Every day at 02:30 | 30 2 * * * |
| Every 6 hours | 0 */6 * * * |
| Sundays at 03:00 | 0 3 * * 0 |
| First day of each month at 01:00 | 0 1 1 * * |
flock -n holds a lock file while the job runs; if the previous backup is still running, the new one exits immediately instead of running in parallel. MAILTO="" disables cron's email, because Ubuntu does not ship a mail server by default and the output already goes to the log file.
Files in /etc/cron.d must be owned by root, must not be writable by group or others, and their names must not contain dots. Set the permissions explicitly:
sudo chmod 644 /etc/cron.d/server-backup
cron picks up the new file automatically. Check the server time zone, so you know when 02:30 actually is:
timedatectl | grep "Time zone"
Time zone: Etc/UTC (UTC, +0000)
Step 5 - Rotating the log file
The cron line appends to /var/log/server-backup.log, which grows forever unless you rotate it. Create a logrotate rule:
sudo nano /etc/logrotate.d/server-backup
/var/log/server-backup.log {
weekly
rotate 8
compress
missingok
notifempty
}
Test the rule without rotating anything:
sudo logrotate --debug /etc/logrotate.d/server-backup
The output should mention /var/log/server-backup.log and show no errors.
Step 6 - Confirming the scheduled run
The day after, check that cron started the job and that it finished:
journalctl -u cron --since yesterday | grep server-backup
journalctl -t server-backup --since yesterday --no-pager
Sep 26 02:30:01 web01 CRON[20412]: (root) CMD (flock -n /run/server-backup.lock /usr/local/sbin/server-backup >> /var/log/server-backup.log 2>&1)
Sep 26 02:30:01 web01 server-backup[20415]: Starting backup 2026-09-26_0230
Sep 26 02:31:18 web01 server-backup[20460]: Backup 2026-09-26_0230 finished: 826M
If a run failed, the journal contains a line starting with ERROR: and /var/log/server-backup.log has the full output.
A failed job is easy to spot, but a job that never runs (cron removed, server off, disk full before the first log line) produces no error at all. To catch that, create a check in a dead man's switch service such as Healthchecks.io or your own monitoring, and put its URL in PING_URL. The service alerts you when the expected daily ping does not arrive.
Step 7 - Testing a restore
A backup is only proven by a restore. Extract a single directory from the latest archive into a temporary location and compare it with the live system:
LATEST=$(sudo find /var/backups/server -mindepth 1 -maxdepth 1 -type d -name '20*' | sort | tail -n 1)
sudo mkdir -p /tmp/restore-test
sudo tar --extract --gzip --file "${LATEST}/files.tar.gz" -C /tmp/restore-test etc/ssh
sudo diff -r /etc/ssh /tmp/restore-test/etc/ssh && echo "identical"
identical
To check the database dump, confirm it is complete. mysqldump writes a final line when it finishes successfully:
sudo zcat "${LATEST}/mysql-all.sql.gz" | tail -n 1
-- Dump completed on 2026-09-25 10:41:07
To restore the whole dump on a new server, run sudo zcat mysql-all.sql.gz | sudo mysql. Remove the test directory afterwards:
sudo rm -rf /tmp/restore-test
Troubleshooting
- The job never appears in
journalctl -u cron: the file in/etc/cron.dhas a dot in its name, wrong permissions, or a missing user field. Fix it and check again the next day, or temporarily set the time a few minutes ahead to test. mysqldump: Got error: 1045: Access denied: the database root account uses a password instead of the Unix socket. Create/root/.my.cnfwith a[mysqldump]section containinguserandpassword, andchmod 600it.rsyncfails withHost key verification failed: root has never connected to the backup server. Runsudo ssh -i /root/.ssh/backup_ed25519 rbackup@your_backup_server_iponce and accept the key.- The disk fills up: lower
KEEP_DAYSor add exclusions. Check which directories are large withsudo du -xh --max-depth=1 /var | sort -h.
Conclusion
You now have a backup script that stops on errors, logs to the journal, rotates old copies, sends them offsite and is scheduled safely with cron and flock, plus a tested restore. For larger servers, full tar archives every night become slow and expensive in space; at that point switch the file part to a deduplicating tool such as BorgBackup or restic, and review the 3-2-1 backup rule to plan retention and restore tests.
