A portable, incremental backup system built entirely from standard Unix tools. Originally written for macOS in 2011, now runs on any POSIX system (Linux, macOS, FreeBSD, ...).
The goal of this project is twofold:
- Provide a practical backup solution — scheduled, incremental, snapshot-based, encrypted, and requiring zero manual intervention.
- Teach Unix fundamentals by example — every command, flag, and technique used in the script is explained below so newcomers can learn real-world shell scripting from a real-world tool.
| Requirement | How it's met |
|---|---|
| Regular | Runs on a cron schedule (e.g. every 30 min) |
| Unobtrusive | Runs as a background job, no interaction needed |
| Incremental | rsync sends only deltas; hard links deduplicate unchanged files |
| Snapshots | Each run creates a timestamped point-in-time copy |
| Fast | Hard links + delta transfer keep I/O and space minimal |
| Secure | Archives are encrypted with GPG (optional) |
| Consistent | Preserves permissions, timestamps, symlinks, and ownership |
# 1. Clone the repo
git clone https://github.com/aadlani/backup-manager.git
cd backup-manager
# 2. Create your configuration
cp backup.conf.example backup.conf
$EDITOR backup.conf # set BACKUP_SOURCE_DIR, BACKUP_HOME, etc.
# 3. Run it
./backup.sh
# 4. (Optional) Schedule it with cron — see the cron section below
crontab -eOnly standard Unix utilities — nothing to install on most systems:
rsynctarfinddategpg(only if you enable encryption)
The script executes five steps in order:
┌─────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐
│ Step 1 │────▶│ Step 2 │────▶│ Step 3 │────▶│ Step 4 │
│ Snapshot │ │ Compress │ │ Encrypt │ │ Rotate │
└─────────┘ └──────────┘ └─────────┘ └──────────┘
- Snapshot — rsync copies the source into a timestamped directory, hard-linking unchanged files to the previous snapshot.
- Compress — snapshots older than today are gathered into daily
.tar.gzarchives. - Encrypt — unencrypted archives are encrypted with GPG (skipped if
GPG_RECIPIENTis empty). - Rotate — daily archives roll into weekly, then monthly buckets.
$BACKUP_HOME/
├── backups.log
├── current -> snapshots/202602211430 # symlink to the latest snapshot
├── snapshots/
│ ├── 202602211400/
│ ├── 202602211430/
│ └── 202602211500/
└── archives/
├── daily/
│ └── 20260220.tar.gz.gpg
├── weekly/
│ └── 202601.WK_2.tar.gz.gpg
└── monthly/
└── 202512.tar.gz.gpg
Copy backup.conf.example to one of these locations:
| Priority | Path |
|---|---|
| 1 | ./backup.conf (next to the script) |
| 2 | ~/.backup.conf |
Variables you can set:
| Variable | Default | Purpose |
|---|---|---|
BACKUP_SOURCE_DIR |
$HOME/Documents |
Directory to back up |
BACKUP_HOME |
$HOME/backups |
Where snapshots and archives live |
GPG_RECIPIENT |
(empty — no encryption) | GPG key email/ID for encrypting archives |
RSYNC_EXTRA_OPTS |
(empty) | Extra flags for rsync (e.g. --exclude) |
The rest of this README walks through the Unix concepts and commands used
in backup.sh. If you are learning the shell, read on.
set -eu-e— exit immediately if any command fails (returns non-zero).-u— treat unset variables as errors instead of silently expanding to empty strings.
These two flags catch entire classes of bugs. Always use them.
Note:
set -o pipefailis a Bash extension. The script uses/bin/shfor maximum portability, so it relies onset -euinstead and checks pipeline results explicitly.
Before doing anything, the script makes sure every required tool is available:
for cmd in date rsync find tar; do
command -v "$cmd" >/dev/null 2>&1 || die "$cmd is not installed"
donecommand -vis the POSIX way to check if a command exists. Prefer it overwhich, which behaves differently across systems.>/dev/null 2>&1silences both stdout and stderr — we only care about the exit code.
This is the single biggest portability pitfall in shell scripts. macOS
ships BSD date; Linux ships GNU date. They have completely different
syntax for relative dates.
| What you want | GNU date (Linux) | BSD date (macOS) |
|---|---|---|
| Yesterday | date -d "1 day ago" +%Y%m%d |
date -v -1d +%Y%m%d |
| Last month | date -d "1 month ago" +%Y%m |
date -v -1m +%Y%m |
The script detects which variant is available at runtime:
date_subtract() {
_fmt="$1" _unit="$2" _n="$3"
if date -d "now" +%s >/dev/null 2>&1; then
# GNU date
case "$_unit" in
d) date -d "$_n day ago" +"$_fmt" ;;
m) date -d "$_n month ago" +"$_fmt" ;;
esac
else
# BSD date
case "$_unit" in
d) date -v "-${_n}d" +"$_fmt" ;;
m) date -v "-${_n}m" +"$_fmt" ;;
esac
fi
}Trick: the detection itself is just date -d "now". GNU date accepts
-d; BSD date does not. We test once and branch.
rsync -aH --link-dest="$CURRENT_LINK" "$BACKUP_SOURCE_DIR" "$SNAPSHOT_DIR/$NOW"| Flag | Meaning |
|---|---|
-a |
Archive mode — recurse, preserve permissions, timestamps, symlinks, owner, group |
-H |
Preserve hard links within the source |
--link-dest=DIR |
For files unchanged since DIR, create hard links instead of copies |
The --link-dest trick is the core of the space efficiency. A snapshot of
143 MB of data may only use 16 KB of new disk space if almost nothing
changed:
$ du -sch backups/snapshots/*
143M snapshots/202602211400
16K snapshots/202602211430
16K snapshots/202602211500
178M total
du -s— summary (don't recurse into subdirectories)du -c— show a grand total at the enddu -h— human-readable sizes (K, M, G)
Caveat: Hard links do not work on NTFS or FAT filesystems (e.g. Samba mounts). rsync will silently fall back to full copies.
After each snapshot, the script points current to the latest one:
ln -snf "$LATEST" "$CURRENT_LINK"| Flag | Purpose |
|---|---|
-s |
Create a symbolic (soft) link, not a hard link |
-n |
If the target is already a symlink to a directory, don't follow it |
-f |
Overwrite the existing link |
Without -n, ln would follow the existing symlink and create a link
inside the old snapshot directory instead of replacing the symlink itself.
command1 && command2command2 runs only if command1 succeeds (exit code 0). This is used
throughout the script so that destructive operations (like rm) only run
after the preceding step completes without error.
LATEST="$(ls -1d "$SNAPSHOT_DIR"/* | tail -n1)"$(...) runs the enclosed command in a subshell and substitutes its
stdout into the outer command. Prefer $(...) over backticks (`...`)
because it nests cleanly and is easier to read.
The rotation step needs to match filenames like 20260220.tar.gz.gpg. The
script uses a helper to stay portable:
find_ere() {
_dir="$1"; shift
if find "$_dir" -maxdepth 0 -regextype posix-extended >/dev/null 2>&1; then
find "$_dir" -regextype posix-extended "$@" # GNU find
else
find -E "$_dir" "$@" # BSD find
fi
}| System | How to enable extended regex |
|---|---|
| GNU find (Linux) | -regextype posix-extended |
| BSD find (macOS) | -E flag before the path |
tar -czf archive.tar.gz -C /parent dir1 dir2| Flag | Meaning |
|---|---|
-c |
Create a new archive |
-z |
Compress with gzip |
-f |
Write to the given filename |
-C |
Change to this directory before adding files (avoids storing absolute paths) |
gpg --batch --yes -r "$GPG_RECIPIENT" --encrypt-files archive.tar.gz| Flag | Meaning |
|---|---|
-r |
Recipient — encrypt so only this key can decrypt |
--encrypt-files |
Encrypt every file argument (produces .gpg files) |
--batch --yes |
Non-interactive mode, skip confirmation prompts |
To decrypt later:
gpg --decrypt-files archive.tar.gz.gpgYou will be prompted for your passphrase.
. "$SCRIPT_DIR/backup.conf"The dot (.) command reads and executes a file in the current shell —
meaning any variables set in that file become available to the rest of the
script. It is the POSIX equivalent of Bash's source.
BACKUP_HOME="${BACKUP_HOME:-$HOME/backups}"If BACKUP_HOME is unset or empty, use $HOME/backups. This is a POSIX
parameter expansion — no external commands needed.
command >/dev/null 2>&1>/dev/null— send stdout to the void.2>&1— redirect file descriptor 2 (stderr) to wherever 1 (stdout) currently points (also the void).
The combined effect: silence the command completely. Useful when you only care about the exit code.
TODAY="${NOW%????}" # remove last 4 characters
THISMONTH="${TODAY%??}" # remove last 2 characters${var%pattern} removes the shortest match of pattern from the end of
$var. Each ? matches one character. This is POSIX shell — no need for
cut or sed for simple trimming.
There is also ${var#pattern} which removes from the beginning:
| Syntax | Removes from |
|---|---|
${var%pattern} |
End (shortest match) |
${var%%pattern} |
End (longest match) |
${var#pattern} |
Beginning (shortest match) |
${var##pattern} |
Beginning (longest match) |
Edit your crontab:
crontab -eA cron entry has five time fields followed by the command:
* * * * * command
┬ ┬ ┬ ┬ ┬
│ │ │ │ └─ day of week (0-6, Sunday=0)
│ │ │ └──── month (1-12)
│ │ └─────── day of month (1-31)
│ └────────── hour (0-23)
└───────────── minute (0-59)
Example — run every 30 minutes during work hours on weekdays:
*/30 8-18 * * 1-5 /path/to/backup.sh
| Field | Value | Meaning |
|---|---|---|
*/30 |
every 30 min | the / means "every" |
8-18 |
hours 8–18 | the - means a range |
1-5 |
Mon–Fri | day-of-week range |
Tip: use crontab -l to list your current entries without opening an
editor.
sh tests/test_backup.shThe test suite is written in pure POSIX shell (no framework needed). It covers:
- Helper functions —
date_subtractandfind_ereportability wrappers - Configuration — defaults and config-file sourcing
- End-to-end pipeline — snapshot creation, archiving, hard-link
deduplication, and log output (requires
rsync)
CI runs the full suite on both Ubuntu (GNU) and macOS (BSD) via GitHub Actions, plus ShellCheck static analysis.
Snapshots are plain directories — just copy what you need:
cp ~/backups/current/Documents/report.txt ~/Documents/# Decrypt first (if encrypted)
gpg --decrypt-files ~/backups/archives/daily/20260220.tar.gz.gpg
# Extract
tar -xzf ~/backups/archives/daily/20260220.tar.gz -C /tmp/restore/
# Browse and copy what you need
ls /tmp/restore/MIT — see LICENSE.
Originally published at anouar.adlani.com in December 2011.