Originally published at hafiz.dev
Earlier this month I finally asked myself a question I'd been avoiding for years. If this MacBook died tonight, what would I lose? The honest answer was everything that wasn't in a git remote. Documents, dotfiles, SSH keys, my notes, a decade of downloads I'd never sorted. My Mac had zero backups. Not a stale one, not an old Time Machine drive in a drawer. Zero.
That's a strange position for a developer who worries about production databases for a living. I'd even written about the risk. When I built my remote dev workstation, a quick check turned up finished work that existed on exactly one machine. I fixed that for code by pushing everything. The laptop itself stayed unprotected, and it's the machine where I let Claude Code run shell commands every day (which is its own risk worth planning for).
So on 24 September I set up restic, Backblaze B2 and Healthchecks.io. The Mac now backs itself up every day, encrypted before anything leaves the machine, and I get an email if it ever stops. This is the third post in the series after the workstation and How I Hardened My VPS in One Afternoon. Same format. The setup, why each piece, the real numbers, and the things that broke.
Why restic and B2, and not the obvious options
I looked at three easier paths first. Each one fails for a specific reason.
Backblaze Personal Backup is the one most people would pick, and it's a good product. It's $9 a month or $99 a year on Backblaze's pricing page, with native Mac and Windows clients. That's the problem. There's no Linux client, and I have three servers that need backing up too. I wanted one system, one bucket and one set of commands for every machine I own, not a consumer app for the laptop and something else for the servers.
iCloud syncs. It doesn't back up. If I delete a folder or a script mangles a file, iCloud copies the mistake to every device as faithfully as it copies my work. It also only covers what lives in iCloud Drive, and my code lives in ~/Sites and ~/side-projects.
An external drive alone sits on the same desk as the laptop. A theft, a fire or a spilled coffee takes both. A local Time Machine copy is still on my list for fast restores and Migration Assistant, but as the second copy, not the only one.
restic splits the job in two. It's the backup program that encrypts, deduplicates and keeps snapshots. B2 is only the storage it writes to. If Backblaze ever raises prices or disappears, I change one environment variable and point restic somewhere else. The passphrase never leaves the Mac, so Backblaze only ever holds ciphertext.
The shape of the setup
| Piece | Choice | Why |
|---|---|---|
| Backup program | restic 0.19.1 from Homebrew | Client-side encryption, dedup, snapshots, one binary |
| Storage | Backblaze B2, EU Central (Amsterdam) | Close to Italy, keeps the data in the EU, cheap per GB |
| Schedule | launchd, daily at 12:30 | Runs on wake if the Mac was asleep, unlike cron |
| Secrets | macOS Keychain | Nothing sensitive in any file |
| Monitoring | Healthchecks.io, free plan | Emails me when a run fails or never happens |
| Retention | 7 daily, 4 weekly, 12 monthly | About a year of history |
The layout is one bucket with one repository per machine, each under its own prefix. Every machine gets its own restic passphrase and its own B2 application key limited to that prefix. A compromised server can't read or delete another machine's backups, because its key can't see them.
The B2 bucket
| Setting | Value | Why |
|---|---|---|
| Visibility | Private | Obviously |
| Default encryption | Enabled (SSE-B2) | A second layer on top of restic's, and it's a checkbox |
| Object Lock | Disabled | With retention set, pruning can't free locked files, and it can't be turned off once enabled |
| Lifecycle | Keep prior versions 30 days | Deleted objects stay recoverable for a month, then stop costing money |
| Caps | Storage $0.10/day, download $1/day | About 440 GB stored before uploads block |
Object Lock looks like the responsible choice, and for some setups it is. But restic's forget --prune works by deleting old pack files. With a retention period set, locked files can't be deleted until it expires, so pruning can't free them. Since the bucket setting is a one-way switch, I left it off.
restic talks to B2 through its S3-compatible API. The restic docs recommend that over the native B2 backend because of error handling issues in the B2 library. So the repository URL is an S3 one, with the regional endpoint, the bucket, and the machine prefix:
RESTIC_REPOSITORY="s3:https://s3.eu-central-003.backblazeb2.com/your-bucket/macbook"
Secrets live in the Keychain, not in files
The quick way is an env file next to the script with the B2 keys and the repository password in it. I didn't want a plain-text file with credentials that can delete my backups sitting in a folder that gets backed up.
macOS already has an encrypted secret store with a CLI. Four entries, added once:
security add-generic-password -a "$USER" -s restic-b2-key-id -w
security add-generic-password -a "$USER" -s restic-b2-key -w
security add-generic-password -a "$USER" -s restic-password -w
security add-generic-password -a "$USER" -s restic-healthcheck-url -w
With -w at the end and no value, it prompts. The prompt says password data for new item: and it wants the secret itself, not your Mac password. I got that wrong the first time.
The scripts read them back at run time. restic has a RESTIC_PASSWORD_COMMAND variable for exactly this, so the passphrase is never even exported as a plain variable:
secret() { security find-generic-password -s "$1" -w; }
export AWS_ACCESS_KEY_ID="$(secret restic-b2-key-id)"
export AWS_SECRET_ACCESS_KEY="$(secret restic-b2-key)"
export RESTIC_PASSWORD_COMMAND="security find-generic-password -s restic-password -w"
The Keychain holds the working copies. Recovery copies live in a Bitwarden folder, and the restic passphrase is also on paper next to my Bitwarden recovery code. Lose the restic passphrase and the backup can never be decrypted, by anyone. Backblaze can't help you. That's the whole point of client-side encryption.
One more thing to remember. The Keychain isn't in the backup. On a new Mac, those four entries go back in by hand before any restore can happen.
What gets backed up, and what doesn't
Two plain text files drive it. includes.txt lists the folders, one path per line. Trimmed:
# Code
~/Sites
~/side-projects
~/second-brain
~/archives
# Personal files
~/Documents
~/Desktop
~/Downloads
~/Library/Mobile Documents
# Settings, keys and app data
~/.ssh
~/.config
~/.claude
~/.zshrc
~/Library/Preferences
~/Library/Application Support
(The real file uses absolute paths. Library/Mobile Documents is where iCloud Drive keeps its local files.)
excludes.txt is where the savings are. Anything that can be downloaded or rebuilt again stays out:
# Dependencies and build output
node_modules
vendor
dist
.next
.venv
# Laravel runtime files
/Users/you/**/storage/logs
/Users/you/**/storage/framework/cache
/Users/you/**/storage/framework/views
# App caches, at any depth
Cache
Code Cache
GPUCache
Plain names like node_modules match at any depth. The dependency and build folders alone were about 16 GB. Claude's desktop app keeps a vm_bundles folder of 10 GB that it re-downloads anyway. Apps whose data lives in their own cloud (Notion, Slack, Spotify, browsers) are skipped too.
The first run read 26.3 GB across 158,561 files and stored about 16 GB after compression and dedup.
The daily job
launchd runs a script every day at 12:30. I picked launchd over cron for one reason. Apple's scheduling docs say a StartCalendarInterval job that was due while the Mac slept runs when it wakes up. Cron would just skip it. A laptop is asleep a lot.
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>12</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
<!-- Stay out of the way of normal work. -->
<key>Nice</key>
<integer>10</integer>
<key>LowPriorityIO</key>
<true/>
The plist lives in ~/Library/LaunchAgents/ and runs backup.sh. The core of that script is short:
echo -n | ping_hc /start
(
echo "=== $(date '+%Y-%m-%d %H:%M:%S') backup"
restic backup \
--files-from "$DIR/includes.txt" \
--exclude-file "$DIR/excludes.txt" \
--exclude-caches
rc=$?
# Sundays: drop old snapshots and verify the repository structure.
if [ "$rc" -eq 0 ] && [ "$(date +%u)" -eq 7 ]; then
echo "=== prune"
restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 12 && restic check
rc=$?
fi
echo "=== exit $rc"
exit "$rc"
) >"$RUN_LOG" 2>&1
rc=$?
cat "$RUN_LOG" >>"$LOG"
# Exit 0 reports success. Anything else, including 3 (some files unreadable),
# reports failure so the email arrives. The last lines explain why.
tail -n 40 "$RUN_LOG" | ping_hc "/$rc"
exit "$rc"
Where ping_hc is a one-line curl that never fails the script:
ping_hc() { curl -fsS -m 10 --retry 3 --data-binary @- "$PING$1" >/dev/null 2>&1 || true; }
A run takes under a minute, and a day with no changes takes about 15 seconds, because restic only uploads what changed. On Sundays the same run also prunes old snapshots and runs restic check against the repository.
Monitoring with Healthchecks.io
A laptop job fails quietly. The lid stays shut for a week, a plist breaks, a macOS update locks a folder. So the job reports to Healthchecks.io, which is free for up to 20 checks.
The check is called "MacBook backup", with a period of one day and a grace of one day. If no successful ping arrives for about two days, I get an email. That covers the case where the job doesn't run at all, like a broken plist or a Mac that's been off for a week.
The failure path is faster. Healthchecks has an exit status endpoint. Ping /0 and it's a success. Ping any other number and it's a failure. The script sends restic's exit code straight through, with the last 40 lines of the log as the request body. Healthchecks stores up to 100 kB of each ping's body, so the alert email tells me why it failed without opening a terminal.
The /start ping at the top also gives me run durations for free.
The gotchas
All of these cost me time.
B2 blocks uploads at 10 GB until you add a card
B2's first 10 GB are free. What I didn't expect is that a new account without a payment method is hard-capped there. My first backup ran happily until it crossed 10 GB, then every upload failed with storage cap exceeded. That's why the initial upload took two runs.
Adding a card fixes it, but it also flips every cap to "No Cap". So the account that was safely limited a minute ago now has unlimited daily spend. Go straight to Caps & Alerts and set them again by hand. Backblaze emails you at 75% and 100% of each cap, so a runaway script or a leaked key gets noticed within a day.
Exit code 3 is a failure, on purpose
restic's exit codes say code 3 means "backup could not read some source data". The snapshot still gets created. It's just missing whatever couldn't be read.
The first few runs returned 3 constantly, thanks to the next gotcha. The tempting fix is to treat 3 as success so the alerts stop. I did the opposite. Every unreadable path either got fixed or got an explicit line in excludes.txt, and 3 still reports as a failure. So when a macOS update protects some new folder, I get an email instead of a silent hole in my backups that I'd discover during a restore.
macOS privacy folders: exclude them, don't grant Full Disk Access
macOS refuses to let a background job read certain folders. Contacts, Messages, FaceTime, call history, browser profiles. restic logs operation not permitted for each one and exits with 3.
The usual advice is to give Full Disk Access to whatever runs the backup. But launchd runs /bin/bash here, and granting Full Disk Access to bash means granting it to every bash script on the machine. No thanks.
Everything in those folders is already synced somewhere else, mostly iCloud and browser accounts. So I excluded them by path and left the privacy controls alone. iPhone backups also go to iCloud, so MobileSync is out too. And one pleasant surprise. launchd can read Desktop, Documents and Downloads without Full Disk Access. I checked that on macOS 27 before trusting it.
restic ls only shows the top level
My first sanity check was restic ls latest ~/second-brain. It printed a handful of entries and I briefly thought the backup was nearly empty.
It wasn't. The restic docs say it plainly. When filtering by directory, files in subdirectories aren't listed unless you pass --recursive. Add the flag and the whole tree shows up.
restic ls latest ~/second-brain --recursive
A backup you haven't restored from is a guess
Snapshots listed, check passed, Healthchecks green. None of that proves you can get your files back. So on day one I restored a real folder and compared it.
Restoring
Everything manual goes through a small wrapper, restic.sh, which loads the same Keychain secrets and passes its arguments to restic:
R=~/.config/restic/restic.sh
$R snapshots # list backups with dates
$R find "contract.pdf" # which snapshots contain a file
$R ls latest ~/Sites/personal --recursive # browse the latest backup
$R restore latest --include ~/Documents/contract.pdf --target ~/Desktop/restored
Always restore into a separate folder like ~/Desktop/restored, check it, then move files back by hand. A restore never touches current files unless the target is /.
The test restore was second-brain, my notes repo. It came back byte-identical, with its git history intact.
The new-Mac plan is written down too. brew install restic, add the four Keychain entries, copy ~/.config/restic/ back, then restore code and files first. Bring Library settings back one app at a time and only where I actually miss something, because settings from an older macOS can clash with a newer one. Apps aren't in the backup. They get reinstalled.
What it costs
B2 charges $6.95 per TB per month, billed on the average amount stored, and the first 10 GB are always free.
The Mac's repository is about 16 GB. Take off the free 10 GB and that's 6 GB billed, which works out to roughly four cents a month before VAT. The 30-day lifecycle rule keeps pruned data around for a month, so the real number wobbles a little after prune weeks. It's still a rounding error.
Healthchecks.io is free at this scale. restic is free. The only real cost was an afternoon.
Compare that with $9 a month for Backblaze Personal. Personal is simpler and does its job well. But for one laptop plus three servers, one restic setup is both cheaper and the same everywhere.
FAQ
Is restic with Backblaze B2 cheaper than Backblaze Personal Backup?
For a single laptop with a small backup, by a wide margin. B2 bills by storage at $6.95 per TB per month with 10 GB free, while Personal is a flat $9 a month. Personal is unlimited though, so if you have several terabytes on one Mac, run the numbers for your own size.
Does restic need Full Disk Access on macOS?
Not for the usual folders. A launchd job could read Desktop, Documents and Downloads without it on macOS 27. It can't read privacy-protected folders like Messages or Contacts. You can grant Full Disk Access to the binary launchd runs, or exclude those folders if they're synced elsewhere, which is what I did.
Can Backblaze read my restic backups?
No. restic encrypts everything on the Mac before upload, with a passphrase that never leaves it. Backblaze stores ciphertext. That also means nobody can recover the data if you lose the passphrase, so keep an offline copy.
Should I enable Object Lock on a restic bucket?
Only if you've planned for it. Once a retention period is set, Object Lock stops deletions until it expires, which also stops restic's forget --prune from freeing old data. And B2 doesn't let you disable Object Lock on a bucket once it's on. A no-delete application key for machines that shouldn't prune gets you most of the protection with less friction.
How do I know the backup is still running?
Ping a monitoring service at the end of every run with the exit code. Healthchecks.io emails you when it gets a failure or when a success doesn't arrive within the period plus grace. Without that, a backup can stop for months before anyone notices.
What's still open
The servers are next. Production SQLite databases and uploads on three boxes have no backup yet, which is uncomfortable to write down after migrating a live SaaS between them. The plan uses the same bucket with one repository and one key per server. The difference is that server keys won't have delete permission. Pruning needs delete, so it'll run from the Mac, and a compromised server can't wipe its own history. Each server will also run sqlite3 .backup before restic, because copying a live database file can capture it mid-write.
After that, Time Machine on a separate drive for fast local restores. And a restore test every few months, because the day-one test only proves day one.




Top comments (0)