MHOSTMHOST

Scheduled backups with cron

A backup you have to start by hand eventually gets forgotten — cron does it without you, running your rsync or restic script on a schedule, every night, with no one at the keyboard. This article covers putting that script on a schedule with crontab, keeping its logs, and not getting tripped up by timezones.

What you'll need

  • An mHost Linux VPS — any of the supported distros works: Ubuntu 22.04/24.04, Debian 11/12, AlmaLinux/Rocky Linux 8/9.
  • SSH access as root or a user with sudo.
  • A working backup script or command built on rsync or restic that you've already tested by hand. If you don't have one yet, set up the tool itself first, following "Backups with rsync" or "Backups with restic" — this article is only about scheduling and unattended runs, not about rsync/restic itself.
  • A destination for the backups: a separate disk/mounted volume on this same server, or a remote host — whichever you chose in the articles above.

Step 1. Prepare the backup script

cron can run anything — a command, a script, a whole program — but a long command is easier to manage as its own executable file rather than written straight into the crontab. That makes it simpler to edit, test by hand, and reuse.

Below are two minimal examples. Pick whichever matches your tool (see the linked articles above for the full picture) and save it as /usr/local/bin/backup.sh.

An rsync-based script

bash
sudo nano /usr/local/bin/backup.sh
bash
#!/bin/sh
set -e

SRC="/var/www"
DEST="/mnt/backup/www"

rsync -a --delete "$SRC" "$DEST"

-a (--archive) recursively copies the directory while preserving permissions, modification times, symlinks, and ownership; --delete removes files from DEST that no longer exist in SRC, so the copy stays a mirror instead of only growing. DEST can also be a remote host over SSH (user@host:/path, with the -e ssh flag) — for that and the rest of rsync's flags, see "Backups with rsync".

A restic-based script

The restic repository is created once, by hand, ahead of time. First, set up the file with the repository password:

bash
sudo mkdir -p /etc/restic
sudo nano /etc/restic/password
sudo chmod 600 /etc/restic/password

Put one long password in the /etc/restic/password file (no trailing newline) and save it — this is the repository's encryption password, not an OS user password. Then initialize the repository:

bash
sudo restic --repo /mnt/backup/restic-repo --password-file /etc/restic/password init

The same repository path and the same password file are also the first lines of the script itself — as the RESTIC_REPOSITORY and RESTIC_PASSWORD_FILE environment variables. That way restic won't ask for them interactively when it's launched from cron, where there's simply no one around to answer:

bash
sudo nano /usr/local/bin/backup.sh
bash
#!/bin/sh
set -e

export RESTIC_REPOSITORY="/mnt/backup/restic-repo"
export RESTIC_PASSWORD_FILE="/etc/restic/password"

restic backup /var/www
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune

restic backup creates a new snapshot; restic forget with the --keep-daily/--keep-weekly/--keep-monthly flags removes older snapshots according to the retention policy, and --prune immediately frees the space held by data that's no longer referenced. /mnt/backup/restic-repo doesn't have to be a local path — it can also be an SFTP or S3-compatible store; for choosing a backend, restoring from a copy, and fine-tuning retention, see "Backups with restic".

Tip: the destination directory (/mnt/backup/...) needs to already exist — it can be a separate mounted disk on this same VPS, or a mounted remote resource. If the backup sits on the same physical disk as the data itself, you're not protected against a failure of that disk or node — a reliable backup needs to live somewhere separate from the server.

Make the script executable and run it by hand once — before you trust it to cron, make sure it actually works:

bash
sudo chmod +x /usr/local/bin/backup.sh
sudo /usr/local/bin/backup.sh
Done: if the script ran with no errors and fresh data shows up in DEST/the repository, you're ready to move on to scheduling.

Step 2. Open crontab

Every user has their own separate crontab. The crontab -e command opens the current user's crontab; to edit root's crontab (usually what you need for a system-wide backup script covering something like /var/www), run:

bash
sudo crontab -e
Tip: the very first time you run crontab -e on Ubuntu/Debian, the system asks you to pick an editor (the list usually includes nano, vim.basic, vim.tiny, ed); this is done by the select-editor utility, and your choice is remembered afterward in ~/.selected-editor. AlmaLinux/Rocky Linux (the cronie package) has no such prompt — crontab opens directly in whatever editor $VISUAL/$EDITOR points to, or vi if neither is set. You can change the editor later with the select-editor command (Ubuntu/Debian) or by exporting EDITOR=nano before running crontab -e.

A couple of related commands:

  • crontab -l — show the current crontab without editing anything.
  • crontab -r — delete the user's entire crontab at once, no confirmation.
Important: crontab -r wipes out all of a user's jobs at once, not just one line. To remove a single job, open the crontab with -e and delete that line by hand instead.

Before adding the job, make sure cron itself is installed and running:

bash
# Ubuntu / Debian
systemctl status cron

# AlmaLinux / Rocky Linux
systemctl status crond

If the service isn't found, install the package and enable it:

bash
# Ubuntu / Debian
sudo apt install -y cron
sudo systemctl enable --now cron

# AlmaLinux / Rocky Linux
sudo dnf install -y cronie
sudo systemctl enable --now crond
Verify: on most mHost images cron is already installed and running by default — the commands above are only needed if systemctl status reports that the service isn't found.

Step 3. Cron schedule syntax

Each crontab line describes a schedule with five fields, followed by the command itself. Field order: minute · hour · day of month · month · day of week.

  • minute — 0–59
  • hour — 0–23
  • day of month — 1–31
  • month — 1–12
  • day of week — 0–7 (0 and 7 are Sunday)
  • * — "any value," the whole field.
  • , — a list of values: 1,15 means the 1st and the 15th.
  • - — a range: 8-11 in the hour field means 8 through 11 inclusive.
  • / — a step: */2 in the hour field means every other hour (0, 2, 4, …).

Examples:

  • 0 3 * * * — Every night at 03:00
  • 30 2 * * 0 — Every Sunday at 02:30
  • 0 */6 * * * — Every 6 hours (00:00, 06:00, 12:00, 18:00)
  • 0 3 1 * * — On the 1st of every month at 03:00

Instead of five fields, you can use short "nicknames": @daily (equivalent to 0 0 * * *), @weekly (0 0 * * 0), @monthly, @hourly, @reboot (once, at system startup), and others. For a nightly backup, explicit fields are more convenient — they give you exact control over the hour: @daily fires at midnight, not, say, 03:00, when disk load is usually lower.

Step 4. Add the nightly job and redirect the logs

Open root's crontab and add the line:

bash
sudo crontab -e
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
  • 0 3 * * * — every night at 03:00 (which timezone that's measured in is covered in step 5 below).
  • >> /var/log/backup.log — append the script's standard output to the end of the file (> instead of >> would overwrite the log from scratch every time).
  • 2>&1 — send the error stream (stderr) there too; order matters: >> first redirects stdout to the file, and only then does 2>&1 say "stream 2 goes wherever stream 1 currently points."
Tip: by default, if you don't redirect the output, cron tries to mail it to the crontab's owner (the MAILTO variable) — and on a fresh VPS a local mail server (MTA) usually isn't configured, so cron just discards the output, leaving only a note about it in its own log (CRON[…]: (CRON) info (No MTA installed, discarding output)), which almost nobody reads. Redirecting to a file is the most reliable way to keep the script's output regardless of whether mail is set up on the server.
Tip: if the backup ever takes longer than the interval between runs, two instances of the script could overlap. To rule that out, wrap the call in flock:
0 3 * * * /usr/bin/flock -n /var/run/backup.lock /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

The -n flag means "don't wait, just exit right away if the previous run hasn't finished yet."

The /var/log/backup.log file will grow forever if you don't rotate it — set up a dedicated logrotate rule for it if needed (a separate topic, not covered here).

Save the file and check that the job was added:

bash
sudo crontab -l

Step 5. Timezone

cron goes by the server's system date and time — not the timezone you had in mind while writing 0 3 * * *. Check the server's current timezone:

bash
timedatectl

Change it if needed (for example, to Moscow time):

bash
sudo timedatectl set-timezone Europe/Moscow

The list of all available zones is timedatectl list-timezones.

Important: overriding the timezone for just one crontab, without touching the system-wide setting, works differently depending on the distro: - On AlmaLinux/Rocky Linux (cronie), adding a CRON_TZ=Europe/Moscow line at the top of the crontab is enough — the timezone changes for every job below that line. - On Ubuntu/Debian (the cron package), the CRON_TZ variable isn't supported: cron always schedules runs by the server's system time, and TZ, even if set inside the crontab, only affects what the running script itself sees (for example, what the date command prints inside it), not when the job actually fires. The one approach that works the same way across all the distros listed here is to set the correct timezone system-wide with timedatectl, as shown above, and write your schedule based on that.
Tip: if the system timezone observes daylight saving time, right at the clock change the job could theoretically fail to fire (an hour "disappears" when clocks move forward) or fire twice (when they move back). For infrastructure jobs like backups, many people keep the server on Etc/UTC, which never shifts, so the schedule stays predictable all year round.

Step 6. Verify the backup actually ran

Let the job fire at least once (you don't have to wait for nighttime — you can temporarily set the time a couple of minutes ahead, check the result, then set 0 3 * * * back) and check the script's log:

bash
tail -n 50 /var/log/backup.log

While you're at it, check that cron itself saw and ran the job — that's a separate record from your script's own output:

bash
# Ubuntu / Debian
journalctl -u cron --since today
grep CRON /var/log/syslog

# AlmaLinux / Rocky Linux
journalctl -u crond --since today
sudo tail -n 50 /var/log/cron

Finally, make sure fresh data actually showed up at the destination — a new file/directory dated today for rsync, or a new snapshot for restic (restic snapshots).

Done: a clean log and fresh data in DEST/the repository mean the schedule is set up and working without you.

What's next

For the details of the tool itself — installation, choosing a destination, restoring from a copy, fine-tuning retention — see "Backups with rsync" and "Backups with restic".