611 words, 4 min read

A naive rsync backup copies everything — including the megabytes of compiled assets, cached responses, and third-party dependencies you never actually need to restore. Here is how to make rsync smarter, without maintaining a separate exclude list.

The problem with a bare rsync

The simplest backup command looks like this:

rsync --delete -avh /src/ /dst/

It works, but it faithfully copies node_modules/ (often hundreds of megabytes), compiled caches, vendor trees, and log files that rotate anyway. These are not worth transferring, not worth storing, and not worth restoring.

Explicit excludes for the obvious offenders

The first fix is straightforward: pass --exclude flags for the directories you know you never want.

rsync --delete -avh \
  --exclude='logs/' \
  --exclude='node_modules/' \
  --exclude='vendor/' \
  --exclude='cache/' \
  /src/ /dst/

The trailing slash on each pattern matters: it matches directories specifically, not files that happen to share the name.

Honouring .gitignore automatically

The more interesting problem is everything your .gitignore already documents but rsync doesn't know about. Build outputs, compiled assets, secrets files — you've already told git to ignore them. Telling rsync separately is just duplication.

rsync has a --filter option that solves this cleanly:

# ':- .gitignore' means: merge each .gitignore as an exclude list
rsync --delete -avh \
  --filter=':- .gitignore' \
  --exclude='logs/' \
  --exclude='node_modules/' \
  --exclude='vendor/' \
  --exclude='cache/' \
  /src/ /dst/

The :- .gitignore rule is a dir-merge filter. As rsync descends into each directory, it reads that directory's .gitignore and applies its patterns as excludes — recursively, the same way git itself does.

Note: The --filter flag reads the contents of .gitignore files, not the .git/ directory itself. Git's implicit exclusion of .git/ is not part of any .gitignore, so those folders are included in the backup. Add --exclude='.git/' if you want to omit them.

Why keep the explicit excludes too?

node_modules/ and friends are almost always in .gitignore already, so in practice the explicit flags are redundant for most projects. But they act as a safety net: if you're backing up a directory that has no .gitignore, or an older project where someone forgot to gitignore the vendor folder, the explicit rules still fire. Belt and braces.

When --delete can't clean up after itself

Once you add --filter=':- .gitignore', you may see a warning like this:

rsync: not empty, cannot delete: ./some-project/.git
rsync: not empty, cannot delete: ./some-project

This happens when rsync's --delete flag tries to remove a directory from the destination that no longer exists (or is now excluded) on the source — but can't fully empty it first. The cause is the exclude rules themselves: rsync won't touch files it has been told to skip, so if a directory contains excluded files, rsync leaves those in place, and then can't remove the non-empty parent.

The fix is --delete-excluded, which tells rsync to also delete destination files that match an exclude rule:

rsync --delete --delete-excluded -avh \
  --filter=':- .gitignore' \
  --exclude='logs/' \
  --exclude='node_modules/' \
  --exclude='vendor/' \
  --exclude='cache/' \
  /src/ /dst/

Worth knowing: --delete-excluded cuts both ways. If you add a pattern to a .gitignore after a directory has already been backed up, the next run will remove those files from the destination too. For a mirror backup that should faithfully reflect the source, that is exactly the right behaviour — but it is worth being aware of.

The final script

#!/bin/bash

rsync --delete --delete-excluded -avh \
  --filter=':- .gitignore' \
  --exclude='logs/' \
  --exclude='node_modules/' \
  --exclude='vendor/' \
  --exclude='cache/' \
  "/path/to/source/" \
  "/path/to/destination/"

Two lines of flags, one principle: let the projects describe what's ignorable, and make the backup command listen.