What is rotate-backups.sh?

two pixel skeletons dancing
Just the bare bones, man.

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.