What is rotate-backups.sh
?

A no-frills, plug-and-play backup rotation script for small setups, homelabs, or anywhere you want clean dailies, weeklies, and monthlies—without third-party tools or magic wrappers. Inspired by the Unix philosophy: simple, composable, inspectable.
The setup includes one Bash script and two systemd units: a *.service to define the job and a *.timer to schedule it. The goal is elegant, self-contained rotation so that when disaster strikes, you’ve got multiple recovery points.
By default, the timer runs at local midnight (00:00). If the machine is offline during that window, the Persistent=true flag ensures the job runs as soon as the system comes back online. The rotation logic keeps 7 daily backups, and from those, promotes 3 weekly (every Sunday) and 3 monthly (on the 1st), pruning anything older.
Quick Start
- 📦 Requires:
bash
,tar
,sha256sum
,systemd
- 🔧 Customize your source/target paths, tweak the retention rules, and you’re set.
Install the script to /usr/local/bin
and drop in the *.service
and *.timer
files. Done.
# Copy and enable
sudo cp rotate-backups.sh /usr/local/bin/
sudo chmod +x /usr/local/bin/rotate-backups.sh
# Prepare and enable systemd scripts
sudo cp rotate-backups.service /etc/systemd/system/
sudo cp rotate-backups.timer /etc/systemd/system/
sudo systemctl daemon-reexec
sudo systemctl enable --now rotate-backups.timer
Features
Keeps:
-
7 daily backups
-
3 weekly backups (Sundays)
-
3 monthly backups (1st of the month)
-
Uses compressed tarballs
-
Uses checksum validation (sha256sum)
-
Compatible with systemd timers
-
Human-readable logs and deletions
Why did I write this?
This is one of those practices that makes a lot of sense but is kind of a pain to get right. I always end up digging through old notes or half-working scripts trying to remember how I did this last time…. I wanted to make a simple skeleton or stub that would be easy to modify and use for virtually any task.
Pro-Tip: Keep your backups mounted and reachable before your systemd timer runs!
Usage
Before using the script, edit the configuration at the top to set your source and destination:
BACKUP_SRC="/var/docker"
BACKUP_DEST="/mnt/sldf/backups"
Once installed and enabled, the timer will automatically run the backup job once per day at midnight. You don’t need to call the script directly.
To check when it last ran, or see what happened during a run, use:
Pro-Tip: Want to test it manually? Run sudo /usr/local/bin/rotate-backups.sh
and check the output folder with ls -lh /mnt/sldf/backups/daily
.
You can also use journalctl -u rotate-backups
to confirm logs after a systemd run.
sudo journalctl -u rotate-backups --no-pager
This shows the logs collected by systemd from previous runs of the backup job—including anything echoed by the script or errors that occurred.
To run the backup manually at any time:
sudo systemctl start rotate-backups.service
So, what if I’m not using systemd?
Not all systems use systemd
. Some distros (like Alpine) or platforms (like macOS) use different init systems. If that’s you—no problem. The script works fine with cron
, launchd
, any scheduler that can run a Bash script daily.
Cron Example (Linux)
If you are using cron, you can add the following to your root crontab (sudo crontab -e) to run it daily at 3:15AM:
15 3 * * * /usr/local/bin/rotate-backups.sh
On macOS
Here’s a simple launchd
.plist
that runs rotate-backups.sh
once a day at 3:15 AM. This assumes the script is installed at /usr/local/bin/rotate-backups.sh
.
Note: Rename the file to match your domain or username (e.g., com.mydomain.rotate-backups.plist)
com.forfaxx.rotate-backups.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.forfaxx.rotate-backups</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/rotate-backups.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>3</integer>
<key>Minute</key>
<integer>15</integer>
</dict>
<key>StandardOutPath</key>
<string>/tmp/rotate-backups.out</string>
<key>StandardErrorPath</key>
<string>/tmp/rotate-backups.err</string>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
Known Issues & Gotchas
- ⚠️ If your backup destination isn’t mounted,
tar
may write into an empty mountpoint (like/mnt/sldf/backups
) and silently fill your root disk.- 🔐 Make sure the script has permission to read everything in
BACKUP_SRC
.- 🧩 On macOS, verify that
launchd
jobs persist across reboots by keeping the.plist
in~/Library/LaunchAgents/
.
Installation Instructions
mkdir -p ~/Library/LaunchAgents
cp com.forfaxx.rotate-backups.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.forfaxx.rotate-backups.plist
-
To unload simply run:
launchctl unload ~/Library/LaunchAgents/com.forfaxx.rotate-backups.plist
-
To reload after edits:
launchctl unload ~/Library/LaunchAgents/com.forfaxx.rotate-backups.plist
launchctl load ~/Library/LaunchAgents/com.forfaxx.rotate-backups.plist
Script Source
I am using the filename rotate-backups.sh
but you can rename it to whatever makes sense in your situation (e.g., website-backup.sh
or finalproj-bak.sh
, etc)
rotate-backups.sh
#!/usr/bin/env bash
# rotate-backups.sh
set -euo pipefail
IFS=$'\n\t'
BACKUP_SRC="/var/docker"
BACKUP_DEST="/mnt/sldf/backups"
DAILY_KEEP=7
WEEKLY_KEEP=3
MONTHLY_KEEP=3
STAMP=$(date +%F)
TODAY_DIR="$BACKUP_DEST/daily"
WEEKLY_DIR="$BACKUP_DEST/weekly"
MONTHLY_DIR="$BACKUP_DEST/monthly"
CHECKSUM_FILE="$BACKUP_DEST/checksums.sha256"
make_backup() {
mkdir -p "$TODAY_DIR"
local target="$TODAY_DIR/backup-$STAMP.tar.gz"
tar -czf "$target" "$BACKUP_SRC"
sha256sum "$target" >> "$CHECKSUM_FILE"
}
rotate() {
local path="$1"
local keep="$2"
find "$path" -maxdepth 1 -type f -name '*.tar.gz' | sort -r | tail -n +$((keep+1)) | xargs -r rm -v
}
copy_if() {
local src="$1"
local dest="$2"
local freq="$3"
if [[ "$freq" == "weekly" && $(date +%u) -eq 7 ]]; then
cp "$src" "$dest/"
elif [[ "$freq" == "monthly" && $(date +%d) -eq 1 ]]; then
cp "$src" "$dest/"
fi
}
verify_checksums() {
sha256sum -c "$CHECKSUM_FILE" | grep -v OK || true
}
make_backup
rotate "$TODAY_DIR" "$DAILY_KEEP"
copy_if "$TODAY_DIR/backup-$STAMP.tar.gz" "$WEEKLY_DIR" "weekly"
rotate "$WEEKLY_DIR" "$WEEKLY_KEEP"
copy_if "$TODAY_DIR/backup-$STAMP.tar.gz" "$MONTHLY_DIR" "monthly"
rotate "$MONTHLY_DIR" "$MONTHLY_KEEP"
verify_checksums
rotate-backups.service
[Unit]
Description=Rotating Backup Script
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/rotate-backups.sh
rotate-backups.timer
[Unit]
Description=Daily Backup Job
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
Sample Output
$ sudo journalctl -u rotate-backups
Aug 04 00:00:01 gir rotate-backups.sh[4521]: Backup started...
Aug 04 00:00:04 gir rotate-backups.sh[4521]: Created: backup-2025-08-04.tar.gz
Aug 04 00:00:04 gir rotate-backups.sh[4521]: Verifying checksums...
Aug 04 00:00:04 gir rotate-backups.sh[4521]: All checksums valid
Aug 04 00:00:04 gir rotate-backups.sh[4521]: Pruned 2 old daily backups
Tips & Extensions
-
🔀 Add hostnames to filenames if you’re backing up multiple systems to one location
-
🌐 Push backups to remote storage using rclone or rsync as a post-step
-
🧪 Replace sha256sum with md5sum, shasum -a 512, or remove if not needed
-
🧬 Swap tar for zstd or pzstd for better compression and speed
You can freely edit the values of DAILY_KEEP
, WEEKLY_KEEP
, MONTHLY_KEEP
as these just define how many tarballs to keep in each bucket. Regardless of what you set, keep in mind that Weekly will trigger on Sunday and Monthly will trigger on the 1st regardless. I tested with oddball values like 0 and 100 and it works as expected.
You can edit the STAMP
variable to use a different ISO 8601 date string if you prefer (e.g., STAMP=$(date +%F_%H%M) # → 2025-08-04_0315
)
Conclusion
If you’re tired of bloated backup solutions and just want a reliable script that does its job, this one’s for you. It’s fast, it’s readable, it’s old school, and it behaves exactly how you’d expect. Have feedback? Email me: feedback@adminjitsu.com
Backup rotation shouldn’t be a mystery—it should be a cron job you trust.