Warning
lilsync is experimental! There may be bugs that could DELETE or EXPOSE
your files. I use it, but use it at your own risk.
lilsync syncs a folder between a small, trusted group of nodes.
-
Designed for peer-to-peer sync between your own machines or other trusted peers.
-
End-to-end encrypted: peers talk over Noise-encrypted TCP connections authenticated by their Ed25519 node keys.
-
Works on any network where your nodes can reach each other directly — a LAN, a Tailscale tailnet, or a VPN. Peers are bootstrapped from join tickets (which embed the inviter's addresses) and keep each other's addresses up to date over the encrypted connections. There is no relay, NAT traversal, or central discovery service.
-
No central node: every node is equal. Each keeps a full copy of the synced folder and each can invite new nodes.
Note
See SECURITY.md for the full threat model and known limitations.
Download the archive for your platform from the
latest release, then install the
lilsync binary somewhere on your PATH:
tar -xzf lilsync-*.tar.gz
sudo install -m 0755 lilsync-*/lilsync /usr/local/bin/lilsyncRelease archives are named by version and target, for example
lilsync-0.3.2-aarch64-apple-darwin.tar.gz or
lilsync-0.3.2-x86_64-unknown-linux-musl.tar.gz.
On macOS, install with Homebrew:
brew install vrypan/tap/lilsyncInstall Rust 1.94.1 or newer, then build the release binary:
cargo build --releaseThe binary is target/release/lilsync. To install it:
sudo install -m 0755 target/release/lilsync /usr/local/bin/lilsyncStart the sync daemon in the background (creates a new single-node group on first run):
lilsync start /path/to/folderLogs are written to /path/to/folder/.lil/sync.log (10 MB rolling, one
backup). To stop the daemon:
lilsync stop /path/to/folderTo run in the foreground instead (useful for debugging):
lilsync watch /path/to/folderState is stored inside the synced folder under .lil/:
| File | Purpose |
|---|---|
private.key |
Node identity key |
port |
Persisted RPC listen port (reused across restarts) |
endpoints.json |
Last known peer addresses |
daemon.lock |
Exclusive process lock |
daemon.pid |
PID of the running daemon (written by start, removed by stop) |
sync.log |
Daemon log file (rolling, 10 MB cap) |
peers.json |
Known peers and group membership |
invites.json |
Pending invite tokens |
entries.bin |
Persisted entry index (survives restarts) |
lamport |
Persisted Lamport clock |
gc-watermark.bin |
Persisted tombstone garbage-collection watermark |
Generate an invite on the first node:
lilsync invite /path/to/folderThis prints a base62 ticket that embeds the inviter's node ID, a one-time
secret, and the addresses where the inviter is reachable (detected from its
local interfaces — including Tailscale/VPN addresses). Use
--endpoint <host:port> to override the detected addresses, e.g. with a DNS
name. On the second node:
lilsync join /path/to/folder2 <ticket>join completes the handshake and then starts the daemon in the foreground.
Use --exit to join and exit instead, then run lilsync start to launch the
background daemon.
lilsync start <folder> [--name <name>] [--poll] [--interval-ms <ms>]
[--announce-interval-secs <secs>] [--port <port>]
lilsync watch <folder> [--name <name>] [--poll] [--interval-ms <ms>]
[--announce-interval-secs <secs>] [--status] [--port <port>]
lilsync stop <folder>
lilsync status <folder>
lilsync invite <folder> [--expire-secs <secs>] [--endpoint <host:port>]...
lilsync join <folder> <ticket> [--name <name>] [--exit] [--port <port>]
lilsync peers <folder>
lilsync remove <folder> <id>
startforks into the background; logs go to<folder>/.lil/sync.log.watchruns in the foreground; logs go to the terminal.--status(watch only) shows a live colour peer-status view instead of log lines.--namesets a human-readable label shared with peers while the daemon runs.--polluses filesystem polling instead of native OS notifications.--interval-mssets the watcher debounce window (default 500 ms).--announce-interval-secssets how oftenSyncStateis broadcast (default 10 s).--portsets the RPC listen port. The port is persisted in.lil/portand reused on later runs, so peers keep a stable address for this node; without--portthe first run picks a free port. Each synced folder on a machine needs its own port.--expire-secssets invite lifetime (default 3600 s).--endpointoverrides the addresses embedded in the ticket (repeatable); useful when the joining node should connect via a specific address or DNS name.--exitmakesjoinstop after writing group state instead of starting the daemon.
Peers find each other in two steps, both over plain TCP:
- Bootstrap: a join ticket carries the inviter's addresses, so the joining node knows where to connect with no discovery protocol involved.
- Refresh: every node periodically gossips its listen port and interface
addresses to the other members over the encrypted RPC channel, and each
receiver also records the address it actually saw the sender connect from.
Last known addresses are persisted in
.lil/endpoints.jsonand reused across restarts.
This means lilsync works unchanged across a LAN, a Tailscale tailnet, or any routed network — no multicast/mDNS required. If every peer changes all of its addresses while the group is fully offline, they can no longer find each other; issue a fresh invite from one node to re-bootstrap.
Create .nolil inside the synced folder to exclude paths:
files/
*.tmp
build/
!build/keep.txt
Supported syntax (gitignore-like):
- blank lines and
#comments are ignored !negates a rule*,?,**wildcards- leading
/anchors to the root - trailing
/matches directories only
When .nolil changes, lilsync rescans the folder. Newly ignored paths stop
being tracked locally; they are not deleted on remote peers.
.lil/is always excluded from sync. Temporary files for in-flight transfers (recv-*) are stored there and removed on completion or error.- File content is streamed over encrypted TCP without buffering the whole file in memory; BLAKE3 hash is verified before the temp file is renamed into place.
- Up to 8 file downloads run in parallel per reconciliation pass.
- Periodic
SyncStatebroadcasts (default every 10 s) drive repair: any node with a different root hash initiates a Merkle tree sync. Filesystem-change announcements also include a small bounded tree hint to reduce follow-up RPCs. - Tombstones (records of deleted files) are persisted across restarts and garbage-collected after all active peers report the same state root. Nodes publish GC watermarks so stale tombstones are not accepted again later.
- Some OS metadata files are always ignored:
.DS_Store,Thumbs.db,Desktop.ini,._*,.Spotlight-V100,$RECYCLE.BIN,lost+found. - Empty directories are not tracked. If the last file in a directory is deleted, the empty directory is removed on peers.
See deploy/systemd/README.md for running
lilsync under systemd, including named instances for syncing multiple
folders.
Use lilsync watch to keep the daemon in the foreground. Log verbosity is
controlled via RUST_LOG (default: info):
RUST_LOG=info lilsync watch /tmp/node-a
RUST_LOG=debug lilsync watch /tmp/node-aWhen running as a background daemon (lilsync start), logs are written to
<folder>/.lil/sync.log. RUST_LOG is still respected.