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 sudo privileges.
  • 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_ed25519 and the remote user rbackup from 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, /root and /var/www into 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 pipefail stops at the first failing command, including a failure in the middle of a pipeline such as mysqldump | gzip, and treats unset variables as errors. -E makes the ERR trap fire inside functions too.
  • umask 077 makes every file the script creates readable only by root, since archives contain /etc/shadow and database contents.
  • "${SOURCES[@]#/}" strips the leading slash from each path, so tar -C / stores etc/... instead of /etc/... and restores cleanly into any directory.
  • logger sends each message to the system journal with the tag server-backup, in addition to standard output.
  • --single-transaction gives 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:

ScheduleExpression
Every day at 02:3030 2 * * *
Every 6 hours0 */6 * * *
Sundays at 03:000 3 * * 0
First day of each month at 01:000 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.d has 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.cnf with a [mysqldump] section containing user and password, and chmod 600 it.
  • rsync fails with Host key verification failed: root has never connected to the backup server. Run sudo ssh -i /root/.ssh/backup_ed25519 rbackup@your_backup_server_ip once and accept the key.
  • The disk fills up: lower KEEP_DAYS or add exclusions. Check which directories are large with sudo 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.