termique
Blog
Guide11 min read

Automating server backups with cron and offsite storage 2026

Automate server backups with cron and rsync/rclone to offsite storage, then verify restores actually work. A practical guide for solo sysadmins.

Automating server backups with cron and offsite storage 2026

Automating server backups with cron and offsite storage is the difference between having a backup strategy and having a backup wish. If “I’ll back it up manually” is the plan, there is no plan: there’s a task that gets skipped the first busy week, and a restore that fails silently the one time it matters. This guide covers the four moving parts of an unattended backup job (what gets backed up, on what schedule, to where, and how you verify it worked), how to wire it into cron without the job dying quietly, and a complete annotated script you can adapt today.

Why “I’ll back it up manually” isn’t a backup strategy

Manual backups fail for a boring reason: they depend on you remembering, on a day when nothing else is on fire, to run a command you don’t run often enough to have memorized. The failure mode isn’t dramatic. It’s a Tuesday where a deploy went sideways, the backup got pushed to “after lunch,” and then a disk fills up or a bad migration corrupts a table before lunch happens. Nobody decided to skip the backup. It just wasn’t automatic, so it wasn’t there.

A backup that only exists because a human remembered to run it is not a backup, it’s a coin flip with worse odds than you’d like. The fix isn’t discipline. It’s removing the human from the loop entirely: a scheduled job that runs whether you remember or not, writes to storage that survives the server dying, and tells you when it fails instead of failing quietly.

How do you actually automate server backups with cron and offsite storage, end to end?

An automated backup system is four decisions, not one script. Skip any of them and you have a job that runs but doesn’t actually protect you.

  • What gets backed up: application files, uploads, and database dumps, not just “the whole disk” (which is slower to restore and includes junk you don’t need back).
  • Schedule: how often the data changes should set the interval, not a round number that feels safe. A database taking orders every minute needs more frequent backups than a static marketing site.
  • Destination: storage physically and logically separate from the server, so the same failure (disk death, ransomware, a fat-fingered rm -rf) can’t take out the backup along with the original.
  • Verification: something that checks the backup actually happened and actually restores, because an exit code of 0 only proves the script finished, not that the data inside is usable.

This is the same logic behind the 3-2-1 backup rule: three copies of your data, on two different kinds of media, with one copy offsite. Cron and a shell script handle the mechanics of getting there; the sections below build each piece.

“What gets backed up” is worth being deliberate about beyond just the database. Configuration that would take real effort to reconstruct from memory belongs in the same backup: nginx or Caddy virtual host files, systemd unit files for any custom services, the crontab itself, and any firewall rules that aren’t in version control. None of this is large, so including it costs almost nothing, and it’s exactly the kind of thing that’s easy to forget you’d need until you’re rebuilding a server from scratch and realize the working nginx config only ever existed on the box that’s now gone.

How do you schedule backups with cron without silent failures?

Cron is the easiest part of this system to set up and the easiest to get wrong in a way you won’t notice for months, because a broken cron job usually fails silently. A typical entry:

# m h  dom mon dow  command
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

Three things in that line matter more than they look like they should:

  • Use full paths for everything. Cron runs with a minimal environment, not your interactive shell’s PATH, so a script that calls `pg_dump` or `aws` by bare name works fine when you test it manually and fails in cron with `command not found`. Set PATH explicitly at the top of the crontab or the script, and call binaries by absolute path (`/usr/bin/pg_dump`) inside the script itself.
  • Redirect both stdout and stderr. `>> /var/log/backup.log 2>&1` is not optional. Without it, cron mails output to a local mail account nobody reads, or it just vanishes, and the first sign of trouble is a restore that has nothing to restore.
  • Check the exit code, don’t assume it. A script that partially runs and returns 0 anyway (because the last command in a pipeline succeeded even though an earlier one failed) will report success to anything watching the exit status while actually producing garbage.

Add `set -euo pipefail` at the top of every backup script. It stops execution on the first error, on unset variables, and on a failed command anywhere in a pipeline, instead of quietly continuing past a broken step and writing a half-finished backup.

Test the exact crontab command by running it with `env -i /bin/sh -c ”` once before trusting the schedule. That strips your interactive environment, which is the closest simulation of cron’s minimal one, and surfaces PATH and permission problems before 2am does.

What should the backup script do before anything leaves the server?

Order matters here. Everything below happens locally, before a single byte goes anywhere near the network.

Dump databases properly, don’t just tar the data directory

Copying a live database’s on-disk files with `tar` risks capturing a half-written write, since the database engine may be mid-transaction when the copy runs. Use the database’s own dump tool, which produces a consistent snapshot: `mysqldump` for MySQL/MariaDB, `pg_dump` for PostgreSQL. Application files and uploads (things that aren’t a live database) are fine to `tar` directly.

pg_dump -U appuser -Fc mydb > /tmp/db-$(date +%F).dump
tar -czf /tmp/uploads-$(date +%F).tar.gz /var/www/app/uploads

Compress and encrypt before it’s written anywhere

Encrypt the backup on the server, before it touches a network socket, not after it arrives at the destination. If encryption happens at the destination (or not at all), the plaintext dump sat on disk and in transit unprotected, and anyone with read access to the offsite bucket can read your database.

gpg --symmetric --cipher-algo AES256 --batch --passphrase-file /etc/backup/passphrase.key \
  -o /tmp/db-$(date +%F).dump.gpg /tmp/db-$(date +%F).dump

Rotate old backups

Keep enough history to recover from a problem discovered days later, not just yesterday’s copy, but not so much that a full disk becomes its own incident. A simple local rotation deletes anything past a retention window; the offsite destination should carry a longer history than local disk does, since it’s cheaper to store there and it’s the copy that survives the server itself failing.

find /var/backups -name '*.gpg' -mtime +7 -delete

A flat “delete after N days” window works, but a tiered retention scheme (often called grandfather-father-son) catches more scenarios for roughly the same storage cost: keep daily backups for a week, one backup per week for a month, and one per month for a year. That way a problem that gets noticed a month after it happened (a slow data corruption, a bad migration nobody flagged immediately) still has a clean recovery point, instead of every backup in your retention window already containing the same bad data.

How do you get backups to offsite storage?

Two tools cover almost every case: `rsync` (or `scp`) over SSH for backing up to another server you control, and `rclone` for pushing to S3-compatible object storage (S3 itself, Backblaze B2, Cloudflare R2, and similar). Both move an already-encrypted file, so the transport method doesn’t need to be trusted with plaintext.

rsync -avz -e 'ssh -i /home/backup/.ssh/id_ed25519' \
  /var/backups/db-$(date +%F).dump.gpg \
  backup@offsite.example.com:/backups/appserver/
rclone copy /var/backups/db-$(date +%F).dump.gpg remote:backups/appserver/ \
  --config /etc/backup/rclone.conf

Key-based auth is non-negotiable for a job that runs unattended. A password-authenticated `scp` either needs a human to type a password (defeating the point of automation) or the password gets hardcoded into a script on disk, which is worse than the problem it solves. Generate a dedicated SSH key for the backup job, scoped to nothing but the offsite destination, and lock it down with `command=` in the remote’s `authorized_keys` if it only ever needs to receive one kind of upload. The same key-management discipline that applies to any unattended job is covered in more depth in managing SSH credentials across devices: a key used by a cron job is still a credential, and it still needs a home that isn’t a plaintext file with loose permissions.

How do you catch a failed backup before you need the restore?

An exit code of 0 tells you the script finished. It does not tell you the dump wasn’t empty, that the offsite copy actually landed, or that the file you’d be restoring from isn’t zero bytes. Verification has to check the data, not just the process.

  • Check file size, not just existence. A `pg_dump` that fails partway through (a lock timeout, a disk-full mid-write) can still leave a file behind. Compare each backup’s size against a sane minimum, or against the previous run’s size within a reasonable margin, and alert if it’s a fraction of what’s expected.
  • Use a dead man’s switch (heartbeat monitoring). Instead of the backup job announcing success, have it ping a monitoring endpoint (a service like Healthchecks.io, or a simple internal check) on completion. If the ping doesn’t arrive within the expected window, that’s the alert. This catches the failure mode manual checking misses entirely: the cron job not running at all, because cron itself is down or the entry got deleted.
  • Periodically test an actual restore. A backup nobody has ever restored from is a theory, not a backup. Monthly is a reasonable cadence for restoring the latest dump into a throwaway database and confirming the app can read it.

Backup verification is really a subset of general server monitoring: you’re watching for an expected event that didn’t happen, the same shape of problem as a disk filling up or a process dying. If you don’t already have baseline visibility into disk space and process health, monitoring CPU, RAM, and disk without third-party tools covers the same lightweight approach applied to the rest of the server, and a full disk is very often the reason a backup silently stopped producing anything.

A complete example: cron job, script, and offsite sync together

Putting the pieces above together, here’s a full script that dumps a Postgres database, encrypts it, rotates old local copies, ships it offsite over SSH, and reports a heartbeat:

#!/bin/bash
set -euo pipefail

DATE=$(date +%F)
DB_NAME="mydb"
DB_USER="appuser"
BACKUP_DIR="/var/backups"
OFFSITE_HOST="backup@offsite.example.com"
OFFSITE_PATH="/backups/appserver"
HEARTBEAT_URL="https://hc-ping.com/your-check-id"

mkdir -p "$BACKUP_DIR"

# 1. Dump the database (consistent snapshot, not a raw file copy)
pg_dump -U "$DB_USER" -Fc "$DB_NAME" > "$BACKUP_DIR/db-$DATE.dump"

# 2. Encrypt before it leaves the server
gpg --symmetric --cipher-algo AES256 --batch \
  --passphrase-file /etc/backup/passphrase.key \
  -o "$BACKUP_DIR/db-$DATE.dump.gpg" "$BACKUP_DIR/db-$DATE.dump"
rm "$BACKUP_DIR/db-$DATE.dump"

# 3. Sanity-check the encrypted file isn't suspiciously small
MIN_BYTES=10000
SIZE=$(stat -c%s "$BACKUP_DIR/db-$DATE.dump.gpg")
if [ "$SIZE" -lt "$MIN_BYTES" ]; then
  echo "backup too small ($SIZE bytes), aborting before offsite sync" >&2
  exit 1
fi

# 4. Ship it offsite over SSH (key-based auth only)
rsync -avz -e 'ssh -i /home/backup/.ssh/id_ed25519' \
  "$BACKUP_DIR/db-$DATE.dump.gpg" "$OFFSITE_HOST:$OFFSITE_PATH/"

# 5. Rotate local copies older than 7 days (offsite retains longer)
find "$BACKUP_DIR" -name '*.gpg' -mtime +7 -delete

# 6. Report success to the heartbeat monitor
curl -fsS --retry 3 "$HEARTBEAT_URL" > /dev/null
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

Every failure mode covered above is addressed somewhere in this pair: PATH is set explicitly, output is captured, `set -euo pipefail` stops on the first error, the file size check catches a silently truncated dump, the SSH transport uses a dedicated key, and the heartbeat ping means a cron job that stops running entirely gets noticed within a day instead of within a restore.

Where this fits in a bigger backup strategy

Automating the backup itself is one piece of a larger picture: the 3-2-1 backup rule for how many copies and where, and a disaster recovery runbook for what you actually do when a restore is needed at 3am and you’re not thinking clearly. A cron job that runs quietly for a year is only useful if the credentials it depends on (the SSH key that authenticates to the offsite host, the passphrase that decrypts the dump) are managed as carefully as the backup itself.

That’s the same problem termique solves for the rest of your SSH workflow: an encrypted key vault so the private key a backup job depends on isn’t sitting in a plaintext file with loose permissions, host management so the offsite destination is one saved connection instead of a hostname copy-pasted into a script, and a per-command audit log so if a backup job’s key is ever used somewhere it shouldn’t be, there’s a record. termique is free to start with.

Try termique free.

SSH manager with end-to-end encrypted credentials, AI assistant, and cross-device sync.

Download free

Keep reading

All articles ⟶