Skip to content

About

Rsyncronizer is a set-and-forget rsync backup manager for macOS and Linux that comes in CLI and GUI flavours.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Rsyncronizer app icon

Rsyncronizer

Append-only backups to another machine over SSH, a drive you plug in, or a cloud service: engine, desktop apps and CLI. Install it on each machine you back up from (see Install below), or clone this repo and run it in place. An SSH destination needs nothing installed beyond SSH and rsync; a cloud destination needs nothing at the far end at all.

                          ┌─►  backup-server (Linux)  ──►  nas (Synology)
                          │      ~/backups                  /volume1/backups/
                          │
laptop (macOS)  ──────────┼─►  external SSD           (when you plug it in)
  ~/Documents             │
                          └─►  S3 · Google Drive · OneDrive · Dropbox

Nothing is ever deleted at a destination by a scheduled or plain run. Remove a file at the source and it stays at the destination until you delete it there yourself, or until you explicitly ask for --sync-deletions run (see Deleting at the destination below). This is enforced at runtime, not just by convention; see Safety.

Install

Every build is on the Releases page: a desktop app for macOS and Linux, and a headless CLI for any machine with bash. All forms need rsync and ssh on the machine; macOS ships both, on Linux run sudo apt install rsync openssh-client.

Cloud destinations additionally need rclone. The desktop apps ship it — nothing to install. The CLI and a source checkout use the system one (brew install rclone, sudo apt install rclone). It is entirely optional: nothing changes for SSH and drive backups if it is absent, and the app only mentions it once you configure, or already own, a cloud backup.

Desktop app on macOS (Apple silicon)

Download rsyncronizer-<version>-macos-arm64.zip from Releases, unzip it and drag Rsyncronizer.app into /Applications. The first launch of a downloaded copy is blocked because the app is unsigned: double-click it once, then System Settings → Privacy and Security → Open Anyway. Terminal alternative:

xattr -dr com.apple.quarantine /Applications/Rsyncronizer.app

That is needed one time only. Afterwards the app updates itself: it checks on startup and shows an Update Available button when a new release is out.

Desktop app on Linux (x86_64)

Needs glibc 2.35 or newer (Ubuntu 22.04+) and a desktop session. This installs per-user, no root needed, and puts Rsyncronizer in your app menu:

ver=$(curl -fsSL https://api.github.com/repos/yilmazdoga/rsyncronizer/releases/latest | sed -n 's/.*"tag_name": *"v//p' | sed 's/".*//')
curl -fsSLO "https://github.com/yilmazdoga/rsyncronizer/releases/download/v${ver}/rsyncronizer-${ver}-linux-x86_64.tar.gz"
tar -xzf "rsyncronizer-${ver}-linux-x86_64.tar.gz"
"./rsyncronizer-${ver}-linux-x86_64/install.sh"

Prefer not to install? Extract and run it in place: ./rsyncronizer-<version>-linux-x86_64/rsyncronizer/rsyncronizer.

CLI on macOS or Linux (no GUI)

One tarball works on both systems; it is plain bash. This installs the engine to ~/.local/share/rsync-backup-scripts/ and the rsyncronizer command to ~/.local/bin/ (make sure that is on your PATH):

ver=$(curl -fsSL https://api.github.com/repos/yilmazdoga/rsyncronizer/releases/latest | sed -n 's/.*"tag_name": *"v//p' | sed 's/".*//')
curl -fsSLO "https://github.com/yilmazdoga/rsyncronizer/releases/download/v${ver}/rsyncronizer-cli-${ver}.tar.gz"
tar -xzf "rsyncronizer-cli-${ver}.tar.gz"
"./rsyncronizer-cli-${ver}/install-cli.sh"

Start with rsyncronizer help; update later with rsyncronizer update.

From source

git clone https://github.com/yilmazdoga/rsyncronizer.git
cd rsyncronizer

The engine runs straight from the clone (./setup.sh, ./status.sh). For the CLI command, run cli/install-cli.sh; for the desktop app, see gui/README.md.

The app and the CLI share one engine home, so on a machine that runs the GUI app, update through the app; it refreshes the shared engine on launch.

Setup

From a clone, run the wizard below; from an install, the same wizard is the app's New backup button or rsyncronizer new.

./setup.sh

The wizard asks for a name, a source folder and a destination, checks that all of it actually works, generates backups/<name>.sh, and optionally installs a cron entry. Re-run it any time to change a setting; existing values come back as defaults.

Then:

./backups/<name>.sh --dry-run    # transfers nothing, shows what would happen
./backups/<name>.sh              # the real thing
./status.sh                      # did it run? did it work?
./tests/run-tests.sh             # ~300 checks, touches no real data

Long first transfers should run under tmux.

Two things that will bite you

1. macOS cron cannot read ~/Documents by default. Until /usr/sbin/cron has Full Disk Access, a scheduled job runs, reports success, and backs up almost nothing. Grant it:

System Settings → Privacy & Security → Full Disk Access → + → ⌘⇧G → /usr/sbin/cron

Do not try launchctl kickstart: System Integrity Protection refuses it (error 150) on modern macOS. If cron is already running, log out and back in so it picks up the grant.

./tests/cron-smoke-test.sh <backup-name>

These scripts detect the condition (an empty source is a hard failure, not a silent success), but fixing it up front is better. After the first scheduled run, check the file count in the log, not just the exit code.

2. Cron does not catch up missed runs. A job scheduled for 02:00 on a laptop with the lid shut simply never fires, and leaves no log to tell you. ./status.sh reports STALE when the last successful run is overdue. Run it now and then.

Backing up to a drive you plug in

A backup can target a drive plugged into this machine instead of another machine over SSH. It then runs when you connect the drive, rather than on a clock. ./setup.sh asks which you want; pick option 2 and it detects the mounted volume, marks it, and installs the trigger.

./setup.sh
  • macOS: a launchd agent (~/Library/LaunchAgents). No sudo.
  • Linux: a udev rule plus a systemd service. These live under /etc, so setup.sh writes an installer script and prints the sudo command rather than escalating on its own. Read it before you run it.

A cooldown stops replugging from rescanning everything. COOLDOWN_HOURS in options.txt (default 12) skips a connect-triggered run that soon after the last attempt. To force one:

rm logs/<name>/.last_attempt_epoch

Two guards stop the worst failure, which is backing up while the drive is absent: rsync would recreate the mount-point directory on your system disk and quietly fill it. A run proceeds only if the path is genuinely a mount point and carries a .rsync-backup-volume marker, which lives on the drive itself. Otherwise it exits 69 and writes nothing.

exFAT drives get PRESERVE_PERMS=no and MODIFY_WINDOW=1 set automatically (no Unix permissions; 2-second timestamps, without which every file looks modified on every run). exFAT also cannot store | < > : " ? * \ in filenames; setup.sh counts them up front, shows which folders they cluster in, and offers to add those to config/<name>/exclude.txt for you. Note macOS actually accepts those characters on exFAT while Linux does not, so exclude them on both machines if the drive is shared.

Backing up to a cloud service

A backup can go to Amazon S3 (or S3-compatible storage), Google Drive, OneDrive or Dropbox. ./setup.sh asks which you want; pick option 3. Cloud backups run on a schedule like SSH ones, and land by the same copy-by-name rule: ~/Documents becomes gdrive:Backups/laptop/Documents/.

rsync cannot reach any of those services — none of them speaks the rsync protocol or SSH — so cloud backups use rclone as a second transport. Which one a backup uses is decided by its config and nothing else; the rsync path is untouched.

Rsyncronizer stores no cloud credentials. Your keys and sign-in tokens live in rclone's own ~/.config/rclone/rclone.conf; this repo's config records only the name of an rclone remote. Connect an account with the app's Connect an account… button, or with rclone config in a terminal. rsyncronizer cloud list shows what is connected, rsyncronizer cloud check <name> probes one destination, and rsyncronizer cloud login <remote> re-authorises an expired one.

An encrypted rclone.conf cannot be used for scheduled backups. Cron has nobody to type the password to, so the job would fail on every fire. Setup warns about it, and a run fails fast rather than hanging on a prompt nobody sees.

config/<name>/destination.txt for a cloud backup:

DEST_TYPE=cloud
RCLONE_REMOTE=gdrive
DEST_PATH=Backups/laptop
CLOUD_PROVIDER=drive

DEST_PATH is a path inside the remote, with no leading slash. For S3 its first segment is the bucket (my-bucket/backups/laptop).

Worth knowing before you rely on it:

S3 (and S3-compatible) No free-space figure exists, so the dashboard never shows one. Reading a file's timestamp costs an extra request, so the first run is slow and chatty.
Google Drive Uploads are capped at 750 GB per day; a larger first backup simply continues the next day. A confirmed sync-deletions run moves entries to Drive's trash, so space is reclaimed only when you empty it. Drive can hold two files with the same name — rclone dedupe fixes that.
OneDrive Paths over 400 characters and files over 250 GB are rejected. The sign-in expires after 90 days of no use. Versioning is on by default and can double the space a file occupies.
Dropbox Some filenames are refused outright (thumbs.db and friends). Dropbox can only change a file's timestamp by uploading it again, so a timestamp-only change costs a full transfer.

None of the four stores symlinks, ownership or permission bits, and no rclone flag changes that: files come back as plain files owned by you. Restores and, on some providers, reads cost money — check your provider's egress pricing before treating a cloud copy as your only copy.

Deleting at the destination

The one sanctioned exception to the never-delete rule, and it is manual by construction:

./backups/<name>.sh --sync-deletions --dry-run   # preview only: nothing happens
./backups/<name>.sh --sync-deletions             # preview -> typed confirmation -> delete

A prune run first lists every destination entry that does not exist at the source (the terminal shows up to 200; the full list is always in the log), then asks you to type I confirm (or i confirm). Anything else aborts with exit 75 and nothing is transferred or deleted. After you confirm, the run re-checks the source and recomputes the list: if it is no longer identical to what you confirmed (say the prompt sat open while a source volume unmounted), it aborts with exit 75 rather than delete anything you did not see.

Details worth knowing:

  • Deletion uses --delete-after: files are removed only after the transfer completed, so an interrupted prune deletes nothing. A cloud backup gets the same guarantee: an ordinary run is rclone copy, which has no code path that deletes at all, and rclone sync --delete-after is reachable only through the gate described here.
  • On a cloud destination, what "deleted" means is the provider's business. Google Drive and OneDrive move the entry to their trash, so space is reclaimed only when you empty it — which is also a 30-day safety net rsync never had.
  • It acts on the destination side only. The source is never touched: rsync's deletion is receiver-side, and --remove-source-files stays banned.
  • Excluded names survive a prune. Anything matched by rsync-ignore.txt or the per-backup exclude.txt (@eaDir, .DS_Store, .rsync-partial, …) is protected; only --delete-excluded, which stays banned, would remove it. The cloud path proves this rather than assumes it: the scan and the transfer are passed the same generated filter file.
  • The cloud preview is two directory listings differenced, not an rclone check. check reports a file missing at the source as an ERROR, which makes "there is something to delete" and "the scan failed" the same signal — no use for a control that gates deletion. A listing either completes or it does not, and a source that lists as empty aborts outright rather than marking the whole destination for deletion.
  • It cannot be scheduled: --sync-deletions with --cron or --on-mount is refused outright, there is no config key for it, and the confirmation needs a real terminal on stdin and stdout; echo 'I confirm' | … does not work.
  • Each scan leaves prune-<ts>-preview.log / prune-<ts>-recheck.log audit files in logs/<name>/ (their own ring of 10, separate from the 30 run logs, so scans never crowd out real run history), and status.sh shows [PRUNE] plus the deleted count for the last run.
  • The list is for your eyes; rsync computes the actual deletion set itself. A filename with a leading space or an embedded newline can render imperfectly in the listing, but is deleted (or kept) correctly.
  • On macOS (openrsync), a directory is only removed once it is already empty: a folder that loses all its contents in one prune disappears on the next one (verified live; the first pass warns not empty, cannot delete).

Remote servers

The app and the CLI can watch and drive backups that live on other machines over SSH: ./remote.sh add workstation user@backup-server '~/rsyncronizer', then ./remote.sh workstation status|run|dry| sync-deletions <name>. In the app, each server gets its own box under LOCAL. All safeguards run on the owning machine: the remote engine's own preview, typed confirmation and re-verification stream back over the connection. Every ssh call is BatchMode-only (a broken key errors cleanly instead of hanging; fix it with ./ssh-setup.sh user@host, which copies your key after asking that account's password once). If the connection drops at the prompt the remote declines; deletions only ever run after a completed transfer.

The CLI (no GUI needed)

rsyncronizer is the same engine with a terminal face (installation is covered under Install above):

rsyncronizer status
rsyncronizer run <name>
rsyncronizer sync-deletions <name>
rsyncronizer ignore            # edit the global excludes in $EDITOR
rsyncronizer cloud list        # cloud accounts rclone knows about
rsyncronizer cloud check <name>  # probe one cloud destination now
rsyncronizer remote list
rsyncronizer update            # self-update from GitHub Releases (CLI installs)

(On a machine running the GUI app, update the app instead; it refreshes the shared engine on launch and would revert a CLI-side update.)

rsyncronizer help shows the rest. It installs to the same engine home the app uses, so the two share every backup.

The Rsyncronizer desktop apps

Rsyncronizer, the standalone GUI app for macOS and Linux, lives under gui/ and is published on the GitHub Releases page. They are thin shells over the exact same scripts: the wizard runs setup.sh --answers, the dashboard renders status.sh --porcelain, and every run streams a real runner under a pty. "Run with sync deletions" exists in the app too, with the identical safeguards: the engine's own preview streams into the dialog and you must type I confirm yourself; the app only forwards what you type, and the engine re-verifies the list before deleting.

The wizard's Destination page offers a third choice — a cloud service — with an account picker and a Connect an account… button. Google Drive, OneDrive and Dropbox open your browser to sign in; S3 is a plain form. Either way the credentials go into rclone's own config and never into this project's config tree or its logs, and the S3 secret is written to ~/.aws/credentials rather than passed on a command line where ps could read it.

The app is self-contained: on first launch it materializes its own copy of the engine into ~/.local/share/rsync-backup-scripts/ (both platforms; that path is spaceless on purpose, because a space would end up inside generated crontab lines). Its backups are managed there, independent of any clone of this repo.

Adopting a backup you configured from a clone: copy its config/<name>/ into ~/.local/share/rsync-backup-scripts/ and run the app's wizard once for that name. Cron blocks are keyed by backup name, so the wizard replaces the clone's cron entry. Do not also run the old clone's cron removal afterwards, or you would strip the entry the app just installed.

Installation and the one-time macOS first-launch step are covered under Install above. A locally built copy is not quarantined and just opens.

Layout

Path
setup.sh the wizard
status.sh health report; exits non-zero if something needs attention
rsync-ignore.txt shared exclude list, applies to every backup
lib/common.sh the engine
lib/cloud.sh the rclone transport: every rclone call lives here
lib/rclone-version.txt the rclone the desktop apps bundle, pinned by version and checksum
backups/<name>.sh generated runner; gitignored, recreated by setup.sh
config/<name>/ source, destination, options; gitignored
logs/<name>/ one log per run, newest 30 kept; gitignored

config/ and backups/ are both gitignored: everything machine-specific is recreated by setup.sh on each machine, so only the tool itself is tracked. A backup whose config is missing fails loudly and points you at setup.sh; it never falls back to a default.

Config

config/<name>/source.txt holds one line. Absolute, or relative to $HOME (cron has no meaningful working directory):

Documents

The folder is copied by name, not by contents, so ~/Documents always lands as <destination>/Documents/. Trailing slashes are stripped for you; this is the single easiest way to end up with Documents/Documents/, so it is not left to chance.

config/<name>/destination.txt:

USER=alice
HOST=backup-server
DEST_PATH=backups

HOST can be an ~/.ssh/config alias, a hostname or an IP. Leave USER blank to let ~/.ssh/config decide. Key-based auth only; the key must have no passphrase, or cron can never authenticate (cron gets no ssh-agent).

config/<name>/options.txt, every key optional: SSH_PORT, BWLIMIT, TIMEOUT, ALLOW_EMPTY_SOURCE, EXTRA_FLAGS. Cloud backups use RCLONE_TRANSFERS, RCLONE_CHECKERS and RCLONE_EXTRA_FLAGS instead — one flag key per transport, and each is refused on the other, so an rsync flag list can never reach rclone or the reverse.

Ignore rules, nothing hidden. config/global-exclude.txt IS the global list: it is seeded once from the shipped defaults (.DS_Store, @eaDir, __pycache__, …) and from then on fully replaces rsync-ignore.txt, so every exclusion is visible in one editable file and deleting a line really un-excludes it. Per-backup config/<name>/exclude.txt stacks on top. Anything excluded is also protected from --sync-deletions at the destination. Edit in the app ("Global Ignore Rules" / "Per-Schedule Ignore Rules") or with rsyncronizer ignore [name].

Safety

EXTRA_FLAGS is not a loophole. Every flag is checked against a blacklist immediately before rsync runs: deletion (--delete*, --remove-*, --force), in-place overwrite (--inplace, --append, bare --partial, -P), and remote command execution (--rsync-path, -e, -M). A blocked flag stops the run with exit 78 before a byte moves. Config files are parsed line by line and never sourced, so a config file cannot execute anything.

A confirmed --sync-deletions run does not weaken any of that: the blacklist is never relaxed. Its --delete-after is appended after the guard has passed, behind a second gate that verifies the run is manual, interactive, and was confirmed by the typed phrase, so EXTRA_FLAGS=--delete still dies 78 even inside a prune run.

Cloud backups get the same treatment from a blacklist of their own, because rsync's flags and rclone's are different languages: deletion (--delete*, --max-delete), source mutation (--delete-empty-src-dirs), selection overrides (--files-from*, --include*), credential leaks (--dump, which writes OAuth tokens and AWS signatures into a log kept for 30 runs), remote control (--rc*) and command execution (--password-command). The verb is the real switch: an ordinary run is rclone copy, sync is produced at one place in the code, and the guard re-proves the confirmation from the verb itself.

A flag blacklist alone would be decorative for rclone, because rclone reads an environment variable for every flag it has — RCLONE_IGNORE_ERRORS=true reaches the same switch as the command-line flag, and RCLONE_CONFIG_<REMOTE>_TYPE can re-point the destination outright. Every RCLONE_* variable is therefore stripped before rclone runs, and each one removed is named in the log.

Ownership is never preserved (--no-o --no-g): it only works when the receiver runs as root, so over an ordinary SSH login it was failing silently anyway. No cloud service records ownership or permissions at all.

A backup run deletes nothing unless it is a confirmed --sync-deletions run: otherwise its only rm removes this repo's own old log files, plus its own lock directory. (setup.sh and the test suite do delete more, such as a temporary crontab entry and scratch directories, but a scheduled backup never touches either.)

Two things the never-delete rule does not cover, both inherent to rsync: a destination file is unlinked if the source turns that same name into a directory, and a destination copy is overwritten in place, so corruption at the source propagates rather than being versioned.

Exit codes

0 success
23 partial; some files could not be read; the log names them
24 success; some source files vanished mid-run (benign)
12, 255 SSH failure; destination unreachable
30, 35 timeout; re-running resumes from .rsync-partial
66 refused; the source was empty or unreadable (see Full Disk Access)
75 prune declined at the prompt, or the deletion list changed after confirmation; nothing was transferred or deleted
78 misconfigured, or an unsafe flag was refused

An exit code is only meaningful against the transport that produced it, and the two tables genuinely collide — rclone's 4 is "file not found" where rsync's is "unsupported action", and rclone's 5 is a temporary error where rsync's is a protocol failure. The engine records which transport ran and reads the code against the right table; status.sh does the same. For cloud backups:

0 success
3, 4 a path was not found at the remote
5 temporary error at the provider; nothing was deleted, re-running resumes
6 partial; some files could not be transferred
7 sign-in expired or the account is suspended — rsyncronizer cloud login <remote>
8, 10 a transfer limit was reached (Google Drive's 750 GB/day); re-run to continue
69 rclone is missing or too old
75 prune declined, the list changed, or a listing could not be trusted; nothing was transferred or deleted

Support

Rsyncronizer is completely free and will be so forever. If you find it useful, please consider supporting me and my work:

Buy Me A Coffee

Every 10 completed backups, the app displays a support reminder. The desktop app shows a small dialog, and the terminal prints a note with a QR code.

If you have already supported (thank you!), or simply do not want to, type i have supported in the dialog (or run rsyncronizer support --done) to permanently disable it. Clicking Later postpones the reminder for 10 more backups.

Notes

  • --iconv is deliberately not used. macOS stores filenames decomposed (NFD) and Linux composed (NFC), and --iconv would normalise them. It is a one-way door: turning it on after the first transfer re-sends every non-ASCII filename under a second spelling, and since nothing is ever deleted, both live at the destination forever and then propagate onward. It also fails outright on glibc, which does not know the utf-8-mac charset.
  • rclone is bundled in the desktop apps, not in the CLI. One tarball cannot carry two architectures, so a CLI install uses the system rclone. The engine looks for its bundled copy first and falls through to the system one, so both work with no configuration. The bundled version is pinned by checksum in lib/rclone-version.txt and verified at build time.
  • macOS openrsync is fully supported. It accepts this repo's entire flag set. brew install rsync is optional; it adds --protect-args, which only matters for destination paths containing spaces.
  • .git/ is not excluded. Dropping it would strip history, branches and stashes; for a repo with no upstream remote the backup would not be a backup. The cost is a slower scan phase.

About

Rsyncronizer is a set-and-forget rsync backup manager for macOS and Linux that comes in CLI and GUI flavours.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages