rsync exclude not working on Mac: Developer Fix Guide

rsync exclude not working on Mac? Fix path roots, trailing slashes, exclude-from files, and developer backup filters.

Mac developer debugging an rsync exclude rule that still copies generated project folders

rsync exclude not working is usually not an rsync bug. On a Mac developer backup, it is more often a path-root mismatch, a pattern that is anchored to the wrong place, quoting that prevents ~ from expanding, or a rule that comes after an include rule. The fix is to make the transfer root explicit, test with a dry run, and write exclude patterns that match the paths rsync actually sees.

rsync exclude not working: check the transfer root first

The first thing to debug is not the exclude pattern. It is the source path. Rsync matches exclude rules against paths relative to the transfer root, and the transfer root changes when you add or remove the trailing slash.

# Copies the contents of my-app into the destination
rsync -avhn --exclude 'node_modules/' \
  ~/Developer/my-app/ /Volumes/DevBackup/my-app/

# Copies the my-app directory itself into the destination parent
rsync -avhn --exclude 'node_modules/' \
  ~/Developer/my-app /Volumes/DevBackup/

Both commands can be correct. The problem starts when the exclude pattern assumes one layout while the command uses the other. A pattern anchored as /node_modules/ means “a node_modules directory at the transfer root.” If your transfer root is the parent folder and the path appears as my-app/node_modules/, that anchored rule will not match.

For most developer backups, the safer default is the unanchored directory rule node_modules/. It skips directories named node_modules wherever they appear below the transfer root. Use an anchored rule only when you deliberately want to exclude a top-level directory but keep nested folders with the same name.

Why rsync exclude rules fail on Mac projects

Developer folders make exclude bugs visible because the folders you want to skip are huge. A missed node_modules rule can turn a small project backup into a transfer of hundreds of thousands of files. A missed .venv rule can copy platform-specific Python binaries into a cloud folder. A missed .git decision can either omit important local history or copy an object database you meant to keep out of the destination.

These are the common causes:

  • The pattern is anchored to the wrong root. /dist/ only matches dist at the transfer root. It will not match packages/web/dist/.
  • The pattern is treated as a file pattern, not a directory pattern. node_modules can match a path component, but node_modules/ is clearer when you mean the directory and its contents.
  • The shell did not expand the path to your exclude file. --exclude-from='~/Developer/.rsync-dev-excludes' passes a literal tilde. Use $HOME in double quotes or a full absolute path.
  • The rule order is wrong. Rsync filter rules are evaluated in order. An include rule can keep a subtree alive before a later exclude gets a chance to remove the files you expected to skip.
  • You are testing a different command than the scheduled one. A manual dry run from ~/Developer/my-app/ is not the same transfer root as a launchd job from ~/Developer/.
Rsync excludes match transfer paths, not Finder paths Source command ~/Developer/my-app/ Transfer paths look like: src/index.ts node_modules/react/ dist/app.js Problem pattern /my-app/node_modules/ Anchored to a parent that is not in the transfer path. Working pattern node_modules/ Matches that directory name wherever it appears. Start by deciding what the transfer root is, then write patterns against the paths rsync prints.
The same Finder folder can produce different rsync paths depending on the source argument. Exclude rules have to match those transfer paths.

Fix 1: use a dry run with itemized output

Do not debug excludes by watching the destination after a real copy. Use -n for dry run and -i for itemized output so you can see exactly what rsync would transfer.

rsync -avnih --delete \
  --exclude 'node_modules/' \
  --exclude '.venv/' \
  --exclude 'dist/' \
  ~/Developer/my-app/ /Volumes/DevBackup/my-app/

Now search the output for the paths you expected to skip:

rsync -avnih --delete \
  --exclude 'node_modules/' \
  ~/Developer/my-app/ /Volumes/DevBackup/my-app/ | grep node_modules

If grep returns nothing, the rule is doing its job. If it prints files, copy one printed path and compare it to your pattern. That path is the truth. For a monorepo, you may see packages/api/node_modules/, apps/web/.next/cache/, or services/worker/dist/. The right exclude set should match all generated folders you intend to skip, not only the one at the repository root.

Abstract visualization of rsync exclude rules missing generated project folders
When the transfer root and the exclude rule disagree, generated folders slip through the filter and the destination gets noisy fast.

Fix 2: use directory patterns that match developer folders

Start with explicit directory rules. They are boring, readable, and easy to test.

rsync -avhn --delete \
  --exclude 'node_modules/' \
  --exclude '.git/' \
  --exclude '.venv/' \
  --exclude 'venv/' \
  --exclude 'dist/' \
  --exclude 'build/' \
  --exclude 'coverage/' \
  ~/Developer/my-app/ /Volumes/DevBackup/my-app/

Do not use broad wildcards just to make the warning go away. A rule such as *cache* may skip framework caches, but it can also skip a real source folder with cache logic, test fixtures, or documentation. A rule such as *.env may protect secrets, but it may also omit .env.example if you write it too broadly. Keep the list specific enough that you can explain every line.

Good default excludes

  • node_modules/ for JavaScript dependencies.
  • .next/cache/, .turbo/, and .vite/ for frontend build caches.
  • .venv/, venv/, and __pycache__/ for Python projects.
  • dist/, build/, and coverage/ when those folders are generated.

Rules to review carefully

  • .git/ if the backup must preserve unpushed local history.
  • *.env if restore needs sample environment files.
  • vendor/ because it can be dependencies in one stack and source in another.
  • *.sqlite if local databases contain work that cannot be recreated.

Fix 3: move long rules into --exclude-from

Once the command grows beyond a few patterns, put the rules in a file. This makes the backup policy easier to review, reuse, and version.

# $HOME/Developer/.rsync-dev-excludes
node_modules/
.pnpm-store/
.yarn/cache/
.next/cache/
.turbo/
.vite/
.venv/
venv/
__pycache__/
.pytest_cache/
.ruff_cache/
dist/
build/
coverage/
.DS_Store

Then call it with an absolute path or $HOME inside double quotes:

rsync -avnih --delete \
  --exclude-from="$HOME/Developer/.rsync-dev-excludes" \
  ~/Developer/my-app/ /Volumes/DevBackup/my-app/

Avoid this version:

rsync -avhn --exclude-from='~/Developer/.rsync-dev-excludes' source/ dest/

The single quotes keep the shell from expanding ~. Depending on your rsync version and current directory, you may get a file-not-found error or quietly test a command that is not using the rules you think it is using. If a scheduled job runs under launchd, absolute paths are even more important because the environment is smaller than your interactive shell.

Fix 4: understand include and exclude order

If your command uses both --include and --exclude, order matters. Rsync applies the first matching rule. A common pattern for copying only certain file types is to include directories, include the wanted files, and then exclude everything else:

rsync -avhn \
  --include '*/' \
  --include '*.ts' \
  --include '*.json' \
  --exclude '*' \
  ~/Developer/my-app/ /Volumes/DevBackup/my-app-source-only/

If --exclude '*' comes before the include rules, rsync prunes the tree before it has a chance to find the files you wanted. If a broad include keeps a subtree alive, later excludes may not behave the way you expected. For normal developer backups, prefer the simpler model: copy the project, exclude generated folders, and inspect the dry run. Reach for include-only filters when you have a specific archival goal.

Fix 5: test macOS paths and cloud destinations

Mac paths often include spaces and cloud-provider directories. Quote source and destination paths. Do not rely on escaping by hand when a variable is clearer.

SOURCE="$HOME/Developer/my-app/"
DEST="$HOME/Library/Mobile Documents/com~apple~CloudDocs/Dev Backups/my-app/"
EXCLUDES="$HOME/Developer/.rsync-dev-excludes"

rsync -avnih --delete \
  --exclude-from="$EXCLUDES" \
  "$SOURCE" "$DEST"

Remember that rsync can finish before iCloud Drive, Dropbox, Google Drive, OneDrive, or Box finishes uploading the destination. If you copy a filtered project into a cloud-synced folder, the cloud client still has its own queue afterward. Keeping generated folders out of that destination is the whole point: the cloud client should see source files, lockfiles, documentation, and configuration, not a dependency forest it has to index one file at a time.

Clean filtered rsync workflow sending only source files to a Mac backup destination
The clean version is small and boring: source files pass through, generated folders stay local, and the destination remains easy to inspect.

When LSyncer is easier than debugging rsync excludes

rsync is still the right tool when you want a script, SSH transfers, exact flags, and reviewable command output. Keep using it if you already have logging, scheduling, mounted-volume checks, and restore tests under control.

If the job is “keep clean local project backups on a Mac,” a focused app can be simpler than another private shell script. LSyncer is built for developer folder sync: choose source and destination folders, skip generated directories such as node_modules, .git, virtual environments, build output, and caches, then run the sync on demand or on a schedule. It is a native macOS app with visible status, and it is a one-time $19.99 App Store purchase, not a subscription.

The underlying habit is the same either way: keep active work local, make dependency folders rebuildable from lockfiles, and sync the clean project state you would actually want to restore.

Best practices for rsync excludes on Mac

  • Use -avnih before every rule change. Dry run, verbose, itemized, human-readable output gives you enough detail to see mistakes before they change the destination.
  • Keep one shared exclude file. A reviewed .rsync-dev-excludes is less fragile than copying long command snippets between projects.
  • Write patterns against transfer paths. If the dry run prints packages/web/dist/app.js, make sure your pattern matches that path.
  • Be deliberate about .git/. Exclude it for clean working-tree backups. Include it when preserving unpushed commits and local branches is part of the backup goal.
  • Keep generated files out of cloud clients. Work in ~/Developer or ~/Code, then sync a filtered copy into iCloud Drive or another destination only if you need cloud availability.
  • Restore-test the backup. Copy it to a scratch folder and run npm ci, pnpm install --frozen-lockfile, pip install -r requirements.txt, or your stack's equivalent.

FAQ

Why is my rsync exclude not working on Mac?

The most common reason is that the pattern does not match the path rsync sees from the transfer root. Check the source trailing slash, avoid anchoring rules to the wrong parent directory, and run rsync -avnih so you can compare the printed transfer paths with your exclude patterns.

How do I exclude node_modules with rsync?

Use --exclude 'node_modules/' before the source and destination paths. For a longer developer backup policy, put node_modules/ in an exclude file and pass it with --exclude-from="$HOME/Developer/.rsync-dev-excludes".

Does a leading slash matter in rsync exclude rules?

Yes. A leading slash anchors the pattern to the transfer root. /dist/ matches a top-level dist folder in the transfer, while dist/ can match directories named dist deeper in the tree.

Should I use --exclude or --exclude-from?

Use --exclude for a short one-off command. Use --exclude-from when the list is long, reused across projects, or important enough to review as backup policy.

Why does --exclude-from='~/file' fail?

Single quotes prevent the shell from expanding ~. Use --exclude-from="$HOME/file" or a full absolute path such as /Users/you/Developer/.rsync-dev-excludes.