One-liner: Continuous, automatic file sync for developers β like Dropbox, but it respects a
.devignore, refuses to leak secrets, runs pre/post hooks on sync events, keeps Git-like version history, and lets you mount shares (or sub-paths) across machines. It is not a Git replacement; it keeps your working directory live-synced across machines without committing half-done work.
Status: approved design, ready for implementation planning. Date: 2026-06-22. (Revised post-audit: scope tightened, secret-guard/metrics/bandwidth-cap promoted into v1.)
Dropbox/iCloud sync everything blindly β they choke on node_modules, leak .env secrets,
and thrash on build artifacts. Git is manual, commit-based, and not meant for continuously
mirroring an in-progress working tree across machines. There is no good tool that says: "keep
these dev directories live-synced across my machines, skip the junk, never leak my secrets,
run my commands when files land, and let me cherry-pick exactly what each machine gets."
devbox fills that gap: a self-hostable hub publishes named shares; devices join
the hub with a token and mount shares (or sub-paths) into any local directory, read-write
or read-only. A per-machine daemon watches every mount, filters via .devignore, syncs
deltas through the hub to the device's other machines in near-real-time, fires lifecycle
hooks, and keeps snapshot history per share.
Who it's for (v1): developers running multiple machines / homelabs who want their active workspaces mirrored without Git ceremony. Fits a Pi 5 cluster + MacBook + TrueNAS setup directly. Also the foundation of a future commercial hosted offering (see Β§13).
- Continuous, automatic, bidirectional sync of shares across linked machines.
- Shares + selective mounts: cherry-pick a whole share or any sub-path onto a machine, mapped to any local path, read-write or read-only.
.devignore(gitignore syntax) to exclude paths from sync entirely.- Default-on secret protection β built-in deny-list hard-refuses to upload matched secret
patterns even if
.devignoreis misconfigured. - Hooks: run user commands on sync lifecycle events.
- Version history (Git-like snapshots, per share) so any synced file can be rolled back.
- Self-hostable hub β single Go binary, runs on a Pi / TrueNAS, no SaaS dependency.
- Delta/chunked transfer + a bandwidth cap so big files and large trees stay fast and don't saturate a link.
- Never silently lose work. Conflicts never block and never destroy a byte.
- Hub observability β
/metrics+ a status page. - Fully cross-platform: Linux, macOS, Windows.
- Open-source (AGPLv3) core, with clean seams for a future commercial hosted tier.
- Not a Git replacement β no branching/merging semantics, no PRs.
- No TUI β
devboxwith no args prints rich text status. (Full TUI = future "someday" todo.) - No mobile/desktop GUI.
- No multi-user roles/teams/ACLs. Single-owner, multi-device. The only access knob is a per-device-per-share read-only bit (deploy boxes can't push).
- No interactive conflict resolver (mine/theirs/both/edit) β v1 lists conflicts; resolving by hand. Interactive resolver is v2.
- No re-share/delegation permission β v2.
- No content-level 3-way text merge β conflicts produce side-by-side copies (v2).
- No LAN peer-to-peer chunk exchange β v1 is hub-centric; P2P block exchange is v2.
- No client-side E2E chunk encryption (v2).
Everything in Go. One toolchain, static single binaries for daemon, CLI, and hub β
cross-compiled to linux/arm64 (Pi), darwin/arm64 (Mac), windows/amd64.
| Component | Tech | Why |
|---|---|---|
CLI + daemon (devbox, devboxd) |
Go, cobra CLI |
Static cross-compiled binaries, trivial Pi deploy |
| File watching | fsnotify, debounced rescan |
inotify / FSEvents / ReadDirectoryChangesW |
| Ignore matching | gitignore-semantics matcher | .devignore = gitignore syntax |
| Chunking / dedup | FastCDC content-defined chunking + BLAKE3 | Delta sync, dedup, content-addressed (manifests too) |
| Transport | WebSocket for change events + stateless HTTP for content-addressed blobs | WS handles live events; HTTP gives range/resume/caching for bytes for free. Same TLS endpoint + bearer token. |
| Hub metadata | SQLite (single-writer, WAL) | Zero-ops, fits homelab |
| Blob storage | BlobStore interface β disk CAS (v1), S3/R2 impl later |
Maps to TrueNAS now, object storage for hosted |
| Auth | Device Ed25519 keypair + bearer token; single rotatable join token | No passwords, device-revocable |
| Hooks runner | exec into bash (default, all OSes); .ps1 hooks auto-run via pwsh on Windows |
Portable hooks across the fleet; zero-config Windows opt-in |
Why not pure WebSocket? A single WS is one ordered stream β a big blob upload head-of-line-blocks event delivery, and WS has no range/resume/caching. Blobs over stateless HTTP (
GET /blob/<hash>, retry = re-request) is less code than reinventing resumable transfer over a socket. Only the WS needs reconnect logic.
- Hub β the server. Publishes shares, stores chunks + manifests (CAS) + metadata (SQLite), brokers change events. One owner, many devices. Self-hosted single binary.
- Device β a machine with an Ed25519 identity, joined to a hub. Runs one daemon.
- Share β a named top-level tree on the hub (
projects,repos,services). The unit of snapshot history. - Mount β a device-side binding:
share[/subpath] β localpath, read-write or read-only. A device has many mounts; one daemon watches them all. - Read-only bit β a per-device-per-share
writableflag on the hub (default writable). A non-writable device's pushes are rejected server-side β the only access control in v1. - Snapshot β an immutable per-share manifest version (id = BLAKE3 of manifest).
- Manifest β
path β [chunk hashes] + mode + sizefor a share at a point in time; itself content-addressed and stored in the CAS.
device (laptop) hub (Pi / TrueNAS) device (pi-07)
devboxd devbox-hub devboxd
β mount: projects/ βββ β shares: projects, βββββ mount: projects/p22/backend
β mount: repos/ ββββββ€ WS β repos, services β (read-only -> /var/www)
β (events) βββββββββββΌβββββββΊ β change log / HEAD WS βββ€ β hooks fire on pull
β (blobs) ββββββββββββΌβHTTPββΊ β CAS: chunks + manifests β
β <mount>/.devbox/ β β SQLite: snapshots, HTTPβ
hooks, .devignore ββββββββΊ β shares, devices,
β tokens, writable bits
β BlobStore: disk (v1) | S3/R2 (later)
β /metrics + status page
fsnotifyreports a change in a mount's local tree (debounced ~300ms).- Path checked against
.devignoreand the built-in secret deny-list; matches dropped. - Changed files chunked (FastCDC) β BLAKE3 β manifest diff vs last-known state.
pre-pushhook fires (can veto). New chunks uploaded over HTTP (only ones the hub lacks), subject to the bandwidth cap.- Hub appends a snapshot, advances that share's linear HEAD, broadcasts a change event over WebSocket.
post-pushhook fires locally.- Other devices with a mount on that share receive the WS event β
pre-pullhook β fetch missing chunks over HTTP β reassemble β atomic rename into place βpost-pullhook.
A read-only mount skips steps 3β6 (never pushes) but still does 7.
devbox -rw <share>[/subpath] [localpath] mounts a whole share or any sub-path to any local
path. Default localpath = ./<last-path-segment>. -r = read-only, -a/-rw = read-write.
devbox -a projects ./ # whole share, rw, here
devbox -r projects/p22/backend /var/www/p22/backend # one sub-path, read-only, elsewhere
A mount is recorded in ~/.config/devbox/daemon.toml (see Β§8).
v1 is single-owner, so there are no roles, no grants ACL, no flag-clamping subsystem. Every
enrolled device is rw by default. The one real need β deploy boxes must not push β is a
single server-enforced writable bit per (device, share):
- Set on the hub:
devbox-hub readonly <device> <share>β the hub rejects that device's pushes. - Server-enforced on purpose: a read-only deploy box cannot pollute canonical even if its local flag is wrong or the client is compromised. (A purely client-side flag would be a footgun.)
devbox publish <localdir> <name> β any writable device can create a share from a local folder
and push it. Admin (devbox-hub β¦) runs on the hub host (whoever has shell there); device
management isn't a device privilege.
A read-only mount pulls but never pushes. To still honor "never lose a byte": if an inbound
change would clobber a locally-modified file on a read-only mount, devbox first stashes the
local version as path.conflict-local-<ts>.ext (preserved on disk, never pushed), then applies
the inbound change.
The mechanism behind "Dropbox-easy, never loses data." Runs per share.
- Each share has one linear HEAD on the hub. SQLite single-writer serializes concurrent pushes for free.
- A push declares the snapshot it was based on (
parent_id).parent == HEADβ fast-forward: accept, advance HEAD.parent != HEADβ per-file 3-way vs the common ancestor (the pusher'sparent):- Changed by only the pusher β applies cleanly.
- Changed by both β conflict: first-to-land (current HEAD) stays canonical; the
loser is written beside it as
path.conflict-<host>-<ts>.ext;on-conflictfires. The conflict copy is a new path β it syncs everywhere, so no edit is ever lost.
- Comparison is by BLAKE3 content hash, not timestamps β touching a file without changing bytes is a no-op.
- Deletes propagate (recoverable from snapshots). Delete-vs-edit never loses the edit β
a delete has no bytes to preserve, so the edit always wins (canonical, or a
.conflictcopy). - Renames are free β old-path-gone + new-path-added; content-addressed chunks mean zero bytes re-transfer.
- Initial mount/bootstrap β mounting onto a non-empty dir reconciles per-file against HEAD via the same conflict path (ancestor = empty).
Never blocks. Never asks. Never destroys a byte.
- Conflict copies land on disk next to the canonical file (always).
devbox statusshows a pending-conflict count + lists the copies. (Always on.)on-conflicthook is the notification API β wire it to ntfy/desktop/Slack.devbox conflictslists the conflict files (read-only listing) so you can find and clean them. (Interactive resolve β mine/theirs/both/edit β is v2. No blocking prompt: a headless daemon can't prompt and it would break "never asks.")
- Machine-global
~/.config/devbox/:- Ed25519 keypair (one identity per device; never leaves the box).
daemon.tomlβ the mount list (share, subpath, localpath, readonly, hub).config.tomlβ global settings.
- Per-mount
<localpath>/.devbox/:hooks/β per-machine lifecycle scripts (never synced).- last-synced snapshot id, mount-local overrides.
- Always implicitly ignored from sync.
.devignoreβ a synced file at a share root (gitignore syntax). Nested ones allowed.
# ~/.config/devbox/daemon.toml
[[mount]]
share = "projects"
local = "/Users/jh/Projects"
hub = "hub.shoemoney.ai"
[[mount]]
share = "projects"
subpath = "p22/backend"
local = "/var/www/p22/backend"
readonly = true
hub = "hub.shoemoney.ai"# ~/.config/devbox/config.toml
[hooks]
# bash everywhere; a .ps1 hook auto-runs via pwsh on Windows
interpreter = "bash"
timeout_secs = 60
[transfer]
max_kbps = 0 # 0 = unlimited; cap to protect a link
[secrets]
# built-in deny-list always on; add extra patterns here
extra_patterns = []Gitignore syntax, relative to the share/mount root; nested files allowed.
node_modules/
vendor/
dist/
build/
.next/
target/
*.log
*.tmp
.DS_Store
!.env.example
- A matched path is invisible to sync in both directions.
!patternnegation re-includes (gitignore precedence).- Changing
.devignoretriggers a rescan; newly-ignored files are left on disk but stop syncing. .devbox/is always implicitly ignored.
A built-in deny-list runs in the push path and hard-refuses to upload matched files, even
if .devignore is misconfigured. This is the "won't leak your secrets" guarantee, enforced β
not an optional hook.
Default patterns: .env, .env.* (except .env.example), *.pem, *.key, id_rsa*,
*.p12, *.pfx, secrets/, *.kdbx, common cloud-credential files. Add more via
[secrets].extra_patterns. A blocked file surfaces in devbox status. (A pre-push
secret-scan hook can still add belt-and-suspenders scanning of file contents.)
Per-machine scripts in <mount>/.devbox/hooks/, named after the event, never synced.
devbox shells out to bash by default (one portable hook language across your fleet); a hook
file ending in .ps1 auto-runs via pwsh on Windows (zero-config opt-in). devbox doctor
flags a missing interpreter. Non-zero exit on a pre-* hook aborts that step. Default
timeout 60s β a hung hook is killed + logged, never wedges the loop.
| Hook | Fires | Can abort? | Typical use |
|---|---|---|---|
pre-push |
before local changes upload | β | lint/format, secret scan |
post-push |
after upload confirmed | β | notify, log, tag a snapshot |
pre-pull |
before inbound changes apply | β | stop a container/dev server |
post-pull |
after inbound changes applied | β | pnpm install, migrate, restart service |
on-conflict |
conflict copy created | β | open a diff, ping you, log |
DEVBOX_EVENT=post-pull
DEVBOX_MOUNT=/srv/project
DEVBOX_SHARE=projects
DEVBOX_HOST=pi-node-07
DEVBOX_CHANGED_FILES=/tmp/devbox-changes.txt
DEVBOX_SNAPSHOT=ab12cd34
DEVBOX_REMOTE=hub.shoemoney.ai#!/usr/bin/env bash
if grep -qE 'package\.json|pnpm-lock\.yaml' "$DEVBOX_CHANGED_FILES"; then
pnpm install --frozen-lockfile
fi
docker compose restart app- Every accepted change set on a share creates an immutable snapshot (id = BLAKE3 of the manifest). The manifest itself is content-addressed and stored in the CAS β identical or near-identical manifests dedup, so 100 pushes don't store the pathβchunks map 100Γ.
devbox loglists snapshots;devbox restore <snapshot> [path]rolls a file or the whole share back (a restore is itself a new change β reversible).devbox deploy <share> <snapshot>(v1.5/M6.5) β atomically pin a read-only mount to a specific snapshot. Blue/green deploys for/var/www-style boxes, nearly free since snapshots are already immutable.- Retention configurable on the hub;
devbox-hub gcgarbage-collects unreferenced chunks via refcounts.
Go cross-compiles to all three; fsnotify covers inotify / FSEvents / ReadDirectoryChangesW.
- Paths: canonical form is forward-slash, relative; convert to
\only at the Windows filesystem boundary. - Atomic apply:
ReplaceFile/MoveFileExon Windows; POSIXrename(2)elsewhere. - Name clashes / reserved names (
foo.govsFoo.go,aux/com1, trailing dot): skip + warn + surface under "unsyncable here" indevbox status. The hub keeps the bytes; OSes that can hold the name still get the file. Never fatal. - Line endings: bytes synced verbatim; no CRLF/LF normalization.
- Service install:
devbox start|stopabstracts launchd / systemd / Windows Service. - Hooks: bash default;
.ps1βpwshon Windows;devbox doctorverifies the interpreter.
Open-source core + commercial hosted tier. The OSS product is the fully self-hostable hub + clients (this entire v1). The hosted version is a separate, closed control plane (signup, billing, quotas, provisioning) β out of scope for v1, but the core carries clean seams:
BlobStoreinterface β disk CAS (v1); S3/R2 impl later for hosted.- Config-driven limits β
max_storage,retention,max_devices; self-host unlimited, the commercial layer sets them per tier. /metrics+ status page β observability now; doubles as the hosted dashboard seam.- Single-tenant core β the OSS hub knows nothing about accounts/billing.
Self-host free; anyone offering it as a hosted service must open their mods or buy a commercial license. Add a contributor CLA once outside PRs arrive (keeps dual-licensing possible).
- Tenancy: per-tenant hub instance β the control plane provisions one clean single-tenant hub (own SQLite + blob bucket) per account. Zero tenancy code in the OSS core. Multi-tenant consolidation is a later scale optimization.
- Pricing: small monthly fee, discount for annual. Tiers enforced via the config knobs.
- Each device generates an Ed25519 keypair on first run; the private key never leaves the machine. Joining a hub authorizes the device's pubkey.
- A single join token (rotatable, expiring) enrolls devices:
devbox join <hub> <token>. - All hub traffic over TLS (behind Nginx Proxy Manager / Let's Encrypt).
- Secret guard (Β§9) keeps
.env, keys, etc. on the machine β enforced in the push path, default-on, independent of.devignore. - Read-only enforcement (Β§6) is server-side β a non-writable device can't push.
- Revoke a lost device with
devbox-hub device revoke <id>β its bearer token dies immediately. - (Deferred to v2) optional client-side E2E chunk encryption so the hub stores only ciphertext.
# device
devbox join <hub> <token> # enroll this device
devbox -rw <share>[/sub] [path] # mount a share/sub-path read-write (default ./<name>)
devbox -r <share>[/sub] [path] # mount read-only
devbox publish <localdir> <name> # create a share from a local folder (writable device)
devbox unmount <share> # stop syncing a mount (files stay on disk)
devbox start | stop # run/stop the daemon (devboxd)
devbox status # rich text: shares, mounts, peers, conflicts, blocked secrets
devbox pause | resume # halt sync without unmounting
devbox log # snapshot history
devbox restore <snap> [path] # roll back a file or a whole share
devbox deploy <share> <snap> # pin a read-only mount to a snapshot (v1.5)
devbox conflicts # list conflict copies (interactive resolve = v2)
devbox ignore <pattern> # append to .devignore + rescan
devbox hook edit <event> # open a hook script in $EDITOR
devbox peers # linked machines, online/offline, last seen
devbox doctor # diagnose watcher limits, perms, hook interpreter, connectivity
# hub (run on the hub host)
devbox-hub serve --config /etc/devbox-hub.toml
devbox-hub token # mint/rotate the join token
devbox-hub device list | revoke <id>
devbox-hub readonly <device> <share> # mark a device read-only on a share (toggle)
devbox-hub gc # garbage-collect unreferenced chunks
(devbox with no args = devbox status. A full TUI is a future "someday" todo, not v1.)
CREATE TABLE devices (
id TEXT PRIMARY KEY, -- pubkey fingerprint
name TEXT NOT NULL,
pubkey BLOB NOT NULL,
last_seen INTEGER,
revoked INTEGER DEFAULT 0
);
CREATE TABLE tokens (
hash TEXT PRIMARY KEY, -- hash of the join token
expires_at INTEGER,
used INTEGER DEFAULT 0
);
CREATE TABLE shares (
name TEXT PRIMARY KEY,
head_snapshot TEXT, -- current linear HEAD
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL
);
-- only access control in v1: a per-(device,share) writable bit. Row present + writable=0 => read-only.
CREATE TABLE access (
device_id TEXT NOT NULL,
share TEXT NOT NULL,
writable INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (device_id, share)
);
CREATE TABLE snapshots (
id TEXT PRIMARY KEY, -- blake3(manifest)
share TEXT NOT NULL,
parent_id TEXT,
device_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
manifest_hash TEXT NOT NULL -- manifest stored content-addressed in the CAS
);
CREATE TABLE chunks (
hash TEXT PRIMARY KEY, -- blake3 (chunks AND manifests live here)
size INTEGER NOT NULL,
refcount INTEGER NOT NULL DEFAULT 0
);Blobs (chunks + manifests) stored via the BlobStore interface; disk impl at
blobs/<first2>/<hash>, content-addressed and deduped across all shares. Refcounts drive GC.
| M | Deliverable | Done = |
|---|---|---|
| M0 β Skeleton | Go monorepo, cobra CLI, devbox join, keypair gen, machine config |
devbox join enrolls a device against a hub |
| M1 β Watch + ignore + secret-guard | fsnotify watcher, .devignore matcher, secret deny-list, content-addressed manifest builder |
Edits produce a correct filtered manifest diff; secrets are refused |
| M2 β Hub + one-way push | devbox-hub serve, shares, join token, BlobStore (disk CAS for chunks + manifests), publish, chunk upload over HTTP, snapshots, /metrics + status page |
A device publishes a share and pushes; snapshots land; hub is observable |
| M3 β Two-way sync | WS events + HTTP blob fetch, mount/pull, atomic apply, per-file 3-way conflict copies, devbox status badge + devbox conflicts list + on-conflict hook |
Two devices stay mirrored live; conflicts produce copies |
| M4 β Read-only + bandwidth | Server-enforced writable bit, devbox-hub readonly, read-only data-safety stash, sub-path mounts, bandwidth cap |
A read-only deploy mount pulls, never pushes; transfers respect the cap |
| M5 β Hooks | bash (+.ps1) lifecycle runner, templates, env injection, timeout |
post-pull runs pnpm install on a Pi node |
| M6 β Versioning | devbox log / restore, hub GC |
Roll back any file from history |
| M6.5 β Deploy | devbox deploy <share> <snapshot> (pin read-only mount to a snapshot) |
Blue/green flip a services box to a known snapshot |
| M7 β Hardening | devbox doctor, watcher-limit handling, WS reconnect/backoff, name-clash handling, arm64/mac/win release builds |
Survives reboots & flaky network on the cluster |
Open-core seams (BlobStore interface, config limits,
/metrics) land in M2. The commercial control plane (accounts/billing/provisioning) is a separate sub-project, not part of v1.
devbox/
βββ cmd/
β βββ devbox/ # CLI + daemon entrypoint
β βββ devbox-hub/ # hub server
βββ internal/
β βββ watch/ # fsnotify + debounce
β βββ ignore/ # .devignore matcher
β βββ secret/ # default-on secret deny-list (push-path guard)
β βββ chunk/ # FastCDC + blake3
β βββ manifest/ # tree state + diff (content-addressed)
β βββ mount/ # mount config, read-only enforcement
β βββ sync/ # push/pull engine, conflict resolution
β βββ hooks/ # lifecycle runner (bash/.ps1)
β βββ transport/ # ws (events) + http (blobs) client
β βββ hub/ # server, sqlite, shares, access, tokens, CAS, gc, metrics
β βββ blobstore/ # BlobStore interface: disk (v1), s3/r2 (later)
βββ pkg/proto/ # shared wire types
βββ deploy/ # systemd/launchd/winsvc units, docker-compose for hub
- Watcher limits at scale β large trees blow past inotify limits on Linux; mitigated by
.devignorecuttingnode_modules, a fallback periodic rescan, anddevbox doctorto raisefs.inotify.max_user_watches. - Hub uplink saturation β 40 nodes pulling one big change strains the hub's link. v1
mitigations: bandwidth cap +
.devignore. The real fix (LAN peer chunk exchange) is v2. - Hub HA β v1 is single-hub (SQLite). Run it on one stable node + TrueNAS-backed blob dir; clustering is v2.
- Central hub vs P2P β central hub for v1; a P2P mesh transport can be added later without changing the CLI/hook contract.
Deferred features, captured so they aren't lost:
- LAN peer chunk-exchange. Co-located nodes swap chunks directly (Syncthing-style block exchange) instead of all hitting the hub β the real fix for cluster uplink pressure.
- Interactive conflict resolver.
devbox conflictsgains diff + keep-mine/theirs/both/edit. - Re-share / delegation permission (the
sbit) β device-to-device granting. - Content-level 3-way text merge for conflicting edits.
- Laptop power sanity β pause-on-metered / pause-on-battery / scheduled sync windows.
- Client-side E2E chunk encryption β hub stores only ciphertext.
- Hub clustering / HA.
- Full TUI for
devbox(no-arg dashboard).