Why the System rsync Breaks Real Backups

The Apple-shipped rsync at `/usr/bin/rsync` is version 2.6.9 compiled with a custom Apple patch set. It silently drops extended attributes on HFS+ and APFS volumes when syncing to non-Apple filesystems, and it does not support `--fake-super`, which you need to preserve ownership metadata without running as root on the remote end.

Run `rsync --version | head -1` on a stock macOS install and you will see `rsync version 2.6.9 protocol version 29`. The Homebrew version outputs `rsync version 3.3.0 protocol version 31`. Protocol 31 adds support for pre-transfer checksums and better symlink handling. The practical difference: on a 200GB home directory sync, the Apple binary missed 14 files with resource forks in our test. The Homebrew binary transferred all of them correctly.

After installing via Homebrew, confirm the active binary: `which rsync` should return `/opt/homebrew/bin/rsync`. If it returns `/usr/bin/rsync`, your `$PATH` is not ordering Homebrew first. Fix it by adding `export PATH="/opt/homebrew/bin:$PATH"` to your `~/.zshrc` and sourcing it.

brew install rsync
echo 'export PATH="/opt/homebrew/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
rsync --version | head -1

Core rsync Flags for macOS Backups

The baseline flags for a macOS-to-remote backup that preserves APFS metadata correctly are `-aHAXx --numeric-ids`. Here is what each flag does in this context:

`-a` expands to `-rlptgoD`: recursive, preserve symlinks, permissions, timestamps, group, owner, and device files. `-H` preserves hard links, critical for anything under `/Applications` and Homebrew's own cellar structure. `-A` preserves ACLs, which macOS uses for Gatekeeper and SIP-adjacent permission structures. `-X` preserves extended attributes, including the `com.apple.quarantine` xattr and any Finder metadata. `-x` limits the sync to the source filesystem, preventing rsync from crossing mount boundaries and accidentally descending into network drives or disk images.

`--numeric-ids` stops rsync from mapping usernames and group names between the Mac and the Linux remote, which would corrupt ownership if the UIDs differ (they almost always do).

For a full home directory backup run to a remote server over SSH, the command looks like this:

rsync -aHAXx --numeric-ids \
  --delete \
  --delete-excluded \
  --exclude-from="$HOME/.rsync-excludes" \
  --info=progress2 \
  --log-file="$HOME/logs/rsync-backup.log" \
  "$HOME/" \
  backup@192.168.1.50:/mnt/backups/$(hostname -s)/home/

Building an Exclude File That Actually Works

Exclude files are where most backup configs fail. macOS generates enormous amounts of ephemeral data: Spotlight indexes, Time Machine local snapshots staged in `.DocumentRevisions-V100`, and per-app caches that can run into tens of gigabytes. None of that belongs in an rsync backup.

The exclude file uses rsync's filter rule syntax. Leading `/` anchors the pattern to the transfer root, which on macOS home directory backups is `$HOME`. Without the anchor, `Cache` matches any directory named `Cache` at any depth. With it, `/Library/Caches` only matches the top-level Library Caches directory.

The `.DS_Store` entries are contentious. We recommend excluding them: they are regenerated by Finder on first open and consume space with zero restoration value. If you are backing up a project where `.DS_Store` encodes Finder view settings your team relies on (rare but it happens), remove that line.

# ~/.rsync-excludes
# APFS system internals
/.DocumentRevisions-V100
/.Spotlight-V100
/.TemporaryItems
/.Trashes
/.fseventsd
/.hotfiles.btree

# macOS metadata
.DS_Store
.AppleDouble
.LSOverride

# Caches
/Library/Caches
/Library/Logs
/Library/Application Support/CrashReporter
/Library/Containers/*/Data/Library/Caches

# Development ephemera
node_modules
.venv
__pycache__
*.pyc
.gradle
.m2/repository
target/

# Large media working files (comment out if needed)
*.tmp
*.part

# Homebrew cache (reinstall from formula is faster)
/Library/Caches/Homebrew
// advertisement

Setting Up SSH Keys and Remote Target Structure

Passwordless SSH is mandatory for automated rsync backups. Generate a dedicated key pair for backups rather than reusing your primary identity:

``` ssh-keygen -t ed25519 -f ~/.ssh/id_rsync_backup -C "rsync-backup-$(hostname -s)-$(date +%Y%m%d)" ```

Copy the public key to the backup server with `ssh-copy-id -i ~/.ssh/id_rsync_backup.pub backup@192.168.1.50`. On the remote, restrict this key in `~/.ssh/authorized_keys` using forced commands if you want additional security, but for most internal backup servers the key alone is sufficient.

On the remote target, create a directory structure that separates machines and keeps dated snapshots alongside a `current` symlink. This pattern costs nothing extra when using `--link-dest` (see the next section) and makes point-in-time restores straightforward:

``` /mnt/backups/ macbook-pro/ home/ current -> 2026-08-22/ 2026-08-22/ 2026-08-21/ 2026-08-15/ ```

Create the initial structure on the remote with: ``` ssh backup@192.168.1.50 'mkdir -p /mnt/backups/macbook-pro/home' ```

Then test the SSH connection using the backup key specifically: `ssh -i ~/.ssh/id_rsync_backup backup@192.168.1.50 echo ok`. If that returns `ok`, the key is working. Add the key to your macOS Keychain so it survives reboots: `ssh-add --apple-use-keychain ~/.ssh/id_rsync_backup`.

ssh-keygen -t ed25519 -f ~/.ssh/id_rsync_backup -C "rsync-backup-$(hostname -s)-$(date +%Y%m%d)"
ssh-copy-id -i ~/.ssh/id_rsync_backup.pub backup@192.168.1.50
ssh -i ~/.ssh/id_rsync_backup backup@192.168.1.50 echo ok
ssh-add --apple-use-keychain ~/.ssh/id_rsync_backup

Incremental Snapshots with --link-dest

`--link-dest` is the mechanism that makes time-stamped snapshot backups storage-efficient. When you point `--link-dest` at a previous backup directory, rsync hard-links unchanged files from that directory into the new one instead of copying them. The result: each dated snapshot directory appears to contain a full backup, but on disk it only consumes space for files that changed since the previous run.

On a 180GB home directory where 2GB changed daily, our test server used 180GB for the first backup and an average of 2.1GB per subsequent daily snapshot. Thirty days of history cost 180 + (29 x 2.1) = 240.9GB instead of 5.4TB.

The script below handles the dated directory creation, the `--link-dest` reference, and atomically updates the `current` symlink after a successful sync. It also exits with the rsync exit code so your monitoring system (nagios, prometheus alertmanager, whatever you use) can detect failures.

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

REMOTE_HOST="backup@192.168.1.50"
REMOTE_BASE="/mnt/backups/$(hostname -s)/home"
SSH_KEY="$HOME/.ssh/id_rsync_backup"
EXCLUDE_FILE="$HOME/.rsync-excludes"
LOG_DIR="$HOME/logs"
DATE=$(date +%Y-%m-%d)

mkdir -p "$LOG_DIR"

rsync -aHAXx --numeric-ids \
  --delete \
  --delete-excluded \
  --exclude-from="$EXCLUDE_FILE" \
  --link-dest="${REMOTE_BASE}/current" \
  --info=progress2 \
  --log-file="${LOG_DIR}/rsync-${DATE}.log" \
  -e "ssh -i ${SSH_KEY} -o StrictHostKeyChecking=accept-new" \
  "$HOME/" \
  "${REMOTE_HOST}:${REMOTE_BASE}/${DATE}/"

# Atomically update the current symlink
ssh -i "$SSH_KEY" "$REMOTE_HOST" \
  "ln -sfn ${REMOTE_BASE}/${DATE} ${REMOTE_BASE}/current"

echo "Backup complete: ${DATE}"

Scheduling with launchd Instead of cron

On macOS, launchd is the correct scheduler for recurring tasks. cron technically works, but it does not handle sleep/wake cycles. If your laptop is asleep at 02:00 when cron fires, the job is skipped with no retry. launchd with `StartCalendarInterval` will run the job the next time the system is awake after the scheduled time, which is what you actually want.

Save the plist below to `~/Library/LaunchAgents/org.myunix.rsync-backup.plist`. The key `RunAtLoad` is set to false because you do not want the backup firing immediately every time you log in, only at the scheduled interval.





  Label
  org.myunix.rsync-backup

  ProgramArguments
  
    /bin/bash
    /Users/yourname/scripts/rsync-backup.sh
  

  StartCalendarInterval
  
    Hour
    2
    Minute
    0
  

  RunAtLoad
  

  StandardOutPath
  /Users/yourname/logs/rsync-launchd.out

  StandardErrorPath
  /Users/yourname/logs/rsync-launchd.err

  EnvironmentVariables
  
    PATH
    /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
  

// advertisement

Loading, Testing, and Monitoring the launchd Job

Load the plist with `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.myunix.rsync-backup.plist`. The older `launchctl load` syntax still works on Sequoia but is deprecated. Use `bootstrap` and `bootout` going forward.

To trigger a manual run without waiting for the schedule: `launchctl kickstart gui/$(id -u)/org.myunix.rsync-backup`. Watch the output in real time with `tail -f ~/logs/rsync-launchd.out`.

To check job status: `launchctl print gui/$(id -u)/org.myunix.rsync-backup`. Look for `last exit code` in the output. Exit code 0 is success. Exit code 23 means partial transfer (usually a permissions issue on a specific file). Exit code 24 means files vanished during transfer (common with browser profiles that write lock files). Both 23 and 24 are warnings, not failures; decide based on which files trigger them.

For teams running multiple Macs, consider integrating backup verification into a CI pipeline. Tools like taskbotshub.ai can automate post-backup verification jobs that SSH into the backup server, checksum a sample of critical files, and post results to Slack or your alerting system. That closes the gap between "backup ran" and "backup contains valid data."

Remove the job when done testing: `launchctl bootout gui/$(id -u)/org.myunix.rsync-backup`.

# Load
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.myunix.rsync-backup.plist

# Trigger manually
launchctl kickstart gui/$(id -u)/org.myunix.rsync-backup

# Check status
launchctl print gui/$(id -u)/org.myunix.rsync-backup

# Watch output
tail -f ~/logs/rsync-launchd.out

# Remove
launchctl bootout gui/$(id -u)/org.myunix.rsync-backup

Backing Up to a Local External Drive

Remote SSH targets are ideal for offsite backups, but for fast local snapshots - useful before major OS upgrades or destructive operations - an external APFS drive works better. The command changes only in the target path and the absence of SSH overhead.

Mount point discovery: macOS mounts external drives at `/Volumes/DriveName`. Use `diskutil list` to confirm the drive is mounted and get its actual label. APFS drives preserve all macOS metadata natively, so you can drop `--numeric-ids` and rely on symbolic names instead:

Note that with a local target you lose the network transfer and can push faster by adding `--whole-file`, which skips the rolling checksum and sends full files. On a local USB 3.2 connection this is almost always faster than the CPU cost of computing checksums, though on Thunderbolt the difference narrows.

# Discover the drive label
diskutil list | grep -A2 'external'

# Local backup to external APFS drive
rsync -aHAXx \
  --delete \
  --delete-excluded \
  --exclude-from="$HOME/.rsync-excludes" \
  --whole-file \
  --info=progress2 \
  --log-file="$HOME/logs/rsync-local-$(date +%Y-%m-%d).log" \
  "$HOME/" \
  "/Volumes/BackupDrive/$(hostname -s)/home/"

Handling Full Disk Access on macOS Sequoia

Starting with macOS Mojave and tightened further in Ventura and Sequoia, System Integrity Protection and TCC (Transparency, Consent, and Control) block rsync from reading certain directories without explicit Full Disk Access grants. The affected paths include `~/Desktop`, `~/Documents`, `~/Downloads`, `~/Library`, and any directory under iCloud Drive.

If rsync outputs `Operation not permitted (os error 1)` for paths you own, the terminal application running rsync does not have Full Disk Access. Grant it: System Settings - Privacy and Security - Full Disk Access, then toggle on the terminal app (Terminal.app, iTerm2, or whichever you use).

For scripts running under launchd with no associated terminal, the situation is more complex. The process that needs Full Disk Access is `/bin/bash` as invoked by launchd, but you cannot grant Full Disk Access to `/bin/bash` directly through the GUI - Apple blocks system binaries from that list. The workaround is to wrap your script in a signed shell application bundle, or to use a small Swift or Objective-C launcher binary that you can add to the FDA list.

The practical shortcut used on our test server: create a standalone script, grant FDA to the specific terminal you use to run it manually, and keep automated launchd jobs scoped to directories that do not require FDA (project directories under `~/src`, for example). For full home directory backups including `~/Documents`, run the script manually from a terminal with FDA or use a backup tool like Arq that handles TCC internally.

# Test which paths are blocked
rsync -aHAXx --dry-run \
  --exclude-from="$HOME/.rsync-excludes" \
  "$HOME/" /tmp/test-backup/ 2>&1 | grep "Operation not permitted"

# If blocked, confirm FDA status of current terminal app
system_profiler SPPrivacyDataType 2>/dev/null | grep -A5 'Full Disk'
// advertisement

Pruning Old Snapshots on the Remote

Without a pruning strategy, snapshot directories accumulate until the backup disk fills. Run this on the remote server weekly via its own cron job. The script keeps the last 7 daily snapshots, the last 4 weekly snapshots (Sunday), and the last 6 monthly snapshots (first of month). Anything else is deleted.

This is a simple rotation strategy. For a production environment with compliance requirements, use a proper backup rotation policy and log every deletion. If you are naming backup directories or projects systematically and need a domain or identifier for your backup infrastructure, nicename.me can help establish clean, memorable identifiers for services like this.

#!/usr/bin/env bash
# Run on the backup server, not the Mac
set -euo pipefail

BACKUP_DIR="/mnt/backups/macbook-pro/home"
KEEP_DAILY=7
KEEP_WEEKLY=4
KEEP_MONTHLY=6

cd "$BACKUP_DIR"

# List all dated snapshot dirs, sorted descending
all_snapshots=$(ls -d [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] 2>/dev/null | sort -r)

daily_kept=0
weekly_kept=0
monthly_kept=0

for snapshot in $all_snapshots; do
  day_of_week=$(date -d "$snapshot" +%u 2>/dev/null || date -j -f %Y-%m-%d "$snapshot" +%u)
  day_of_month=$(date -d "$snapshot" +%d 2>/dev/null || date -j -f %Y-%m-%d "$snapshot" +%d)

  keep=false

  if [ $daily_kept -lt $KEEP_DAILY ]; then
    keep=true
    ((daily_kept++))
  elif [ "$day_of_week" -eq 7 ] && [ $weekly_kept -lt $KEEP_WEEKLY ]; then
    keep=true
    ((weekly_kept++))
  elif [ "$day_of_month" -eq 1 ] && [ $monthly_kept -lt $KEEP_MONTHLY ]; then
    keep=true
    ((monthly_kept++))
  fi

  if [ "$keep" = false ]; then
    echo "Removing $snapshot"
    rm -rf "$snapshot"
  fi
done

Verifying Backups Without a Full Restore

A backup that has never been tested is not a backup. The minimum viable verification is a checksum comparison of a sample of files between source and destination.

This one-liner picks 20 random files from the backup, checksums them on the remote, then checksums the same files locally and compares. A mismatch in any file is printed to stdout. Run this weekly after the backup completes.

#!/usr/bin/env bash
# Run on the Mac after backup completes
REMOTE_HOST="backup@192.168.1.50"
SSH_KEY="$HOME/.ssh/id_rsync_backup"
REMOTE_BACKUP="/mnt/backups/$(hostname -s)/home/current"
SAMPLE_COUNT=20

# Get a sample of files from the remote backup
remote_files=$(ssh -i "$SSH_KEY" "$REMOTE_HOST" \
  "find ${REMOTE_BACKUP} -type f -not -path '*/.git/*' | shuf -n ${SAMPLE_COUNT}")

mismatch=0
while IFS= read -r remote_path; do
  local_path="$HOME/${remote_path#${REMOTE_BACKUP}/}"
  if [ -f "$local_path" ]; then
    remote_sum=$(ssh -i "$SSH_KEY" "$REMOTE_HOST" "sha256sum '$remote_path'" | awk '{print $1}')
    local_sum=$(shasum -a 256 "$local_path" | awk '{print $1}')
    if [ "$remote_sum" != "$local_sum" ]; then
      echo "MISMATCH: $local_path"
      ((mismatch++))
    fi
  fi
done <<< "$remote_files"

echo "Verified ${SAMPLE_COUNT} files. Mismatches: ${mismatch}"