> ## Content Index
> Fetch the complete content index at: https://sanghunkang.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Sync Claude Code Config Across Multiple Macs with Syncthing
- URL: https://sanghunkang.com/syncing-claude-code-config-across-multiple-macs-with-syncthing-en/
- Published: 2026-04-02T09:39:17.000Z
- Updated: 2026-07-22T02:41:06.000Z
- Author: Sanghun Kang
- Tags: dev, #en, #multi, mac

I use a MacBook Pro and a Mac Mini side by side. Both run Claude Code, but custom skills, agents, and memory files created on one machine don't exist on the other. Manually copying them every time is not an option.

I wanted to automatically sync the `~/.claude` folder between the two machines while also tracking changes with Git.

## What's inside \~/.claude

All Claude Code configuration lives in `~/.claude/`.

| Path                 | Description                        | Sync needed |
| -------------------- | ---------------------------------- | ----------- |
| CLAUDE.md            | Global instructions                | Yes         |
| settings.json        | Settings (hooks, MCP, permissions) | Yes         |
| memory/              | Global memory                      | Yes         |
| skills/              | Custom skills                      | Yes         |
| agents/              | Custom agents                      | Yes         |
| commands/            | Custom slash commands              | Yes         |
| projects/\*/memory/  | Per-project memory                 | Yes         |
| \*.jsonl             | Session logs (\~1.6GB)             | No          |
| cache/, debug/, etc. | Cache, temp files                  | No          |
| settings.local.json  | Machine-specific settings          | No          |
| .credentials.json    | Auth credentials                   | No          |

In my case, the entire folder was about 2GB, but only \~165MB actually needed syncing. The rest was session logs and cache.

### Why not sync sessions

Session logs (`*.jsonl`) and session data (UUID folders) are excluded from sync. The volume keeps growing, and I start and end dozens of sessions a day. There is no need to sync all of that. My workflow is already split: short-lived sessions run on the MacBook, long-running sessions run on the Mac Mini. And I certainly did not want my conversation history pushed to GitHub.

## Comparing sync options

### Dropbox

Cloud-based sync. Automatic and stable. But you'd need to move `~/.claude` into the Dropbox folder and create a symlink. This breaks Git version control since the symlink gets committed instead of the actual files. Free plan is limited to 3 devices. Idles at 200-400MB RAM.

### rsync

One-directional copy tool. You can automate it with cron, but bidirectional sync and conflict resolution need to be built from scratch. Essentially manual.

### Syncthing

P2P real-time bidirectional sync. Open source (70k+ GitHub stars). No limits on devices, storage, or bandwidth. Idles at 30-50MB RAM. Syncs `~/.claude` in place, so you can use Git in the same directory. No symlinks needed.

**Why Syncthing**: free, lightweight, syncs the original folder without symlinks, making it fully compatible with Git version control.

## Setup

### 1\. Install and start

On both machines:

```bash
brew install syncthing
brew services start syncthing

```

Access the WebUI at `http://localhost:8384`.

### 2\. Connect the two machines

1. On Machine A's WebUI, go to Actions > Show ID and copy the Device ID
2. On Machine B's WebUI, click Add Remote Device and paste the ID
3. Accept the connection request on Machine A

Adding from one side establishes a bidirectional connection.

### 3\. Configure the shared folder

On Machine A, click Add Folder:

- Folder Label: `claude-config`
- Folder Path: `~/.claude`
- Folder ID: `claude-config`

Save, then Edit > Sharing tab > check the other machine. Accept the share request on Machine B.

### 4\. Set up .stignore

`~/.claude/.stignore`:

```
// Session logs
(?d)*.jsonl

// Session data (UUID folders)
(?d)projects/*/????????-????-????-????-????????????
(?d)projects/*/????????-????-????-????-????????????.jsonl

// Projects that don't need memory/skill sync
projects/-Users-sh

// Cache and temp (no trailing slash: ignores the folder entry itself too)
(?d)cache
(?d)image-cache
(?d)debug
(?d)file-history
(?d)paste-cache
(?d)session-env
(?d)shell-snapshots
(?d)sessions
(?d)telemetry
(?d)tasks
(?d)downloads
(?d)ide
(?d)backups
(?d)plans

// Machine-specific
settings.local.json
.credentials.json

// System
.DS_Store

// Git (root-anchored /: avoids clashing with the skills/*/.git submodule rule; no (?d) to protect the local repo)
/.git
(?d)skills/*/.git

// Build artifacts
(?d)**/node_modules

```

I occasionally run Claude Code from my home directory (`~`), which creates a project folder under `projects/` based on the home path. For example, if your username is `sh`, it creates `projects/-Users-sh`. I use this for quick, throwaway sessions, so there's no need to sync memory or skills for it. I excluded the entire project path.

> Note: Syncthing's `.stignore` does not support character ranges like `[0-9a-f]`. Use `?` wildcards for UUID patterns instead.

### 5\. Add Git version control

Initialize Git in the same `~/.claude` folder. The `.gitignore` is nearly identical to `.stignore`, with the addition of Syncthing metadata (`.stfolder/`, `.stversions/`).

```bash
cd ~/.claude
git init
gh repo create .claude --private --source=. --push

```

Skills that contain external git repos should be managed as submodules.

```bash
git submodule add https://github.com/garrytan/gstack.git skills/gstack

```

## Gotchas

### Never sync .credentials.json

I initially forgot to exclude `.credentials.json`. One machine's auth credentials overwrote the other's, and I got logged out. This file must be unique per machine. Exclude it from both `.stignore` and `.gitignore`.

### .stignore glob syntax differs from .gitignore

`.gitignore` supports character ranges like `[0-9a-f]`, but `.stignore` throws a parse error on them. Use `????????-????-????-????-????????????` for UUID folder patterns.

### Skills may contain git repos

Some community skills are installed via `git clone`. Running `git add` on these triggers an "embedded git repo" warning. Register them as submodules, or exclude them in `.gitignore`.

## Result

When I install a new skill or add a memory file on one machine, it shows up on the other within seconds. Git tracks the change history and backs everything up to GitHub.

Maintenance is minimal. Occasionally check the WebUI at `localhost:8384`. After macOS updates, verify Syncthing is running with `brew services list`.

---

## Update 2026-05-02: Syncthing + git race conditions on the same file

After a month running this setup, one recurring problem showed up: editing files like `.stignore` or `.gitignore` would cause `git pull` to fail with "Your local changes would be overwritten by merge."

The mechanism is straightforward once you see it:

1. I edit `.stignore` on Machine A.
2. Syncthing notices the file change and propagates it to Machine B in seconds (file-level, FSEvents-based).
3. I commit on A and push.
4. On Machine B I run `git pull`. It errors. The working tree already matches the new commit's content (Syncthing got there first), but git's HEAD is still on the old commit, so git sees the new content as uncommitted local modifications.

In short, Syncthing and git are touching the same file at different layers and on different schedules. Race condition.

### Fix: take sync-rule files out of Syncthing entirely

Add this to `.stignore`:

```
// Manage these via git only (avoid Syncthing/git race)
.gitignore
.stignore

```

Now `.gitignore` and `.stignore` are git-only. Syncthing leaves them alone. Updates flow through `git push` / `git pull` exclusively. The race is gone.

This works because the problematic files are exactly the sync-rule files themselves. Self-referential, but harmless: `.stignore` ignoring itself means each machine keeps its own copy of `.stignore`, but they stay identical because git is the single source of truth.

### When NOT to apply this pattern

This is worth doing only for files where the Syncthing-vs-git race fires often. For most other files (skills, agents, scripts), occasional conflicts are rare and easy to clear:

```bash
cd ~/.claude

# Common case: local content already matches remote, just clear the diff
git checkout HEAD -- <file>
git pull

```

Or, if you genuinely want to keep your local edits:

```bash
git stash push <file>
git pull
git stash pop

```

### One footgun while you're recovering

`git reset --hard origin/master` will wipe **all uncommitted changes to git-tracked files**. Anything in `.gitignore` survives. If you've been mid-edit across several files when things tangled, commit (or copy aside to `/tmp`) before resetting.

## Update 2026-07-22: (?d) and trailing slashes, or why a folder won't leave sync

Two months in, I noticed one folder, `image-cache`, was stuck "out of sync," with Syncthing logging the same error every minute:

```
Failed to sync (path=image-cache error="syncing: delete dir: directory has been deleted on a remote device but contains ignored files (see ignore documentation for (?d) prefix)")

```

### Cause 1: a folder deleted on the remote + local ignored files = can't delete

I had deleted the `image-cache` directory on one machine. Syncthing tried to propagate that deletion to the other, but the local `image-cache` still held ignored files. **Syncthing refuses to delete a directory that contains ignored files** — it assumes they might be precious local data. So it retries the delete every cycle and fails.

The error message names the fix itself: the `(?d)` prefix. It means "when the parent directory is deleted, it's OK to delete these ignored files too." Safe to add for a regenerable cache.

```
(?d)image-cache

```

### Cause 2: a trailing slash only ignores the contents (the real trap)

There was a deeper issue. The original pattern was `image-cache/`, which expands to `image-cache/**` — meaning it **ignores what's inside the folder but not the folder entry itself**. So the empty folder shell kept getting tracked as part of the sync set.

I confirmed it: I dropped a file under `image-cache`, rescanned, and Syncthing's tracked file count (globalFiles) went from 183 → 184\. The contents (the png files, the test file) were ignored, but the folder entry entered the sync set.

Fix: **drop the trailing slash**. `image-cache` (no slash) matches the folder entry itself, so the whole folder leaves sync. Recreating the same file and rescanning left the count unchanged this time.

```
// With a slash: contents ignored, but the empty folder still syncs
image-cache/

// Without a slash: the folder entry is ignored too, folder fully leaves sync
(?d)image-cache

```

### Two caveats

**1\. `.stignore` is per-machine.** Syncthing does not sync `.stignore` itself (it's device-local by design). So the change above has to be made **on each machine separately** for the folder to leave sync on both sides. Fix only one and the other keeps tracking the folder.

**2\. `.git` is the exception.** I added `(?d)` to the cache folders but not to `.git`. `(?d)` authorizes deleting local files when a remote deletion propagates — put that on `.git` and Syncthing could wipe your local git repo. I also root-anchored it (`/.git`) so it doesn't overlap with the `skills/*/.git` submodule rule.

```
// Protect the local git repo: no (?d), root-anchored to stay separate from the submodule rule
/.git
(?d)skills/*/.git

```

In short: **to fully exclude a folder from Syncthing, drop the trailing slash**, and add `(?d)` to regenerable folders so a propagated remote deletion doesn't get stuck. Just don't put `(?d)` on something precious like git.