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 matchesdistat the transfer root. It will not matchpackages/web/dist/. - The pattern is treated as a file pattern, not a directory pattern.
node_modulescan match a path component, butnode_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$HOMEin 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/.
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.
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/, andcoverage/when those folders are generated.
Rules to review carefully
.git/if the backup must preserve unpushed local history.*.envif restore needs sample environment files.vendor/because it can be dependencies in one stack and source in another.*.sqliteif 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.
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
-avnihbefore 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-excludesis 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
~/Developeror~/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.
Related reading
- rsync exclude-from Mac — move long exclusion lists into a reusable file.
- rsync exclude multiple directories on Mac — combine generated-folder excludes safely.
- Rsync dry run on Mac — preview copy and delete behavior before running a real mirror.
- Mac copy folder exclude files — copy project folders without dependency junk.
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.