Justfile CLI for rsync backups on external SSD

Easy backups of the folders you need

rsync
just
bash
CLI
This is a walkthrough of my choices underlying the justfile I wrote using rsync for backups on my external T5 SSD formatted as exFAT.
Author

Daniel S. Mazhari-Jensen

Published

July 23, 2026

Backups - the problem:

Backups are important but too often shrouded in anxiety and manual error prone workflows. I have spent hours checking manual backup procedures, which became a outright dread and a task I’d postpone.

The most common way to backup data is manually dragging and dropping files from a source (e.g., your computer or phone) to a destination (e.g., an external hard drive or other computer).

While common, manual drag-n-drop has several cons:

  1. Manual errors from “slips” or “typos”
  2. Needs deleting and complete re-copy for updates
  3. No helper for iterative backup (which files have changed?)
  4. Minimal feedback (what happened: files, memory, deletions etc.)

In this blog post, I’ll thoroughly document my thoughts and decisions on moving to a command-line interface (CLI) for my ad-hoc backup of data.

Commercial solutions exists

There are of course solutions out there - but they are not for free…

Carbon Copy Cloner (CCC) seems like a great solution, however, it’s not for free. As I haven’t tried it, I cannot recommend it. But it seems as the solution I would pick from the market at the moment of writing. Note that it only works for MacOS. Check out CCC.

Time Machine on Macs is of course the native and simple solution. However, the TM algorithm currently backs up the entire OS which requires significantly more memory and time than just backing up a couple of folders that you might need. Besides, many people find themselves with the unhappy surprise that TMs often copy the OS bug as well and you’ll have to try-and-error when the bug first occurred. check out TM.

rsync - the CLI solution

Luckily for us, Linux introduced a command-line utility that solved this problem: rsync. First released in 1996, rsync (remote synchronization) is still actively maintained today. Written in C, it was designed to efficiently synchronise files and directories between two locations while transferring only the data that has actually changed. This delta transfer algorithm dramatically reduces bandwidth usage and makes rsync an excellent choice for backups, mirroring, and remote file synchronisation. Over the years, it has become one of the most trusted and widely used tools in the Unix and Linux ecosystem, and it remains the foundation for many backup and deployment workflows.

As a vertue of it’s +30 year long life, rsync has a robust and versetile set of options that can be called using flags. Below is a list of the flags I’ll be covering in this blog.

Flag Meaning
-r Recursive mode. Copies directories and their contents recursively.
-l Copies symbolic links as symbolic links instead of copying the files they point to.
-p Preserves file permissions.
-t Preserves file modification times (timestamps).
-g Preserves group ownership.
-o Preserves file owner (requires appropriate privileges, typically root).
-D Preserves device files and special files (--devices --specials).
-a Archive mode (-rlptgoD). Recursively copies files while preserving symbolic links, permissions, ownership, group, device files, and timestamps.
-v Verbose output. Shows the progress of the sync process.
-h Human-readable output. Formats file sizes in KB, MB, GB instead of raw bytes.
--dry-run The most important flag. Simulates a sync without making any changes. Great for testing.
--delete Deletes files in the destination that no longer exist in the source. Use to make the destination mirror the source.
--progress Displays progress information for each file being transferred.
--info=progress2 Displays overall transfer progress instead of per-file progress.
--stats Prints a detailed summary of the transfer, including bytes sent, speed, and number of files transferred.
--modify-window=1 Allows timestamps to differ by up to 1 second when comparing files. Useful when syncing with filesystems that have lower timestamp precision (e.g., FAT or some network filesystems).
--exclude Skips files or directories that match the specified pattern.
--exclude-from=rsyncignore.txt Reads exclude patterns from the file rsyncignore.txt instead of specifying them on the command line.

So in practice, it’s as easy as:

zsh
rsync -a my_source my_destination/

As seen from the table, the -a (archive) flag is a shorthand for the seven flags -rlptgoD. This is the standard format for UNIX/linux/mac native formatting, which brings us to the next section.

openrsyn - rsync, licenses and recent AI-slop controversies

In 2006, rsync 2.6.9 released under the GPL 2.0 license for the last time, with newer versions using the somewhat more restrictive GPL 3.0 license. This restricting Apple from using any newer version. Therefore, rsync 2.6.9 was shipped on Macs for several years without updates. Eventually, in 2024, Apple switched to openrsync — a a free, BSD-licensed alternative implementation of the classic file synchronization tool rsync. openrsync is compatible with rsync 2.6.9, but lack some of the newer features. It is actively maintained and is capable of everything we’ll be covering in this blog.

Due to serious AI-slop criticism on the rsync project, I’ve deliberately decided on using openrsync instead of the original rsync.

I’m running Mac 26.5.2 with openrsync: protocol version 29:

zsh
rsync --version 
openrsync: protocol version 29 
rsync version 2.6.9 compatible

Cross-platform compatibility and the exFAT format

Although I prefer UNIX-like systems, I also value cross-platform compatibility for storing my data. Therefore, I used exFAT for my Samsung T5 external SSD (2T memory). See a more complete discussion on NTFS vs. exFAT vs. APFS.

https://www.fortuneportech.com/2025/05/06/best-file-systems-for-ssds/

exFAT is great for many reasons:

  1. cross-platform compatibility
  2. unlike the older FAT format, it doesn’t have the 4GB size limits on individual files
  3. Optimized for SSDs, minimizing wear and tear

But as always, there is no free lunch:

  1. permissions, commonly used by UNIX systems, are not supported
  2. the format is not native to either Windows, Linux, or Mac

Therefore, we need to adjust our rsync flags in order to safely copy our data.

Instead of -a, it’s therefore recommended using -vrltD and omitting -gop. As seen from the flag table, -gop is for permissions (chmod bits), group ownership, and user ownership (requires root). As these are not supported by FAT or exFAT, we will have to skip them.

Note that this is a limitation of cross-platform compatibility and not recommended for pure Mac (APFS) or Linux system compatibility.

Fixing timing issues with windows

It will come as no surprise to most Windows users that Windows has some serious timing issues.

The --modify-window=1 flag is essential for exFAT. exFAT rounds timestamps differently than Linux file systems. This flag allows a 1-second tolerance, preventing rsync from repeatedly re-copying files that are actually unchanged.

Quoted below from man rsync:

Specifying 1 is useful for copies to/from MS Windows FAT filesystems, because FAT represents times with a 2-second resolution (allowing times to differ from the original by up to 1 second).

What we have now:

  • The rsync -a (archive) flag is a shorthand for -rlptgoD:
  • exFAT does not support permission meta-data, so we remove -pgo and retain the -rltD flags. -pgo is
    • Permissions (chmod bits)
    • Group ownership
    • User ownership (requires root)
  • Windows and it’s FAT formats have lower timing resolution, so we add the --modify-window=1 flag
  • we also want more verbose output, so we add -v

We now have:

zsh
rsync -vrltD --modify-window=1 my_source my_destination/

Final shinanigans from Mac

Apple use a file format called AppleDouble. AppleDouble stores finder meta-file information or extended attributes in a file named ._myfilename. While useful and harmless on a Mac, it’s simply clutter on other OS and formats (as you get 2x files you’d expect with no added value). These .AppleDouble will be created every time you use rsync to backup to your exFAT system. However, since we mirror backup, and the source doesn’t contain these .AppleDouble files, we continuously prune them from the SSD. It’s a small quirk that is relatively simple to manage. If you really want to get rid of these files, and you want to do it manually, you can use the terminal utility dot_clean. It merges hidden ._* files with their native files and clean up unwanted dot-underscore clutter from directories or external drives. This might be appropriate to do on the SSD before dumping all content to a non-Mac.

Making a justfile CLI

We’re gonna use just, a command runner that easily saves and runs project-specific commands.

just is an amazing way to make a CLI fast and without any unnecessary wrapper code. It’s not really a program, but rather a mapping of code snippits executed from the commandline. The power lies in it’s ability to host a full workflow with its own tests, helper functions, and user-facing functions. I’m not gonna cover just in this blog.

Adding style to it

First, we make variables for color coding our echo text. This is pure aesthetics.

justfile
# Color codes
GREEN := '\033[0;32m'
RED := '\033[0;31m'
YELLOW := '\033[1;33m'
BLUE := '\033[0;34m'
NC := '\033[0m' # No Color

Config of the setup

Next, we make some configurations specific to our setup.

justfile
# Configuration
SOURCES := '$HOME/code/ $HOME/uni-non-code/'
DEST := '/Volumes/Samsung_T5/backup-ssd'
RSYNC := '/usr/bin/rsync'
RSYNC_BASE_FLAGS := '-vrltDh --delete-after --info=progress2 --stats --modify-window=1 --exclude-from=rsyncignore.txt'
RSYNC_DRY_FLAGS := '{{RSYNC_BASE_FLAGS}} --dry-run' # all base flags + dry-run!

We hardcode variables:

  • SOURCES — the folder(s) we want backup
  • DEST — the place to backup and store it all
  • RSYNC — where rsync is (usr/bin, brew, or other)
  • RSYNC_BASE_FLAGS — all the flags we want to use
  • RSYNC_DRY_FLAGS — adding --dry-run to our base flags

Note that the base flags are:

  • -v — Verbose
  • -r — Recursive
  • -l — the
  • -t — Timestamp
  • -D — the
  • -h — Human-readable
  • --delete-after — Mirror function with deletion after copying (requires more space).
  • --info=progress2 — Progress info on entire backup. Note this doesn’t work in openrsync as it was added after v3.
  • --stats — Memory and time feedback
  • --modify-window=1 — more loose timing for FAT format to work
  • --exclude-from=rsyncignore.txt — exclude pattern for files

The rsyncignore.txt file is a simple list of all patterns to ignore.

These include .DS_Store, .quarto, *.tmp and .httr-oauth etc.

A help command

It’s good practice to provide overview by writing a help command. In this case, we just list the four just commands in the terminal.

justfile
help:
  @echo "Backup to Samsung T5 SSD - Available commands:\n"
  @echo "  just help      - Show this help message"
  @echo "  just preview   - Dry run: show what would be synced (no changes)"
  @echo "  just backup    - Run the mirror backup (with deletion confirmation)"
  @echo "  just status    - Quick health check"

As we just use echo, theres no need for me to explain further.

The status command

The status command is the simplest of the three. Here, we just check whether the external SSD is mounted or not.

justfile
status:
  @echo "{{BLUE}}=== Backup Status ==={{NC}}"
  @if [ -d "{{DEST}}" ]; then \
    echo "{{GREEN}}Destination exists: {{DEST}}{{NC}}"; \
  else \
    echo "{{RED}}Destination NOT found: {{DEST}}{{NC}}"; \
    echo "{{YELLOW}}Please ensure Samsung T5 SSD is connected and mounted.{{NC}}"; \
    exit 0; \
  fi
  @if [ -d "/Volumes/Samsung_T5" ]; then \
    echo "{{GREEN}}Samsung T5 is mounted at /Volumes/Samsung_T5{{NC}}"; \
    DF_OUTPUT=$(df -h /Volumes/Samsung_T5 2>/dev/null | tail -1); \
    echo "{{BLUE}}$DF_OUTPUT{{NC}}"; \
  else \
    echo "{{RED}}Samsung T5 is NOT mounted{{NC}}"; \
  fi

The function has two if-else statements:

  • Feedback on whether the destination directory within the SSD exists (-d "{{DEST}}") using echo.
  • Feedback on the memory state and space left on the SSD, if it’s mounted (-d "/Volumes/Samsung_T5"). We get this feedback using df -h /Volumes/Samsung_T5 2>/dev/null.

Here’s the breakdown of the last code call:

  • df displays filesystem disk space usage.
  • -h formats the sizes in a human-readable way (e.g. 500G, 1.2T).
  • 2>/dev/null Redirects stderr (file descriptor 2) to /dev/null, effectively hiding any error messages. For example, if the drive isn’t mounted, df would normally print an error, but this suppresses it.

The preview command

We’ve already covered most of this command. It looks like this:

justfile
preview:
  @echo "{{YELLOW}}=== DRY RUN: No changes will be made ==={{NC}}\n"
  @{{RSYNC}} {{RSYNC_DRY_FLAGS}} {{SOURCES}} {{DEST}}/
  @echo "{{BLUE}}\nPreview complete. Use 'just backup' to execute.{{NC}}"

So we basically call 4 variables that define our rsync, flags, sources, and destination: {RSYNC} {{RSYNC_DRY_FLAGS}} {{SOURCES}} {{DEST}}. Easy piecy!

The backup command

justfile
backup:
  @echo "{{YELLOW}}Checking for changes...{{NC}}"
  @DRY_OUTPUT=$({{RSYNC}} {{RSYNC_DRY_FLAGS}} -i {{SOURCES}} {{DEST}}/ 2>&1); \
  HAS_DELETIONS=$(echo "$DRY_OUTPUT" | grep -q '^\*deleting' && echo "yes" || echo "no"); \
  if [ "$HAS_DELETIONS" = "yes" ]; then \
    echo "{{RED}}WARNING: Deletions detected in dry run!{{NC}}"; \
    echo "$DRY_OUTPUT"; \
    echo "\n{{RED}}Files will be DELETED from the backup destination.{{NC}}"; \
    read -p "Continue with backup? [y/N] " -n 1 -r; \
    echo; \
    if [ "$REPLY" != "y" ] && [ "$REPLY" != "Y" ]; then \
      echo "{{YELLOW}}Backup cancelled.{{NC}}"; \
      exit 0; \
    fi; \
  else \
    echo "{{GREEN}}No deletions detected.{{NC}}"; \
    echo "$DRY_OUTPUT"; \
    read -p "Continue with backup? [Y/n] " -n 1 -r; \
    echo; \
    if [ "$REPLY" = "n" ] || [ "$REPLY" = "N" ]; then \
      echo "{{YELLOW}}Backup cancelled.{{NC}}"; \
      exit 0; \
    fi; \
  fi; \
  {{RSYNC}} {{RSYNC_BASE_FLAGS}} {{SOURCES}} {{DEST}}/ && \
  echo "{{GREEN}}Backup completed successfully!{{NC}}" || \
  echo "{{RED}}Backup failed!{{NC}}"

1. Dry-run test

```bash filename=“just” DRY_OUTPUT=$({{RSYNC}} {{RSYNC_DRY_FLAGS}} -i {{SOURCES}} {{DEST}}/ 2>&1)

HAS_DELETIONS=\(( echo "\)DRY_OUTPUT” | grep -q ‘^*deleting’ && echo “yes” || echo “no” ) ```

Here’s what each part does:

  1. DRY_OUTPUT=$(...)

Executes the rsync command (with placeholders like {{RSYNC}}, {{RSYNC_DRY_FLAGS}}, etc.). 2>&1 redirects standard error to standard output so all output is captured. The entire output is stored in the DRY_OUTPUT variable.

  1. grep -q '^\*deleting'

Searches the captured output for lines beginning with *deleting. -q makes grep quiet so it only returns an exit status.

  1. ^\*deleting matches rsync’s itemized deletion lines, for example:
*deleting   old-file.txt
  1. && echo "yes" || echo "no"

If grep finds a match (exit status 0), HAS_DELETIONS is set to “yes”. Otherwise it is set to “no”.

2. Feedback on deleted files

justfile
read -p "Continue with backup? [y/N] " -n 1 -r

Here’s what each option does:

  1. -p "Continue with backup? [y/N] "

Displays the prompt before reading input. The [y/N] convention indicates that No is the default if the user simply presses Enter.

  1. -n 1

Reads exactly one character without requiring the user to press Enter (on most terminals).

  1. -r

Prevents backslashes () from being treated as escape characters, so the input is read literally.

The character entered is stored in the default variable REPLY unless another variable name is provided.

3. exit or backup

Depending on the user response, the script either

  • exit 0;: terminates the program early or
  • {RSYNC} {{RSYNC_BASE_FLAGS}} {{SOURCES}} {{DEST}}/ &&: performing the backup.

The && at the end means the next command will run only if this rsync command succeeds (returns an exit status of 0).If the backup completes successfully, execution continues with the next command in the script. If rsync encounters an error (such as a missing source directory or permission issue), the chain stops at this point because of the && operator.

Good to go

There you have it.

We have:

  1. Discussed the issue of backups and why you should use code for this tedious task
  2. Discussed the pros and cons of rsync and openrsync
  3. Cross-platform compatibility using exFAT
  4. Timing issues in FAT formats
  5. Walkthrough of justfile for personal backup CLI using rsync

All code can be found in the Github repo.

What I read in order to write this blog: