GitHub - evantahler/icloud-backup: Append-only backup of iCloud Photos, Drive, Notes, and Contacts to any local directory.

5 min read Original article ↗

Append-only backup of iCloud Photos, Drive (Desktop & Documents), Notes, and Contacts to any locally-mounted directory. macOS-only. No network calls to Apple — reads local SQLite databases directly via macos-ts.

Four-lane backup in progress

icloud-backup all /Volumes/icloud-backup-evan

Install

# Standalone binary (darwin-arm64 or darwin-x64)
curl -fsSL https://raw.githubusercontent.com/evantahler/icloud-backup/main/install.sh | bash

Prerequisites (one-time)

  1. Photos.app → Settings → iCloud → "Download Originals to this Mac"
  2. System Settings → Apple ID → iCloud → iCloud Drive → "Desktop & Documents Folders" = on
  3. Full Disk Access for your terminal app (System Settings → Privacy & Security → Full Disk Access)
  4. Mount your destination(s) at stable paths

Run icloud-backup doctor to verify all of the above before your first backup.

icloud-backup doctor

Usage

icloud-backup <command> [options]

Per-service backup commands (each takes a destination directory):
  photos    <dest>     back up Photos library originals → <dest>/photos/
  drive     <dest>     back up iCloud Drive Desktop & Documents → <dest>/drive/
  notes     <dest>     back up Apple Notes as markdown → <dest>/notes/
  contacts  <dest>     back up Apple Contacts as vCard or JSON → <dest>/contacts/
  all       <dest>     run all four → <dest>/{photos,drive,notes,contacts}/

Backup options (photos / drive / notes / contacts / all):
  --full                  ignore the incremental high-water mark and re-scan everything
  --rewind-time <dur>     incremental overlap window, e.g. 1d/12h/30m (default 1d)
  --concurrency <n>       files in flight per lane (1..64, default 5)
  --no-manifest-snapshot  skip writing .manifest.sqlite/.json next to backed-up data

Other:
  doctor [dest]        run preflight checks and exit
  rebuild <dest>       walk destinations and rebuild manifests (forces a full re-scan next run)
  check-update         force a fresh npm-registry check, print result, exit
  upgrade              upgrade to the latest published version (in-place)
  --help, -h
  --version, -v

Environment:
  ICLOUD_BACKUP_NO_UPDATE_CHECK=1   suppress the background "update available" notice

Run an individual service to a different destination than all:

icloud-backup all /Volumes/main
icloud-backup photos /Volumes/photo-archive

How it works

  • Photos: iterates the Photos SQLite library, copies originals + a JSON metadata sidecar, copies the .mov companion for Live Photos.
  • Drive: brctl download materializes Desktop & Documents, then enumerates changed files (incrementally via Spotlight mdfind, else a full Bun.Glob walk) and copies them.
  • Notes: iterates Notes, writes each as a markdown file with attachments in a sibling .attachments/ directory.
  • Contacts: iterates Contacts, writes one JSON file per contact, sha256 of contents is the change key.

State (manifests, lock, update cache) lives at ~/.icloud-backup/ regardless of where backups land — keeps SQLite local-fast and survives unmounted destinations.

When a source changes, the existing destination file is moved to <dest>/_overwritten/<date>/v<n>/ before the new version is written. Append-only.

Incremental sync (with overlap)

By default each run is incremental: it asks the source only for items changed since the last successful sync, so a large library isn't fully enumerated every time. Photos and Notes filter at the SQLite level (modifiedAfter); Drive asks Spotlight (mdfind) for files changed since the cutoff instead of walking the whole tree. The per-item manifest diff is unchanged — incremental only shrinks what's examined, never how a change is detected — so a wider window only costs re-examination, never duplicate copies.

Because iCloud stamps an item's modification time on the device that made the edit (it then syncs here later), the cutoff is rewound by an overlap window (--rewind-time, default 1d) so a just-synced edit isn't missed. Each run logs the window it used.

  • --rewind-time <dur> — widen/narrow the overlap (e.g. --rewind-time 7d if devices sync slowly; 1d/12h/30m/bare-seconds accepted).
  • --full — ignore the high-water mark and re-scan everything. Run this periodically (e.g. monthly) as a safety net: it catches anything an incremental pass can't see — edits whose timestamp predates the last sync, file renames (Drive), or items skipped by a transient error.

Contacts is always a full scan — Apple doesn't reliably bump a contact's modification date when a child field (a phone number, email, address) changes, so a time filter there would silently miss edits. Contacts is tiny, so its content-hash diff is effectively instant anyway.

Resume & rebuild

Crash-safe: writes are atomic (write-to-tmp, fsync, rename); the manifest upsert is the last step per file. If the manifest is lost, run icloud-backup rebuild <path> to walk destinations and reconstruct it.

Output layout

/Volumes/icloud-backup-evan/
├── photos/2024/01/IMG_0001.HEIC + IMG_0001.HEIC.json
├── drive/{Desktop,Documents}/...
├── notes/<folder>/<title>-<id>.md (+ .attachments/ sibling)
├── contacts/<displayName>-<id>.json
└── _overwritten/<date>/v<n>/...

Destination compatibility

The destination can be any locally-mounted directory: APFS-formatted external SSD, exFAT USB stick, or an SMB share to a NAS. Filenames coming out of iCloud sometimes contain characters or lengths that work on macOS-local filesystems but get rejected on shares — long DALL·E prompt-named PNGs, decomposed-Unicode names, trailing dots, etc.

Rather than supporting each filesystem's quirks separately, all destination paths are sanitized to fit the strictest commonly-deployed SMB share (the lowest common denominator). A backup that works to a local disk will also work when the same destination is later moved to a NAS.

Constraint SMB (worst-case observed) APFS / HFS+
Filename byte length per component 143 bytes (HVTVault probe)* 255 bytes
Total path length 1024 bytes (PATH_MAX) 1024 bytes
Trailing dot or space in name rejected allowed
Reserved chars (\ : * ? " < > |) rejected allowed
Filename encoding NFC (UTF-16 on the wire) NFD
Leading dot allowed but hides on Unix allowed

* The 143-byte ceiling is server-specific — some Samba builds cap at 255 UTF-16 chars, others lower. The byte cap is therefore probed at the start of each lane by binary-searching test writes against the destination root. The discovered cap is logged at run start (e.g. destination NAME_MAX=143, sanitizing filenames to 126 bytes).

Truncated filenames keep their extension when possible and never split a multi-byte UTF-8 codepoint. Decomposed names are NFC-normalized so DALL·É round-trips identically on both ends.

Contributing

Screenshots and GIFs in this README are regenerated by bun run capture — see docs/captures.md for the VHS + fake-source pipeline.

Development

If you have Bun installed and prefer to run from the npm registry instead of the standalone binary:

bun install -g @evantahler/icloud-backup

Or run the latest version without installing:

bunx @evantahler/icloud-backup all /Volumes/icloud-backup-evan

License

MIT © Evan Tahler