I lost a month of work once because my backup was a cron job that emailed me logs and I never read them. The job was failing silently for weeks. The data was gone. That was the day I stopped trusting anything that requires me to remember to check.

If your backup strategy involves you manually running a command, opening an email, or checking a dashboard, you do not have a backup strategy. You have a hope strategy.

Code on screen
The screen where I discovered my backup had been dead for three weeks. Not my finest moment.

The Three Rules I Now Live By

After that disaster I rewrote everything. Three rules:

  1. Backups must run automatically with no human involvement.
  2. If a backup fails, I must know within 5 minutes ( not next time I remember to check ).
  3. Backups must be tested by restoring. Untested backups are not backups.

Here is the setup I now run on every server I manage. It uses restic, a simple wrapper script, systemd timers ( not cron, cron is fine but systemd gives me better logging ), and a Telegram alert on failure.

Step 1: Install restic and create a repo

Restic handles encryption, deduplication, and pruning. I do not need to think about any of that.

sudo apt install restic

# Initialize a backup repo on your storage ( local drive, S3, SFTP, whatever )
restic init --repo /mnt/backup/repos/main
# It prompts for a password. Save that password somewhere safe.
# If you lose it, your backups are gone. No recovery.

Step 2: The wrapper script

Here is /usr/local/bin/backup-run:

#!/usr/bin/env bash
set -euo pipefail

REPO=/mnt/backup/repos/main
PASSWORD_FILE=/etc/restic/password
TELEGRAM_TOKEN=your-bot-token
TELEGRAM_CHAT=your-chat-id
HOSTNAME=SUKUNA

export RESTIC_REPOSITORY=
export RESTIC_PASSWORD_FILE=

SOURCES=( /home /etc /var/lib/docker /opt )

log() { echo [2026-06-06T08:08:23] ""; }

alert() {
  local msg="SUKUNA backup FAILED: "
  curl -s -X POST https://api.telegram.org/bot/sendMessage \n    -d chat_id= -d text= >/dev/null 2>&1
}

log Starting backup
if ! restic backup "" --tag auto 2>&1; then
  alert restic-backup-failed; exit 1
fi

log Pruning
if ! restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 3 --prune 2>&1; then
  alert restic-prune-failed; exit 1
fi

log Checking integrity
if ! restic check 2>&1; then
  alert restic-check-failed; exit 1
fi

log Backup complete

Make it executable:

sudo chmod +x /usr/local/bin/backup-run

Three things happen: backup, prune, check. If any step fails, I get a Telegram message and the script exits with code 1. No silent failures. No email logs I will ignore.

Dashboard analytics
Dashboards are nice. Alerts you can not ignore are better.

Step 3: Systemd timer instead of cron

Cron works. But systemd timers give me journalctl logging for free, missed-run catchup, and RandomizedDelaySec so multiple servers do not all hit the storage at the same second. Here is the service unit:

# /etc/systemd/system/backup.service
[Unit]
Description=Run restic backup
After=network.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-run
Nice=19
IOSchedulingClass=idle

And the timer:

# /etc/systemd/system/backup.timer
[Unit]
Description=Daily restic backup

[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=1800
Persistent=true

[Install]
WantedBy=timers.target

Enable it:

sudo systemctl daemon-reload
sudo systemctl enable backup.timer
sudo systemctl start backup.timer

# Verify it is scheduled
systemctl list-timers backup.timer

Persistent=true means if the server was off at 3am, it runs the backup as soon as it boots. With cron, you would miss that run entirely. Nice=19 and IOSchedulingClass=idle mean the backup will not steal CPU or disk from anything important.

Step 4: Test your backups ( the step everyone skips )

An untested backup is a wish, not a backup. Every month I restore a random file to /tmp and diff it against the original. If they do not match, the whole system is broken and I need to fix it before anything else.

# Pick a random file from the latest snapshot
RANDOM_FILE=

# Restore it
restic restore latest --target /tmp/restore-test --include ""

# Diff it
diff "" "/tmp/restore-test" \n  && echo OK || echo FAIL

I put that in a weekly systemd timer on a separate machine. The test restore writes to /tmp/restore-test and if diff fails, same Telegram alert fires. I do not have to think about it.

Step 5: Off-site copy ( because one copy is zero copies )

Local backups protect against human error. Off-site backups protect against flood, fire, or someone walking off with your server. Restic supports SFTP, S3, Backblaze B2 out of the box. I use a Hetzner Storage Box via SFTP because it is cheap and fast from Uruguay.

# Initialize remote repo
export RESTIC_REPOSITORY=sftp:your-user@your-storagebox.hetzner.com:restic-remote
restic init

# Same backup-run script, different REPO variable
# Or add a second backup call to the same script:
restic backup "" --tag offsite
Laptop with code
The laptop where I found out my offsite backup had been full for two months. B2 storage limit. Good times.

Step 6: Monitoring ( because alerts you miss are not alerts )

Telegram works for me because it is on my phone and I check it constantly. If you prefer email, Slack, or Discord, swap the alert function. The point is: the notification has to be somewhere you already look, not somewhere you will forget to check.

I also added a healthcheck.io ping at the end of backup-run so I know the script actually ran ( not just that it did not fail ): if the ping does not arrive within 24 hours, healthcheck.io emails me. Double safety net.

curl -fsSL https://hc-ping.com/your-uuid >/dev/null 2>&1

What I ended up with

Six pieces total: restic, one bash script, a systemd service, a systemd timer, a Telegram alert function, and a healthcheck ping. No UI, no dashboard, no commercial backup product. It costs me nothing but disk space and a Hetzner Storage Box at EUR 3.81/month.

It has been running for 18 months without me touching it. That is the whole point. Set it and forget it. But set it with alerts, or you are just forgetting it without setting it.

:)