Introducing Synx: File Sync for Remote Development

Remote development always converges on the same shape, and if you've lived it you'll recognize it in one sentence: the machine you think on and the machine the work runs on are not the same machine.

The laptop is where you think. It has the editor whose keybindings live in your fingers, your theme, your fuzzy finder, your diff tool, your clipboard, your language server tuned exactly the way you like it, the AI agent you've pointed at this project. It's the only machine you're actually fast on.

The server is where the work happens. Sixty-four cores instead of eight. The RAM, or the GPU, or the one kernel your driver builds against, or the private network that can reach the database. It's the box where a release build finishes in ninety seconds instead of eleven minutes, where the test suite spreads across every core, where the container matches production because it's built from the same image.

Neither can do the other's job, and you don't want them to. You want to keep editing here and keep building there.

Which reduces the whole of remote development to one unglamorous question: how do the files get from one side to the other, continuously, in both directions, fast enough that you forget it's happening? Get that right and the setup disappears — you type, you hit save, you flip to a terminal that happens to be on another continent, and it builds. Get it wrong and you spend the day being a courier for your own source tree.


The answers that don't hold

Just work on the box. SSH in, open vim, and the problem evaporates — there's only ever one copy. Some people are genuinely happy here and I'm not going to argue them out of it. But the price is your entire local environment: your editor config, your extensions, your GUI git client, your clipboard, your screenshot tool, the AI assistant running on your own machine. And every keystroke is now behind the network. On fiber that's fine. On hotel wifi you feel each character land.

Mount the remote over the network. SSHFS turns the remote directory into a local path, which sounds like exactly what you want until you notice what's on the wire: every stat(). Your editor's file watcher, your language server's indexer, your git status — each walks thousands of paths, and each walk is a round trip. The reports are consistent: performance falls off a cliff the moment anything touches files in bulk, like a git checkout or an npm install. On macOS it's worse, because the FUSE story there has been awkward for years. The abstraction is lovely right up until latency makes it unusable.

rsync in a loop. The honest hack, and everyone has written it at least once: fswatch | rsync, or a while true; sleep 1. It's correct for one-way snapshots. Live, it degrades — either it fires on every keystroke and floods the link, or it batches and lags behind your saves. It's one-directional by design, so if you want generated files or build output to come back you run a second one, and now two processes with no shared notion of truth are racing over the same tree. People are right to be nervous: rsync with the wrong flag in the wrong direction deletes real work.

Let the IDE handle it. VS Code Remote-SSH and JetBrains Gateway solve this properly for the editor — a server component runs on the remote, the UI stays local. If your entire world is inside one IDE, this is a good answer. But the files still only exist on the far side, so everything outside the editor is blind: your terminal, your standalone git client, your local scripts, the coding agent running on your laptop. You've also bought into one vendor's remote protocol, reinstalled your extensions over there, and accepted that the remote has to be trusted enough to run them.

Mutagen. This is the one that got the model right: a real copy on both sides, deltas synced continuously, edit locally, execute remotely. It's good software and it earned its reputation. The friction people report with it is consistent, though, and it comes from the shape rather than the quality: it's a daemon and a session manager, not a command.

That shape has weight. There's a resident daemon on your machine plus an agent per endpoint, alive whether or not you're working, and the issue tracker has a long run of reports about it burning CPU during periods with no file changes at all, on large trees, and under WSL. It has weight on disk too: in the Docker workflow it's built for, your project exists twice, and DDEV's own docs tell you to watch the duplicate volume — over 5 GB is a warning, over 10 GB is critical.

Then there's the operational surface. You create sessions, list them, learn mutagen sync terminate — and learn that it can hang while staging files, with the folk remedy in the issue threads being to stop the daemon, delete the lock file, and start it again. Its safe-by-default conflict mode halts and waits for a human, which is the right call, but it means a session can quietly stop converging while you assume it's fine; there's a long-standing request for a dependable way to just tell whether a session is broken. On large trees the initial scan takes minutes before anything is live. The clearest signal of how much surface accumulated is that DDEV, which leans on Mutagen hard, eventually shipped a whole diagnostic subcommandddev utility mutagen-diagnose — to tell you why your sync is unhappy.

Then the ground moved. Docker acquired Mutagen in 2023. Mutagen Compose was deprecated outright — the docs say it plainly: "Mutagen Compose is now deprecated with the release of v0.18.0." Mutagen itself hasn't cut a tagged release since v0.18.1 in February 2025. The repository still takes the occasional dependency commit, so it isn't dead. It also isn't moving.

None of this is a scandal, and I want to be fair about it: that weight is simply what a general-purpose sync engine costs once it has to serve local, SSH, and Docker endpoints, on four operating systems, under an orchestrator. I just didn't need any of it. I needed a directory and a path.

So I went looking for something that was only that, and there wasn't one. Unison exists, and you re-learn its CLI every time you touch it. Syncthing exists, and it's built around devices and shared folders rather than one developer's source tree. Past that it's a long tail of half-finished GitHub projects. Nothing that was simply: this directory, that path, over SSH, right now, respecting my .gitignore — and then get out of my way.

So I wrote it.


What I wanted from it

The requirement list was short, and every line on it came from something that had annoyed me:

  • One command, not a session. No daemon to babysit, no status / terminate / lock-file dance. It runs while you want it, and ctrl+c means stopped.
  • Nothing resident. When I'm not syncing, there is no process. Nothing warming a cache, nothing scanning a tree, nothing to wonder about in Activity Monitor at 2am.
  • No config file. The two paths are the arguments. Nothing to commit to the repo, nothing to remember, nothing to get out of date.
  • .gitignore is the law. Your repo already declares what isn't source. target/, node_modules/, .venv/ — the directories that make every naive sync tool crawl — should never enter the manifest at all, in either direction.
  • Never lose data. A sync tool that deletes something you meant to keep is worse than no sync tool. Everything else is negotiable; this isn't.
  • Cheap on a tree that's already synced. The second run should cost a directory walk, not a full re-hash.
  • Just SSH. Your keys, your ~/.ssh/config, your ProxyJump. No new auth surface, no new port to open.

Read that list again and notice what isn't on it: containers, orchestration, network forwarding, Windows, a GUI, a config schema, a plugin system. Every one of those is a reasonable thing to want and every one of them is why a sync tool grows a daemon. Leaving them out is not modesty — it's the entire design. The tool stays small because the problem it accepted is small.

That's Synx: a single open-source Rust binary — version 0.1.2 today — that mirrors a local directory to a remote path over SSH and keeps the two in lockstep while you work. One process, running only while you're working, doing one job. It's early. It already does the job I needed it to do every day.


What Synx is

You run it on your local machine and point it at a remote target:

synx ./src dev@beefy:/srv/app/src

That's the whole interface. The same binary runs on both ends — locally it's the client, on the remote it runs in a hidden --agent mode that the client launches over SSH for you. There's no service to install, nothing to register, no YAML.

It does an initial reconciling sync, then drops into a live watch where every change on either side ships to the other within a couple hundred milliseconds:

synx  /Users/dk/proj  ◀─▶  dev@beefy:/srv/proj
✓ connected
• manifests:  local 1243  •  remote 1180 (47 ignored)
• plan: push 78 files (4.2 MiB) 6 dirs 0 links  •  pull 14 entries
✓ initial sync: 4.2 MiB sent, 312 KiB received in 1.4s
• watching for changes — ctrl+c to stop
  → src/main.rs  3.1 KiB
  ← README.md   824 B

It runs on macOS and Linux. Transport is plain SSH — your keys, your agent, your ~/.ssh/config, your ProxyJump, all of it. Synx doesn't invent a new auth scheme; it borrows the one you already trust.


Sync modes and conflict handling

Direction is a single flag. The default is two-way.

Mode Direction Conflict rule Initial sync
push local → remote local always wins sends local-only or differing files
pull remote → local remote always wins fetches remote-only or differing files
both (default) bidirectional newer mtime wins merges, no deletions
# two-way (default)
synx ./src dev@host:/srv/app/src

# one-way push
synx ./build host:/var/www --mode push

# one-way pull
synx ./nginx host:/etc/nginx --mode pull

I'll be honest about the conflict model because it matters: in both mode, when the same file changed on both sides, the newer modification time wins. That's it. There's no three-way ancestor-aware merge, no conflict markers. This is reliable exactly as long as both machines' clocks are sane — so if you see files ping-ponging, the first thing to check is clock skew (NTP fixes it), and if you can't trust the clocks, pick an explicit push or pull direction.

The other deliberate choice: the initial sync never deletes anything. If you point Synx at a stale remote path, it won't wipe data — it merges. Deletions only propagate once Synx is live and watching, and only when it has a baseline — a recorded snapshot of what both sides last agreed on. The baseline lives in your cache directory and lets Synx tell the difference between "this file was deleted" and "this file simply never existed on the other side." A fresh run with no baseline keeps everything; deletions start propagating from the second sync onward. That asymmetry is on purpose. Losing data to an overeager sync tool is the one failure mode I refuse to ship.


Ignore rules are authoritative

Synx loads every .gitignore under your sync root — nested ones at any depth — plus an optional .synxignore with identical syntax. Anything that matches is never synced, in either direction. This isn't a best-effort filter applied at the source; it's enforced at three points:

  1. The initial walk — ignored files never enter the manifest in the first place.
  2. The remote manifest — files the remote reports that match your local ignore rules are filtered out before the diff plan runs, so a target/ or node_modules/ that happens to exist on the remote never gets pulled down.
  3. Live events — both incoming applies and outgoing notifications skip ignored paths.

One thing that surprises people: dotfiles are not special. .env, .vscode/, .git/ — all synced like anything else unless you exclude them. If you don't want your .git/ directory mirrored, say so:

echo '/.git' >> .synxignore

Speaking of .git/ — Synx is careful around it. Git treats that directory as transactional state, and atomically renaming half-written objects or lock files onto the peer mid-commit corrupts the repo. So Synx watches for git's in-progress markers (index.lock and friends) and pauses syncing of .git/ paths while a git operation is running, replaying the deferred changes once git is done. Your working tree keeps syncing throughout; only .git/ waits. If you're going to mirror a live repo, you want this.

There's a guard on the other direction too. If your local .git/ is left over from an interrupted operation and the remote has none, Synx mirrors that removal only when the baseline proves .git/ was part of the last state both sides agreed on. Without that evidence, your .git/ is simply data that was never synced — so Synx keeps it and pushes it instead.


How it works under the hood

┌─ local (client) ──────────────┐         ┌─ remote (agent) ─────────────┐
│  watcher (notify)             │   ssh   │  watcher (notify)            │
│  parallel walker (blake3)     │ ◀─────▶ │  parallel walker (blake3)    │
│  persistent hash cache        │  stdio  │  persistent hash cache       │
│  diff plan + executor         │ postcard│  message dispatcher          │
└───────────────────────────────┘  + zstd └──────────────────────────────┘

A few pieces worth explaining, because they're where the speed comes from.

Parallel hashing with a persistent cache. Both sides walk their tree in parallel (via the ignore crate's parallel walker) and hash every file with blake3. Nested ignore files are discovered inside that same walk rather than in a separate pre-pass, so startup costs one traversal instead of two — and the watcher is armed before the walk begins, so anything you save while it's still scanning is queued rather than missed. The hashes go into a persistent cache in your user-cache directory (~/.cache/synx/ on Linux, ~/Library/Caches/synx/ on macOS), keyed on (path, size, mtime). Re-running Synx on an unchanged repo skips rehashing entirely — the second sync of a hundred-thousand-file tree is bound by the directory walk alone, which is about a second. Cache reads are immutable and walker results are batched per worker, so there's no global lock in the hot loop, and a cache that didn't change is never rewritten to disk.

Delta transfer for big files. Small or changed-wholesale files go over the wire complete. But for files between 256 KiB and 256 MiB where the peer already has a different version, Synx does an rsync-style delta. It uses fast_rsync, a SIMD-accelerated port of librsync: the receiving side computes a signature of the file it already has, the sending side diffs against that signature, and only the changed blocks travel. Because librsync's internal block hashing predates modern cryptographic hashes, Synx verifies every delta-applied result against a fresh blake3 hash before committing it. The wire never gets to lie to you.

Compression and chunking. Messages are length-prefixed postcard (a compact, actively-maintained binary format) and zstd-compressed when compression actually saves space. Formats that are already compressed — archives, media, packages — bypass zstd on extension, so they don't burn CPU proving they can't shrink. Files above 16 MiB stream in 4 MiB chunks into a temp file, then get atomically renamed into place with their original mode and mtime preserved — so a crash mid-transfer never leaves a half-written file at the real path.

State-based echo suppression. This is the subtle part of any two-way sync. When Synx applies an incoming change, your local watcher is about to fire for that same path — and if you naïvely re-send it, you've got an infinite echo. The lazy fix is a time window ("ignore events for the next N ms"), which also drops legitimate edits the user made during that window. Synx instead records the resulting on-disk state (the mtime, or "deleted") and, when the watcher fires, compares the current file against what it recorded. Only a match is treated as an echo and dropped. If you edited the file in the meantime, the event flows through normally. There's no blind window where your keystrokes get swallowed.

Connection reuse. SSH runs with ControlMaster auto and a short persist, so multiple Synx invocations against the same host share one TCP connection instead of renegotiating.


Try it in five minutes

You need Synx on both ends — your machine and the remote. The one-liner is the fastest path on each:

# Linux & macOS, x86_64 + ARM64
curl -fsSL https://raw.githubusercontent.com/Muvon/synx/master/install.sh | sh

# or from crates.io
cargo install synx

If you already have a local release build, just copy it over:

scp target/release/synx user@host:~/.local/bin/synx
ssh user@host 'chmod +x ~/.local/bin/synx'

Then start a session:

# two-way sync, live
synx ./project dev@host:/srv/project

# see the plan, change nothing
synx ./project dev@host:/srv/project --dry-run

# initial sync only, then exit
synx ./project dev@host:/srv/project --once

A few flags you'll reach for:

# non-standard SSH port (or any extra ssh args)
synx ./code host:/work --ssh-opts "-p 2222 -i ~/.ssh/devkey"

# synx isn't on PATH on the remote
synx ./code host:/work --remote-synx ~/.local/bin/synx

# skip compression (faster on a fast LAN with incompressible blobs)
synx ./code host:/work --no-compress

# more logging
synx ./code host:/work -v     # debug
synx ./code host:/work -vv    # trace

If the remote can't find the binary, you'll get synx: command not found from the login shell — that's the --remote-synx case, not a bug. And both ends must run the same protocol version; 0.1.x speaks protocol v1 and is wire-incompatible with whatever comes next, so upgrade both sides together.


What it doesn't do yet

Synx is 0.1.2. I'd rather tell you the edges than have you find them.

  • No daemon mode. It runs in the foreground. Background it with &, or live in tmux/screen. A proper synx status / synx stop is on the list.
  • No three-way content merge. Deletions are baseline-backed, but file content conflicts are mtime-wins, not ancestor-aware. Sane clocks required.
  • The hash cache keys on (size, mtime). A file rewritten in place with an identical size and timestamp won't be re-hashed. It's the same heuristic git uses, and it's correct in practice.
  • Restart to pick up new ignore rules. Change a .gitignore mid-session and Synx won't notice until you restart it.
  • macOS and Linux only. Windows isn't supported.

None of these are hard blockers for the core use case — edit here, run there — which is exactly what I built it for and what it does well today.


Open source

Synx is on GitHub under Apache-2.0. It's Rust because file sync wants compile-time correctness, predictable latency, and no GC pauses in the middle of a transfer — and because a single static binary you can scp to a server is the right shape for a tool like this.

It's built by the same team behind our other developer tools; if you're curious why we keep shipping small, sharp, open-source utilities instead of one big platform, that's a longer conversation. Synx is the latest of them, and the one I personally use most.

If it's useful to you, the code is right there. If it breaks, the issue tracker is too — bug reports from real remote-dev setups are the fastest way to make 0.2 better than 0.1.

— Vladimir

Synx is open source under Apache-2.0. Get it, read the source, or file an issue at github.com/Muvon/synx.