A small backup CLI in Go. It scans a local folder, works out what actually changed since the last run, and uploads only that to a Backblaze B2 bucket through a bounded worker pool with retry. Optionally it also removes the objects whose local files are gone.
Go standard library plus blazer as the only third-party dependency.
- Change detection that does not re-read your disk. A file whose size and modification time both match the last run carries its recorded SHA-256 forward and is never hashed again. Only the first run pays for reading every byte.
- Concurrent uploads with retry. A bounded pool (4 workers by default) with exponential backoff, 3 attempts per file. A cancelled run and a missing local file are never retried, because waiting cannot change either answer.
- Partial failure never loses work. One file failing does not stop the batch, and the state written back reverts every failure to what the previous run recorded — so the next run retries exactly what did not land, and nothing else.
- Opt-in deletion. With
--delete, objects whose local file has vanished are removed from the bucket. Off by default. - Bucket inspection.
b2sync listprints what the bucket actually holds, including superseded versions if you ask.
- Go 1.26 or newer
- A Backblaze B2 account, a bucket, and an application key
The application key needs listBuckets, readFiles, listFiles, writeFiles
and deleteFiles. listBuckets is the non-obvious one: blazer resolves a bucket
by name by listing buckets and matching, so a key without it fails at startup
with "bucket not found", which reads like a mistyped bucket name.
go build -o b2sync ./cmd/b2sync
Credentials come from three environment variables:
B2_KEY_ID
B2_APPLICATION_KEY
B2_BUCKET_NAME
They are read from the process environment first, and from an env file
otherwise, so exporting them wins over the file and a scheduled job with no
shell still works. The default env file is .env in the working directory;
point elsewhere with --env-file.
B2_KEY_ID=your-key-id
B2_APPLICATION_KEY=your-application-key
B2_BUCKET_NAME=your-bucket
Never commit that file. Only credentials belong in it — the object prefix, the worker count and every other limit are policy and live in code or flags.
./b2sync sync [flags] <folder>
| Flag | Meaning |
|---|---|
--state <path> |
JSON state file (default <folder>/.b2sync-state.json) |
--prefix <prefix> |
Object name prefix inside the bucket |
--delete |
Also remove objects whose local file is gone |
--dry-run |
Report what would happen; contact nothing, write nothing |
--env-file <path> |
File to read credentials from (default .env) |
$ ./b2sync sync --prefix laptop ~/Documents
uploaded laptop/notes.md
uploaded laptop/budget.csv
synced 2 uploaded, 0 failed
$ ./b2sync sync --prefix laptop ~/Documents
synced 0 uploaded, 0 failed
Successes go to stdout and failures to stderr, so redirecting stdout leaves exactly the problems visible. A dry run needs no credentials and no network, which makes the whole pipeline inspectable before it touches anything:
$ ./b2sync sync --dry-run ~/Documents
would upload budget.csv
would upload notes.md
dry run: 2 files would upload
The dry run lists files in sorted order because that is the order the jobs are built in. A real run prints them as each upload finishes, so its lines can come back in a different order — nothing downstream depends on that.
$ rm ~/Documents/budget.csv
$ ./b2sync sync --delete --prefix laptop ~/Documents
removed laptop/budget.csv (1 versions)
synced 0 uploaded, 0 failed
removed 1 objects (1 versions), 0 failed
Deletion is opt-in because the two failure modes are not symmetric: an upload that goes wrong wastes bandwidth, while a deletion that goes wrong destroys the backup the tool exists to keep.
For the same reason, --delete refuses to run when the folder scanned empty but
the state file recorded files. That is far more likely an unmounted disk or a
mistyped path than a decision to erase every backup:
$ ./b2sync sync --delete ~/Documents
refusing to delete 214 objects: /Users/me/Documents scanned empty (unmounted or wrong path?)
./b2sync list [--prefix p] [--all-versions] [--env-file path]
$ ./b2sync list --prefix laptop
laptop/notes.md
1 objects
--all-versions includes hide markers and superseded versions. It is the only
way to confirm a deletion left nothing behind, because a bucket configured to
keep all versions can look empty under a plain listing while the data is still
there and still billed.
| Code | Meaning |
|---|---|
0 |
Everything asked for succeeded |
1 |
The run started but something failed; state is saved so the next run retries it |
2 |
Nothing was attempted: bad usage, missing credentials, unreachable folder, or a refused deletion |
A scheduled job can tell these apart: 1 means try again next time, 2 means
the machine is misconfigured and trying again will not help.
The binary reads its own env file, so a cron entry needs no wrapper script to source anything. Pass absolute paths, since cron does not run in your shell's working directory:
0 * * * * /usr/local/bin/b2sync sync --env-file /home/me/.b2sync.env --prefix laptop /home/me/Documents
scan -> diff -> upload -> remove -> save state
- Scan walks the folder into a snapshot: size, modification time and SHA-256 per file, keyed by slash-separated path relative to the root. Files whose size and mod-time match the previous run keep their recorded hash and are not read.
- Diff compares the two snapshots by hash alone, producing new, changed, unchanged and deleted sets.
- Upload runs new and changed files through the worker pool.
- Remove deletes the objects for vanished files, but only under
--delete. Removals come after uploads so a run that dies partway has destroyed as little as possible. - Save state writes the snapshot back, with every failed operation reverted to what the previous run recorded.
The state file is a plain JSON snapshot stored in the folder it describes, so two folders cannot collide on one file, and it is excluded from its own scan.
- Deleting means every version. A B2 bucket keeping all versions answers a
single-version delete by revealing the version underneath, so the object stays
listed and downloadable while the call reports success.
b2synclists every version of the exact name and removes all of them. - The state file is the retry queue. A failed operation leaves the saved state exactly as it was, so the next run recomputes the same work. That is why deletion carries no retry loop of its own: the next run is the retry.
- A batch reports per item, never one error. Successes plus failures always equal the number of inputs, which is what stops a caller from recording an unattempted file as done.
go test ./... # everything; live tests skip without credentials
go vet ./...
gofmt -l . # prints nothing when clean
go test ./internal/upload ./internal/cli -race -count=2
The tests that need a real bucket skip themselves with a reason unless the three credential variables are set, so a clean clone stays green. Run them deliberately:
set -a && source .env && set +a && go test ./internal/b2 -run TestLive_ -v
They upload to, read back from, and delete from the real bucket under their own prefixes, cleaning up after themselves.
cmd/b2sync/ the binary: signal handling, call, exit
internal/index/ scan, state file, diff (no network)
internal/b2/ one bucket, one operation (no diffing, no retry)
internal/upload/ bounded worker pool, retry (no scanning, no state)
internal/prune/ deletions for vanished files (no scanning, no state)
internal/cli/ flags, wiring, output, exit codes
Each package's doc comment states what it does and what it deliberately does not, so the boundaries survive without the conversations that drew them.
This project is built in numbered slices, each planned in claude-plans/ before
any code is written. claude-plans/ROADMAP.md is the full committed and parked
list, claude-plans/STATE.md is the living file-by-file inventory, and
CLAUDE.md holds the conventions, rules and scars the project has earned.
MIT — see LICENSE.