Backup Automation - Systemd Timers, Notifications, and Restore Testing
Status: Active
Last Updated: 2026-08-26
Category: Cloud - Backup Operations
Prerequisites: backup-to-object-storage, system-admin-basics
Time: 2-3 hours
Tags: automation, systemd-timers, notifications, uptime-kuma, restore-testing
Summary
Turning individual backup scripts into an unattended, observable system: systemd timer units for scheduling, lock handling to prevent overlapping runs, failure notification hooks into the fogserv.cloud monitoring stack (Uptime Kuma / simple alerts), and a calendarized verify-and-restore testing cadence. The principle throughout: a backup that has never been restored, and whose failure nobody notices, does not exist.
๐ฏ What You'll Learn
By the end of this article, you'll be able to:
- โ Convert cron-style backup jobs into systemd services + timers
- โ Prevent and detect overlapping backup runs safely
- โ Push success/failure signals into Uptime Kuma and alert channels
- โ Design a realistic verify/restore test cadence
- โ Build a per-service backup registry documenting RPO vs reality
Table of Contents
- Why systemd Timers over Cron
- Timer & Service Units
- Locking & Overlap Prevention
- Notification Hooks
- Verification Cadence
- The Backup Registry
Context / Why This Matters
backup-to-object-storage produced solid scripts for restic against MinIO โ but a script sitting in /opt/restic-scripts/ runs only when someone remembers it, fails silently, and is never proven restorable. This article closes those three gaps with scheduling (storage-backup-strategies' RPO made mechanical), notification, and rehearsal.
Implementation / Core Content
Why systemd Timers over Cron
For backup workloads, systemd timers beat crontab on every dimension that matters:
- Persistent timers (
Persistent=true): a machine powered off at 02:00 still runs the missed job at next boot โ cron simply skips it - Journal integration: stdout/stderr land in
journalctl -u restic-nextcloud.servicewith no redirect plumbing - Dependencies: express "after network-online and after docker" declaratively
- Failure state visibility: failed units appear in
systemctl --failed, which monitoring can scrape
Timer & Service Units
One service+timer pair per backup job:
# /etc/systemd/system/restic-nextcloud.service
[Unit]
Description=Restic backup: Nextcloud files + DB to MinIO
Wants=network-online.target
After=network-online.target docker.service
[Service]
Type=oneshot
EnvironmentFile=/etc/restic-env
ExecStartPre=/opt/restic-scripts/dump-nextcloud-db.sh
ExecStart=/usr/bin/restic backup /srv/nextcloud/data \
--tag nextcloud --tag auto
ExecStartPost=/usr/bin/restic forget --keep-daily 7 --keep-weekly 4 \
--tag auto --group-by paths,tags
User=root
Nice=10
IOSchedulingClass=idle
# Locking: refuse to start if another instance holds the lock
RuntimeDirectory=restic-lock
# /etc/systemd/system/restic-nextcloud.timer
[Unit]
Description=Nightly Nextcloud restic backup
[Timer]
OnCalendar=*-*-* 02:15:00
RandomizedDelaySec=10m # don't hammer MinIO at exactly 02:00 from every host
Persistent=true # catch up after downtime
Unit=restic-nextcloud.service
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now restic-nextcloud.timer
systemctl list-timers 'restic-*' # confirm next trigger time
Weekly maintenance (prune + check) gets its own pair with OnCalendar=Sun *-*-* 04:00:00 running restic forget --prune and restic check --read-data-subset=10%.
Useful operational commands:
sudo systemctl start restic-nextcloud.service # run now (test!)
journalctl -u restic-nextcloud.service -n 100
systemctl show restic-nextcloud.timer -p LastTriggerUSec -p NextElapseUSecRealtime
Locking & Overlap Prevention
Overlapping restic runs cause lock errors at best, repo churn at worst. Two layers:
- restic's own lock: exclusive by default; a second run fails with "repository already locked". Fine, but the failure looks like a backup failure.
- systemd-level serialization for jobs sharing a repo or heavy I/O:
# In each backup .service sharing infrastructure:
[Service]
...
ExecStartPre=/usr/bin/flock -n /run/backup-infra.lock true
Better: give all backup units the same slice so they queue instead of colliding:
systemd-run --scope --slice=backup.slice systemctl start restic-immich.service
Or simplest robust pattern โ a wrapper used as ExecStart:
#!/usr/bin/env bash
exec 9>/run/locks/restic-global.lockflock || exit 75
flock -n 9 || { echo "another backup is running; skipping"; exit 0; }
exec "$@"
Exit-code semantics matter for alerting: distinguish "ran fine" (0), "skipped due to overlap" (0 with message), "real failure" (non-zero).
Notification Hooks
Failures must reach humans within minutes of RPO breach risk. fogserv.cloud uses two complementary paths.
Path 1: push heartbeat to Uptime Kuma (uptime-kuma-setup). Create a Push monitor per job ("nextcloud-backup", expected period 26 h). Last line of every backup unit:
ExecStartPost=/bin/sh -c 'curl -fsS -m 10 --retry 2 \
"https://kuma.fogserv.cloud/api/push/<token>?status=up&msg=$(date +%s)&ping="' \
|| logger -t restic "WARNING: heartbeat push failed"
If the nightly job dies entirely, the monitor goes "down" after its grace period โ existing alert routing fires (see ../observability/simple-alerts.md). Heartbeat beats log-scraping because it catches silence, not just errors.
Path 2: explicit failure hook inside the script for immediate detail:
#!/usr/bin/env bash
# /opt/restic-scripts/notify.sh <service> <exit-code>
SERVICE=$1; RC=$2
if [ "$RC" -ne 0 ]; then
journalctl -u "$SERVICE" -n 40 --no-pager \
| curl -fsS -m 10 -H 'Content-Type: application/json' \
-d "$(jq -nc --arg t "BACKUP FAIL: $SERVICE (rc=$RC)" \
--arg c "$(cat)" '{text: ($t + "\n" + $c)}')" \
"$ALERT_WEBHOOK_URL"
fi
Wire via OnFailure= in the service unit โ systemd's built-in failure hook:
[Unit]
OnFailure=notify-failure@%n.service
# /etc/systemd/system/notify-failure@.service
[Unit]
Description=Report failure of %i
[Service]
Type=oneshot
EnvironmentFile=/etc/alert-env
ExecStart=/opt/restic-scripts/notify.sh %i 1
OnFailure fires on any non-zero exit, timeout, or signal โ one mechanism covering everything, no per-script try/catch needed.
Verification Cadence
Three escalating levels, all calendarized:
| Level | What | Command | Frequency |
|---|---|---|---|
| Structural | repo metadata intact | restic check |
after every prune (weekly) |
| Data sampling | read 10% of blobs | restic check --read-data-subset=10% |
weekly |
| Full read | every blob verified | restic check --read-data |
quarterly |
| Restore drill | actually restore + diff | see below | monthly (1 random service) |
Restore drill template (rotate through services: nextcloud, immich, jellyfin config, syncthing mirrors):
#!/usr/bin/env bash
set -euo pipefail
source "/etc/restic-${1}.env"
DEST=/tmp/drill-$1-$(date +%F)
time restic restore latest --target "$DEST"
echo "Drill $1: $(du -sh "$DEST" | cut -f1) restored, RTO above."
rm -rf "$DEST"
Record in the ops journal: date, service, duration, anomalies. The measured number is your demonstrated RTO; compare against targets from storage-backup-strategies. A drill without a recorded duration proves nothing about recovery time.
Automate the reminder rather than the discipline: a monthly timer that runs one drill and pushes the result as a heartbeat keeps the cadence honest even when humans forget.
The Backup Registry
Keep a single file (/opt/restic-scripts/registry.tsv) listing every protected dataset โ this is what you check during audits and incidents:
service rpo_target schedule repo last_drill
nextcloud 24h daily 02:15 restic-nextcloud@minio 2026-08-12 OK
immich 24h daily 03:00 restic-immich@minio 2026-07-30 OK
jellyfin 7d weekly Sun restic-jellyfin@minio 2026-08-05 OK
syncthing 24h daily 02:45 restic-syncthing@minio never โ TODO
Review it monthly alongside timer status:
systemctl list-timers 'restic-*' --all | awk '{print $1, $NF}'
Any row where last_drill is older than a quarter, or missing entirely, is an action item.
Practical Examples
Example 1: Complete rollout for a new service
# Given: working script /opt/restic-scripts/jellyfin.sh and env /etc/restic-jellyfin.env
cp templates/restic-job.service{,.tmpl} # sed service name, ExecStart path
systemctl link /etc/systemd/system/restic-jellyfin.{service,timer}
systemctl enable --now restic-jellyfin.timer
systemctl start restic-jellyfin.service && journalctl -u restic-jellyfin -f # first run supervised
curl -fsS "https://kuma.fogserv.cloud/api/push/<token>" # confirm heartbeat registered
Example 2: Simulate failure to prove alerting works
sudo systemctl stop minio-server # controlled outage window!
sudo systemctl start restic-nextcloud.service
systemctl status restic-nextcloud.service # expect failure
# Expect: OnFailure hook fired โ webhook message received;
# Uptime Kuma goes down after grace period.
sudo systemctl start minio-server # end outage window
An alert pipeline never tested with a real failure is a hypothesis, not a control.
Example 3: Catch-up behavior after downtime
sudo shutdown -h now # Friday evening maintenance
# boot Monday morning
systemctl list-timers | grep restic
# Persistent=true shows the missed job ran shortly after boot โ verify in journalctl.
Troubleshooting & Common Pitfalls
| Problem | Cause | Fix |
|---|---|---|
| Timer enabled but never triggers | Forgot daemon-reload; timer not started |
systemctl enable --now; check list-timers |
| Missed runs while server was off | Cron semantics; Persistent unset |
Set Persistent=true in timer |
repository is already locked alerts |
Overlapping runs (stale lock or slow prune) | Stale lock: restic unlock; structural: serialize via flock/slice |
| Alerts silent during failures | Only success-heartbeats implemented; no OnFailure |
Add OnFailure= hook + push monitors |
| Backup green but repo dead | No checks/drills ever run | Weekly --read-data-subset, quarterly full read + drills |
| Jobs stampede MinIO at same minute | Identical OnCalendar across hosts | RandomizedDelaySec per unit |
| Journal floods disk | Verbose restic output at level info | Keep logs; set LogRateLimitIntervalSec in unit if extreme |
Next Steps / Ops Actions
- Inventory current backup jobs and convert them to service+timer pairs this week
- Create Uptime Kuma push monitors for every job and wire
OnFailure=hooks - Run the Example 2 failure simulation once โ today, not someday
- Start the backup registry file and schedule the first monthly restore drill
- Revisit tier coverage gaps using storage-backup-strategies
Sources & Related Articles
External references consulted:
- https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html
- https://restic.readthedocs.io/en/stable/075_scripting.html
- https://github.com/louislam/uptime-kuma/wiki/Push-Monitor
Related knowledge-base articles:
Change Log
2026-08-26
- Initial creation by KB writing session.