A terminal UI for rsync β manage reusable profiles, preview a transfer as a
structured diff before running it, and watch a live run with progress and
cancellation. All from the terminal, including over SSH where a desktop GUI
can't reach.
- Why
- Features
- Install
- Quickstart
- Headless & scheduling
- Keybindings
- Configuration
- Contributing
- Acknowledgements
- License
rsync is the right tool for backups and syncs, but its flags are easy to get
wrong and a single mistake can delete data. lazyrsync keeps you in the terminal
while giving you the safety of a GUI: save your transfers once, see exactly what
a run will change before it runs, and keep destructive flags behind a gate.
Save a Source β Destination pair once and rerun it with a keystroke.
Press p and watch the transfer resolve into a +/~/- diff with stats.
Nothing is written until you say so.
r runs it β a progress bar fills with byte and file counts. Press c to
stop mid-transfer.
Toggle rsync's options as checkboxes. Flip on --delete and it makes you
confirm before anything can be removed.
Put a user@host:/path on either side of a task and it runs over SSH β remote
source downloads, remote destination uploads.
Keep numbered, hardlinked versions with --link-dest β each run writes the
next directory (1/, 2/, β¦).
rsync 3.1 or later must be on your $PATH β the preview needs
--itemize-changes, the progress bar needs --info=progress2 (added in
3.1.0) and snapshots need --link-dest. The Homebrew and AUR packages pull
rsync in; cargo install and cargo binstall do not.
On macOS 15.4 and later /usr/bin/rsync is openrsync, which has no
--itemize-changes and no --info at all, and accepts --link-dest without
reliably hardlinking. brew install rsync, then either put it ahead of
/usr/bin on $PATH or set rsync_path (see
Choosing which rsync runs). lazyrsync warns at
startup when the rsync it finds is older than 3.1.
cargo install lazyrsync # crates.io
cargo binstall lazyrsync # prebuilt release binary
brew install lazyrsync # Homebrew
yay -S lazyrsync # AUR (Arch)Or build from source:
cargo install --path .lazyrsync # launch the TUI- Press
]to switch to the Profiles sub-tab, thenato add a profile. - Back on Tasks (
]), pressato add a task: an ID, an Action (Sync β Snapshot withβ/β), a Source, and a Destination. Either path may be local or a remoteuser@host:/path. - Press
pto preview (dry-run) β you'll see the exact+/~/-changes and stats, and nothing is written. - Press
rto run it. Watch progress in the Runs panel; presscto cancel.
A task is just Source β Destination, exactly like the rsync command line β
no push/pull, no separate "remote" field. A trailing / on the Source copies
its contents; without it, the folder itself is copied.
Every profile you build in the TUI also runs without it, so the transfer you verified by hand is the exact one your scheduler runs at 2am.
lazyrsync list # profiles, task ids, resolved rsync commands
lazyrsync run backups # every task in the profile
lazyrsync run backups/photos-3f2a # a single task, by id from `list`
lazyrsync run backups -n # real dry run, changes nothing
lazyrsync run backups --yes # required if any task uses --delete
lazyrsync run backups -v # add rsync's own output for each taskFlags compose freely β lazyrsync run backups/photos-3f2a -n --yes is valid.
(list prints the command with the TUI's --info=progress2 flag; a headless
run drops it, since there's no progress bar to feed. A headless -n also drops
--stats, which only the TUI's preview parser reads β you get rsync's itemized
diff without the fourteen-line statistics block after every task.)
A real run passes rsync -q, so you get one line per task and nothing
else β rsync still prints its errors, which is the only chatter worth mailing.
-v/--verbose opts back into the full log per task. A dry run is never
quiet: the itemized diff is the whole point of -n, so -n shows it with or
without -v.
Why not just point cron at rsync directly? For a plain Sync you could. A
Snapshot you can't: the numbered destination directory and the
--link-dest chain are computed at run time by scanning the destination, so
the command differs on every run β 1/ links against nothing, 2/ links
against 1/, and so on. No static crontab line can express that.
lazyrsync run resolves it fresh each time.
Tasks run in the order lazyrsync list shows them, which is not the order
they appear in profiles.toml β the loader sorts by recency. Check list
before you schedule anything that assumes an order.
A failing task doesn't stop the rest. Every task gets its turn, then you get a summary and a non-zero exit code β so one broken source leaves less data unprotected than aborting the batch would.
| Exit | Meaning |
|---|---|
| 0 | every task succeeded β or the profile has no tasks, which says so on stderr |
| 1 | refused: a task uses --delete and --yes was absent; nothing ran |
| 2 | no such profile or task id, or the config is missing or failed to load |
| 3 | a task couldn't be started, or was killed by a signal |
| n | the first failing task's own rsync exit code |
rsync's exit 24 β source files vanished mid-transfer β counts as success.
rsync's own exit codes 1, 2 and 3 overlap these, so the status alone isn't
always conclusive; read the message. A refusal always says nothing ran
explicitly, and a task that never started ends its line with exit 3.
Successful task lines and the success summary go to stdout. Failed task lines and the summary-when-something-failed go to stderr. So:
lazyrsync run backups >/dev/nullis completely silent when every task succeeds, and produces output only when something needs your attention β which is what makes cron's mail-on-output behaviour useful instead of noisy.
Each task gets one styled line: a counter, β/β, the task's label, and then
elapsed time if it worked or the exit code and the task id if it didn't β
the id being what you paste back into lazyrsync run backups/<id> to retry
just that one. Colour is dropped when the stream isn't a terminal, or when
NO_COLOR is set β cron mail and journald get plain text.
A run where one task fails looks like this:
[1/3] β Documents 1.5s
rsync: [sender] change_dir "/mnt/camera" failed: No such file or directory (2)
rsync error: some files/attrs were not transferred (see previous errors) (code 23) at main.c(1347) [sender=3.4.3]
[2/3] β Photos exit 23 photos-3f2a
[3/3] β Music 0.0s
3 tasks: 2 ok, 1 failed
and like this under >/dev/null β which is exactly what cron mails you:
rsync: [sender] change_dir "/mnt/camera" failed: No such file or directory (2)
rsync error: some files/attrs were not transferred (see previous errors) (code 23) at main.c(1347) [sender=3.4.3]
[2/3] β Photos exit 23 photos-3f2a
3 tasks: 2 ok, 1 failed
lazyrsync run backups -n works on a profile containing --delete tasks
without --yes, because --dry-run changes nothing and no destination
directories are created. Preview first, then add --yes only to the command
you actually schedule.
The --delete gate reads a task's --delete and --delete-excluded
toggles. It does not parse the Advanced raw-args field, so a --delete
written by hand there gets past the gate β the same caveat the TUI carries.
30 2 * * * /usr/bin/lazyrsync run backups >/dev/nullAdd --yes only if the profile contains a --delete task.
Prefer this over cron: Persistent=true catches up a run missed while the
machine was off, and journald keeps the output.
# ~/.config/systemd/user/lazyrsync-backups.service
[Service]
Type=oneshot
ExecStart=/usr/bin/lazyrsync run backups
# ~/.config/systemd/user/lazyrsync-backups.timer
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.targetsystemctl --user enable --now lazyrsync-backups.timer
journalctl --user -u lazyrsync-backups # what the last run didBecause a task has its own address, each one can run on its own clock:
0 * * * * /usr/bin/lazyrsync run backups/docs-a91c >/dev/null
30 2 * * 0 /usr/bin/lazyrsync run backups/photos-3f2a >/dev/null- Use the absolute path. cron's
PATHis minimal and won't find a binary in~/.cargo/bin.command -v lazyrsynctells you what to write. - SSH keys must be passwordless. Remote tasks run under
ssh -o BatchMode=yes, so ssh fails fast instead of hanging on a prompt β but there's no ssh-agent under cron. Use a passwordless key, or set the task's SSH key file. - Don't expect a scheduled run to tell you what moved. It prints one line
per task and nothing else, so the exit code is the signal, and cron mails you
on failure by itself. Add
-vwhen you rerun by hand to debug.
Dated destinations need no extra flags β path fields expand {now:%Y-%m-%d},
{utcnow:β¦}, {hostname}, {user}, $VAR and ~ on every run, headless
included. See Dynamic paths.
Press ? in the app for the full, context-aware list. The essentials:
| Key | Action |
|---|---|
1β4, Tab |
Focus a rail panel (Runs / Tasks Β· Profiles / Flags / Filters) |
] |
Toggle the Tasks / Profiles sub-tab |
j/k, β/β |
Move the cursor |
space / enter |
Select the task (or toggle the highlighted flag) |
a |
Add a task (or profile, on the Profiles sub-tab) |
p |
Preview (dry-run) the selected task |
r / R |
Run the selected task / run every task in the profile |
e / s / i / x |
Edit Basics / SSH / Filters / Advanced |
d |
Delete (confirm first) |
V |
Visual range (multi-select), then r/d acts on the block |
c |
Cancel the running job |
/ |
Filter the list, or search the run output |
q / Esc |
Quit |
Profiles and settings live under $XDG_CONFIG_HOME/lazyrsync/ (typically
~/.config/lazyrsync/):
profiles.tomlβ your profiles and taskssettings.tomlβ preferences (theme, hints, confirmation prompts)
If XDG_CONFIG_HOME is unset, the path falls back to
~/.config/lazyrsync/profiles.toml.
The TUI writes this file for you, but it is a supported hand-editable format β useful for provisioning with Ansible or a dotfiles repo alongside headless runs. Every key below is shown with its default:
[[profile]]
name = "backups" # required
description = "nightly" # ""
[[profile.task]]
label = "photos" # required
source = "/home/me/Pictures/" # required
dest = "/mnt/nas/pics/{now:%Y-%m-%d}/"
id = "photos-3f2a" # auto-generated from label + source/dest
action = "sync" # sync | snapshot
[profile.task.flags]
archive = true
compress = true
verbose = true
human = true
progress = true
partial = true
delete = false # destructive β mirrors deletions to dest
delete_excluded = false # destructive β deletes excluded files at dest
backup = false
update = false
checksum = false
size_only = false
existing = false
ignore_existing = false
bwlimit_kbps = 0 # 0 = unlimited
hardlinks = false
acls = false
xattrs = false
[profile.task.filters]
excludes = []
includes = []
exclude_from = ""
include_from = ""
files_from = ""
filter = []
[profile.task.ssh]
port = 22
keyfile = ""
extra = ""
[profile.task.advanced]
raw_args = ""A profile holds one or more tasks; every section except [[profile]],
label and source may be omitted. lazyrsync also writes created and
last_files bookkeeping keys, which you can leave out.
source and dest accept the placeholders described under
Dynamic paths β {now:%Y-%m-%d}, {utcnow:β¦},
{hostname}, {user}, $VAR, ${VAR} and ~ β resolved on every run.
With action = "snapshot", dest is the parent directory: lazyrsync picks the
next numbered subdirectory and builds the --link-dest chain to the previous
one at run time, so each run keeps a hardlinked version. See
Snapshots.
Unknown keys are an error, not a warning. A misspelled key would otherwise
be dropped in silence and its default used in place β for excludes that means
running with no exclusions at all, which on a delete = true task mirrors away
everything you meant to skip. lazyrsync instead refuses to load the file and
names the offending key:
$ lazyrsync run backups
error: parsing /home/me/.config/lazyrsync/profiles.toml: TOML parse error at line 13, column 1
|
13 | exclude = ["node_modules/"]
| ^^^^^^^
unknown field `exclude`, expected one of `excludes`, `includes`, `exclude_from`, `include_from`, `files_from`, `filter`settings.toml is parsed under the same rule. One consequence for both: a file
written by a newer lazyrsync may fail to load on an older binary rather than
being partially ignored.
Every prompt has its own opt-out in settings.toml, all false by default:
| Key | Silences |
|---|---|
skip_delete_warning |
the alert shown when you enable a task's delete flag |
skip_run_confirm |
the confirmation shown before a run starts |
skip_remove_confirm |
the confirmation shown before removing a profile or task |
skip_run_confirm removes the last prompt before a transfer, including for
tasks that use --delete.
By default lazyrsync runs the first rsync on your $PATH. Point
rsync_path in settings.toml at a specific binary to override that:
rsync_path = "/opt/homebrew/bin/rsync"Useful on macOS 15.4+, where /usr/bin/rsync is openrsync and comes ahead of
Homebrew's rsync on $PATH β and in any launch context with a minimal
$PATH, such as cron. The configured binary is what the resolved command
shown in the TUI and lazyrsync list reports.
Source and destination paths can contain placeholders, resolved every time the task runs β so one saved task can write to a new dated folder each night:
| Placeholder | Expands to |
|---|---|
{now} |
today's date, 2026-07-27 |
{now:FORMAT} |
any strftime format, e.g. {now:%Y/%m/%d} or {now:%H%M} |
{utcnow}, {utcnow:FORMAT} |
the same in UTC |
{hostname} |
this machine's hostname |
{user} |
the current user |
$VAR, ${VAR} |
an environment variable |
~ |
your home directory |
dest = "~/backups/{hostname}/{now:%Y-%m-%d}/"Unknown placeholders, unset variables and a bare % are left exactly as typed,
and the dry-run preview always shows the resolved path before anything runs.
Braces and $ are escaped by doubling them β {{now}} is a folder literally
named {now}, and $$HOME a folder named $HOME.
See CONTRIBUTING.md for the build/test/lint commands, the module map, and the code + UI conventions.
- lazygit β the TUI whose keyboard-driven, panel-based workflow inspired this one.
- ratatui β the Rust TUI library lazyrsync is built on.
MIT.







