Update, May 2026: a macOS 26.5 update broke this setup soon after I was
done writing it. I had Claude dig into it for a while and I made some
experiments, but I couldnāt get the pretty Login Items name and the custom icon
working again without a paid Apple Developer certificate. I decided to drop the
cosmetics entirely, which simplified the architecture considerably (no .app
bundle, no Mach-O launcher, no codesigning step). The post below is accurate
for macOS 26.4.1 and earlier; if youāre on 26.5 or later, treat the bundle
/ launcher / icon sections as historical context rather than instructions.
If you know how to get a pretty name and / or a custom icon for a Login Items
entry without a paid developer account, please reach out!
I wanted unattended, encrypted, offsite backups of $HOME on my MacBook Pro:
nightly, deduplicated, secrets in Keychain, retention managed for me and the
laptop firing the job on its own while I sleep.
This is half setup guide, half tour of the macOS security mechanisms and internals that make a backup like this awkward to wire up (for the end result I was aiming for, at least).
This post walks the setup top to bottom. We start with the building blocks and run a backup by hand. Then we wire it into launchd so it runs every night. After that, we work on some polish: notifications, cosmeticsā¦And finally, the repo layout. The code is on GitHub at lucas-santoni/macos-backup-restic-b2.
The backups are uploaded to Backblaze B2 but the same setup works against any other restic backend (S3, SFTP, REST serverā¦) with adjustments to the credentials section and the repository URL.
Versions used: restic 0.18.1, resticprofile 0.33.1, macOS 26.4.1
(Tahoe) on an M1 MacBook Pro.
The building blocks
This section is an overview of the tools we are going to work with. It includes the pros and cons of each tool, plus the alternatives I considered.
restic
restic is an encrypted backup tool that
deduplicates data automatically: identical chunks of content are stored
only once, across files and across snapshots. Snapshots are stored in a
content-addressed repository (each chunk is keyed by a hash of its
content) on whatever backend we point it at (local disk, SFTP, S3, B2, REST
serverā¦). Encryption is mandatory and uses a passphrase set on init. Deduplication
works via content-defined chunking, so renames are free and a daily snapshot of
a mostly-unchanged tree adds only the diff.
Limitations: command-line only, no GUI. Browsing a snapshotās contents is
possible via restic mount, but that uses FUSE, which on macOS means installing
macFUSE, and performance on large trees is mediocre. For most restore work I
just use restic restore --include to a temp directory and poke around there.
For a polished consumer UX, restic isnāt the right pick.
Other alternatives I considered:
- Borg is battle-tested and the feature overlap is large, but it has no native cloud backends. Putting a Borg repo in object storage means using something like rclone or running a Borg server on a remote box.
- Time Machine encrypts at rest on an encrypted APFS volume but isnāt offsite without third-party glue, and itās tied to a directly-attached or Bonjour-discoverable drive.
- Backblaze Computer Backup is Backblazeās managed consumer product: a flat monthly fee per computer, unlimited storage, native macOS client. Itās the no-fuss option for someone who doesnāt want to learn restic at all. Trade-offs: it only backs up user files (no full-system snapshots), it requires their daemon running on a supported OS, and the flat fee tilts unfavorably for small data sets. I was also wary of the client itself: there are regular bug reports around the macOS app. For my use case, metered B2 storage ends up cheaper, and I wanted something more flexible.
resticprofile
resticprofile
wraps restic in a TOML configuration model and a scheduler. Instead of
typing the same long restic invocation every night, we declare
[profiles.default.backup] once in a configuration file, with all the flags,
and call resticprofile -n default backup.
For the nightly automation, resticprofile doesnāt run a daemon of its own: it
delegates to the OSās scheduler. Once a profile has a schedule directive,
running resticprofile schedule generates the appropriate scheduler
configuration (a launchd plist on macOS) and registers it with the OS. From then
on, the OS scheduler is what wakes the machine up (or catches the next wake,
more on this later) at the configured time and runs resticprofile run-schedule <command>@<profile>, which in turn runs restic <command> with
the flags from the profile.
After each scheduled run completes, resticprofile inspects the exit code
and invokes the matching run-after (success) or run-after-fail
(failure) hook. The notification piece we wire up later hangs off those
hooks.
The alternative is a hand-rolled wrapper plus cron, which works fine and is a few dozen lines. The trade-off is that we eventually rebuild most of resticprofileās features (logging, hooks, multi-profile config) badly. For a single profile and a single schedule, the DIY approach is defensible. Once we want forget + prune + check (more on these later) on their own schedules with notification hooks per command, resticprofile is most likely the right path.
Backblaze B2
Backblaze B2 is the
destination bucket. Restic has first-class B2 support: a URL of the form
b2:bucket:/prefix and two environment variables (B2_ACCOUNT_ID,
B2_ACCOUNT_KEY) are all the configuration there is.
I picked B2 over AWS S3 because itās cheaper for backup workloads at this scale, and because the onboarding is simpler. Cloudflare R2, Wasabi, and other S3-compatible backends are also valid as restic supports the S3 API out of the box.
Self-hosting rest-server is the zero-third-party option, given a spare box somewhere with redundant disks. It removes the cloud dependency and adds the operational one: someone (you š«µš») has to own the disks and the uptime.
A backup we run by hand
Goal of this section: end with a working command we can type to back up
$HOME to B2, encrypted, with a sensible exclude list. No scheduling yet!
Install
brew install restic resticprofile
Both ship as static binaries, so no dependencies to chase.
Create a B2 bucket and an application key
In Backblazeās web UI: create a private bucket, then create an application key
scoped to that bucket. Backblaze returns an applicationKeyId and an
applicationKey. Save them now, as the web UI never shows the key string again.
Generate a passphrase and store secrets in Keychain
The repository passphrase encrypts everything in the bucket and thereās no recovery path: if itās lost, the backup is unrecoverable. I generated something long enough and put a copy in my password manager.
We now have three secrets to keep around: this passphrase, the B2 application
key ID, and the B2 application key. Putting them in plaintext on disk (a
.env, a config fileā¦) somewhat defeats the point of encrypting the backup
in the first place. macOSās built-in Keychain is the right home for them.
Keychain is macOSās built-in encrypted secrets store. Several keychains coexist
(System, login, iCloudā¦) but the login keychain is the one we want here as
it unlocks automatically at user login and stays unlocked while the session is
active. Entries are encrypted on disk and per-entry ACLs control which
processes can read them. Thereās a GUI app (Keychain Access) for browsing, but
everything we need is available from the built-in security command.
Add the three entries:
security add-generic-password -a "$USER" -s restic-repo-password -w
security add-generic-password -a "$USER" -s restic-b2-key-id -w
security add-generic-password -a "$USER" -s restic-b2-app-key -w
-a "$USER" sets the account name (used for searching). -s <name> is
the service name, which is what weāll look up by. -w without a value tells
security to prompt for the secret interactively, so it never lands in shell
history (it wonāt appear on-screen when typed either).
Read one back to confirm:
$ security find-generic-password -a "$USER" -s restic-repo-password -w
your-passphrase-here
If a mistake slips in (typo, wrong service name), delete the entry and re-add it:
security delete-generic-password -a "$USER" -s restic-repo-password
A Keychain access prompt may appear when a process tries to read the entry.
Typically the interactive security find-generic-password call above wonāt
prompt, because the same security binary created the entry and is already
in its ACL. The prompt is most likely to appear later, when launchdās
scheduled chain invokes security under a different responsible-code
context. Click Always Allow when it does, so subsequent reads go through
without further prompting. For interactive use from the terminal, clicking
Allow each time would also work, since thereās someone in front of the
screen to dismiss the prompt. For the nightly schedule we set up later,
Always Allow is mandatory: when the scheduled job runs while the laptop
is unattended, there is no one to answer the prompt, and the read fails. The
backup ends up blocked on a permission dialog.
From now on, no secret needs to be typed or pasted anywhere. The next section initializes the repository by reading the three values straight out of Keychain, and the automation we build later wires the same lookup into the scheduled job.
Initialize the restic repository
For a B2 setup, restic reads three environment variables: the passphrase
(RESTIC_PASSWORD), the B2 key ID (B2_ACCOUNT_ID), and the B2 key string
(B2_ACCOUNT_KEY). Pull them from Keychain into the current shell, then run the
init:
export RESTIC_PASSWORD=$(security find-generic-password -a "$USER" -s restic-repo-password -w)
export B2_ACCOUNT_ID=$(security find-generic-password -a "$USER" -s restic-b2-key-id -w)
export B2_ACCOUNT_KEY=$(security find-generic-password -a "$USER" -s restic-b2-app-key -w)
restic init --repo "b2:your-bucket:/your-prefix"
This creates the repository structure (a few keys/ entries and a
config file) inside the bucket. From now on every restic command needs
those three variables in the environment, and we can always rerun the
three export lines to populate them.
(The export VAR=$(...) pattern above is fine here because weāre in an
interactive shell where a failure in security is immediately visible. The
later wrapper script runs under set -e and has to be more careful. Weāll
get to that.)
Write a minimal profiles.toml
I keep mine at ~/Documents/backups/config/profiles.toml:
version = "2"
[global]
default-command = "snapshots"
initialize = false
[profiles.default]
repository = "b2:your-bucket:/your-prefix"
[profiles.default.backup]
source = ["/Users/your-username"]
exclude-file = "/Users/your-username/Documents/backups/config/excludes.txt"
exclude-caches = true
one-file-system = true
tag = ["scheduled"]
[profiles.default.forget]
keep-daily = 30
keep-monthly = 9999
prune = false
[profiles.default.prune]
[profiles.default.check]
read-data-subset = "5%"
A few comments on this file belowā¦
Retention: 30 daily snapshots, then one per calendar month kept indefinitely.
Restic doesnāt accept inf or all here, so 9999 months (about 833 years) is
the conventional way to express āforeverā.
one-file-system keeps the backup from following mounts into /Volumes.
exclude-caches honors the CACHEDIR.TAG
standard: any directory containing a
CACHEDIR.TAG file (a small text file with a specific magic signature) is
treated as a cache and skipped. Cargo, various build tools, and other utilities
drop this file in their cache directories automatically, so those get excluded
without having to enumerate each one.
Finally, the exclude-file directive points at a file we havenāt written yet
so thatās next!
Write excludes.txt
A starter list:
# Build artifacts
**/node_modules
**/.cache
**/.venv
**/__pycache__
# macOS system files restic can't read even with Full Disk Access
**/Library/Application Support/FileProvider
**/Library/Group Containers/group.com.apple.secure-control-center-preferences
**/Library/Group Containers/group.com.apple.CoreSpeech
**/Library/Daemon Containers/*/Data/com.apple.milod
**/Library/Biome
**/Library/DuetExpertCenter
# Big sparse files that explode logical size
**/Library/Group Containers/HUAQ24HBR6.dev.orbstack
**/Library/Containers/com.docker.docker
Full Disk Access (FDA, from here on) is the macOS permission that lets a process read protected user data. Itās granted per-binary and weāll actually set it up a bit later. But even with FDA, certain system-owned files remain unreadable to any user process, which is why those paths get excluded outright. Iām discovering new locations from time to time and adding them to this list, although it has been stable for a few weeks now.
Run a backup
resticprofile -n default --config ~/Documents/backups/config/profiles.toml backup
First run uploads the whole home directory (minus excludes) so plan for it: depending on data size and internet speed, anywhere from a few minutes to several hours. Subsequent runs upload only the diff and finish in minutes, if not seconds.
Verify
resticprofile -n default --config ~/Documents/backups/config/profiles.toml snapshots
Should list one snapshot tagged scheduled.
Restore a file
resticprofile -n default --config ~/Documents/backups/config/profiles.toml \
restore latest --target /tmp/restore --include ~/some/path
This dumps the matching path into /tmp/restore. Itās worth exploring this now
and making sure the restoration flow works before any incident occurs.
Prune cycle
resticprofile -n default --config ~/Documents/backups/config/profiles.toml forget
resticprofile -n default --config ~/Documents/backups/config/profiles.toml prune
forget applies the retention policy and marks snapshots as deleted. prune
reclaims the actual storage. Considering the modest data volume and B2ās
pricing for prune-style rewrites, we can afford to run it regularly.
We have a working baseline with manual backups. Now letās automate!
Make it run every night
We have at least two options on macOS: cron or launchd. cron most likely works fine (I havenāt tested it) but itās less integrated with macOS (cron jobs donāt appear in macOSās Login Items UI, for example). launchd seems like itās the right tool for this, but itās also where the platform-specific complexity lives. Resticprofile knows how to generate launchd plists. The rest of this section is the long list of things weāll have to fix on top of what it generates.
What resticprofile schedule gives us
Add a schedule directive to each command in profiles.toml:
[profiles.default.backup]
# ... existing config ...
schedule = "*-*-* 02:30:00"
schedule-permission = "user"
schedule-lock-mode = "default"
schedule-log = "/Users/your-username/Documents/backups/logs/backup.log"
schedule-capture-environment = false
[profiles.default.forget]
schedule = "*-*-* 03:00:00"
# ...
[profiles.default.prune]
schedule = "Sun *-*-* 04:00:00"
# ...
[profiles.default.check]
schedule = "Sun *-*-* 05:00:00"
# ...
The schedule strings are systemd-calendar format. Backup runs at 02:30 daily, forget at 03:00 daily, prune and check on Sundays. Install with:
resticprofile --config ~/Documents/backups/config/profiles.toml schedule --all
This generates four launchd plists, one per scheduled command (backup,
forget, prune, check), and drops them into ~/Library/LaunchAgents/,
the per-user directory where launchd looks for job definitions. It then
registers each one with the running launchd instance via launchctl bootstrap, so the jobs become active immediately (no logout / login
or reboot required). A plist here is just an Apple property-list XML file
describing when to run the job, what binary to execute, and the runtime
environment. Weāll dissect one in a moment. On Linux (where resticprofile
generates systemd units instead) weāre basically done at this point. On
macOS, various things still need fixing.
With the secrets kept out of the plist, the next question is how the
scheduled job picks them up at runtime. Restic and resticprofile still
need RESTIC_PASSWORD, B2_ACCOUNT_ID, and B2_ACCOUNT_KEY set in the
environment when they actually run. Weāve just made sure those values
arenāt sitting in the plist on disk. Something has to fetch them from
Keychain at exec time and put them in the environment, fresh, every time
the job fires. resticprofile doesnāt do this itself, so we wrap it in a
small script that does.
A wrapper script that bridges Keychain to environment
The wrapper
reads the three Keychain entries we stored earlier
(restic-repo-password, restic-b2-key-id, restic-b2-app-key),
exports them as the environment variables restic expects, then execs
resticprofile with whatever arguments it was called with. Both
interactive use (restic-wrap.sh -n default snapshots) and the
scheduled launchd plists call this same script, so thereās exactly one
place where the Keychain reads live.
#!/bin/zsh
set -e
export PATH="/opt/homebrew/bin:$PATH"
fetch_secret() {
local var_name="$1" service="$2" out
out="$(security find-generic-password -a "$USER" -s "$service" -w 2>&1)"
if [[ $? -ne 0 || -z "$out" ]]; then
echo "restic-wrap: keychain read failed for $service: $out" >&2
exit 11
fi
export "$var_name=$out"
}
fetch_secret RESTIC_PASSWORD restic-repo-password
fetch_secret B2_ACCOUNT_ID restic-b2-key-id
fetch_secret B2_ACCOUNT_KEY restic-b2-app-key
export RESTIC_CACHE_DIR="$HOME/Documents/backups/cache"
if [[ "$*" != *"--config"* ]]; then
set -- --config "$HOME/Documents/backups/config/profiles.toml" "$@"
fi
if [[ "$*" != *"--log"* ]]; then
for arg in "$@"; do
case "$arg" in
run-schedule)
break ;;
backup|forget|prune|check)
set -- --log "$HOME/Documents/backups/logs/$arg.log" "$@"
break ;;
*.backup|*.forget|*.prune|*.check)
set -- --log "$HOME/Documents/backups/logs/${arg##*.}.log" "$@"
break ;;
esac
done
fi
exec /opt/homebrew/bin/resticprofile "$@"
Three things worth pointing outā¦
PATH is rewritten because launchdās default doesnāt include
Homebrew. A launchd-spawned process inherits a minimal PATH
(/usr/bin:/bin:/usr/sbin:/sbin), with no /opt/homebrew/bin.
Resticprofile invokes restic by name, so the scheduled job fails with
cannot find restic until we prepend Homebrewās bin directory.
fetch_secret exists because of a shell footgun with set -e.
set -e does not propagate a command-substitution failure when it sits on
the right-hand side of export, local, declare, or readonly: the
builtinās own exit status (0 on a successful variable assignment) masks the
inner commandās failure. ShellCheck flags this as
SC2155 and it applies to bash and zsh
equally. An earlier version of the wrapper used:
export RESTIC_PASSWORD="$(security find-generic-password -a "$USER" -s restic-repo-password -w)"
When security failed (which it did, for reasons weāll get to), this
exported RESTIC_PASSWORD="" and let the script continue. Restic then
attempted to open the repository with an empty password and printed
Fatal: an empty password is not allowed by default.
--log and --config are injected only when the caller didnāt pass
them. Interactive use shouldnāt have to type the absolute config path. The
launchd plists already pass --config, and run-schedule already wires the
schedule-log directive, so injecting --log on the schedule code path would
double up and the notify hook (more on this later) would read the wrong section.
Patching the plists launchd will run
launchd reads a .plist file at ~/Library/LaunchAgents/<label>.plist
for each agent it manages, describing when to fire the job and what to
exec. resticprofile generates one per scheduled command, but the default
needs a few tweaks before itās usable for our setup. Hereās what
resticprofile gives us (notice the empty EnvironmentVariables, thanks to
schedule-capture-environment = false):
$ plutil -p ~/Library/LaunchAgents/local.resticprofile.default.backup.plist
{
"EnvironmentVariables" => {}
"Label" => "local.resticprofile.default.backup"
"LimitLoadToSessionType" => "Background"
"Program" => "/opt/homebrew/bin/resticprofile"
"ProgramArguments" => [
"/opt/homebrew/bin/resticprofile", "--no-prio", "--no-ansi",
"--config", "/Users/lucas/Documents/backups/config/profiles.toml",
"run-schedule", "backup@default"
]
"StartCalendarInterval" => [ { "Hour" => 2, "Minute" => 30 } ]
}
And a small Python patcher that rewrites it in place:
import plistlib, sys
path = sys.argv[1]
launcher = sys.argv[2]
wrapper = sys.argv[3]
with open(path, "rb") as f:
p = plistlib.load(f)
p["Program"] = launcher
p["ProgramArguments"][0] = launcher
p["ProgramArguments"].insert(1, wrapper)
p.pop("EnvironmentVariables", None)
p.pop("LimitLoadToSessionType", None)
p["StandardErrorPath"] = "/Users/.../logs/launchd-backup.err.log"
p["StandardOutPath"] = "/Users/.../logs/launchd-backup.out.log"
with open(path, "wb") as f:
plistlib.dump(p, f)
Invoke once per agent, passing the launchd plist, the launcher binary inside the bundle (which doesnāt exist yet, weāll build it in the next sub-section), and the wrapper script:
python patch.py \
"$HOME/Library/LaunchAgents/local.resticprofile.default.backup.plist" \
"$HOME/Documents/backups/bundles/Hercules Backup.app/Contents/MacOS/Hercules Backup" \
"$HOME/Documents/backups/bin/restic-wrap.sh"
One line in the patcher deserves a callout:
p.pop("LimitLoadToSessionType", None). resticprofile sets that key to
"Background" by default, which would load the agent into the
user/<uid> launchd domain. That domain has no access to the userās
login Keychain, so the wrapperās security find-generic-password calls
would return empty strings. Stripping the key lets us bootstrap each
agent into gui/<uid> instead. Once the bundle (next sub-section) is
in place, reload each agent with:
launchctl bootout "user/$UID/$LABEL" 2>/dev/null || true
launchctl bootout "gui/$UID/$LABEL" 2>/dev/null || true
launchctl bootstrap "gui/$UID" "$PLIST"
After patching, the plist references a launcher binary that doesnāt exist yet. We build that next!
The .app bundle, AMFI, and codesigning
This is the most goofy-looking 𤪠part of the setup. A backup script ending up
inside an .app bundle, codesigned, feels disproportionate to the task. I
tried to avoid this as much as possible but after a lot of iterations, I
actually donāt think there is a way around it.
The rest of this sub-section walks through, in order: the two concrete issues we hit when we hand the manual setup to launchd, the macOS security mechanisms behind those issues, the architecture to address these issues, and finally the build and signing steps.
The Issues
-
launchd canāt read the wrapper script from
~/Documents/. The scheduled run dies withcan't open input file: .../restic-wrap.sh, even though permissions and ownership are correct and the same command works fine from a Terminal session. -
launchd refuses to run the wrapper as
Program. Pointing the launchd plistāsProgramatrestic-wrap.shkills the process at launch withOS_REASON_CODESIGNING, before anything in the script gets to run.
Security Mechanisms
-
TCC causes (1). TCC mediates access to privacy-sensitive resources, including the protected home folders (
~/Documents/,~/Desktop/,~/Downloads/) and the broader Full Disk Access category (FDA, the one we end up using here because it covers everything under$HOME). Terminal can hold an FDA grant in System Settings, which is the case on my machine, hence why manual runs work. launchd has no equivalent option: launchd itself isnāt TCC-grantable, and a process it spawns has no signed code identity for TCC to attach a grant to unless we give it one. That gap is exactly what the bundle architecture fills. The important property: TCC grants are keyed by code identity (an expression derived from the binaryās signature, typically referencing its cdhash, a cryptographic hash of the signed code pages that fingerprints the exact bytes of the binary) rather than path, and the identity TCC checks is the responsible process at the top of the exec chain, not the syscalling process. We need a stable, signed code identity to grant FDA to, sitting at the top of the chain. -
macOS code-signing enforcement causes (2). The launchd
Programhas to be a signed Mach-O. Pointing it at a shell script (which lacks a Mach-O signature) is rejected at exec time withOS_REASON_CODESIGNINGbefore the script runs. Apple-signed binaries and properly user-signed binaries (including ad-hoc) pass.
The architecture which solves all the problems above is a small .app
bundle per scheduled command, ad-hoc-codesigned, containing a 20-line
Mach-O launcher whose only job is to exec /bin/zsh with the wrapper
script as argument. The bundles live at ~/Documents/backups/bundles/
alongside the wrappers (bin/), configs (config/), and logs (logs/).
Thatās just because I like having everything in one place, not a constraint:
launchdās exec of the launcher binary isnāt TCC-gated according to my testing.
This architecture works for the following reasons:
-
(1) FDA stability + chain propagation. The bundleās cdhash is a function of its signed contents, and we control when those change.
brew upgrademay update restic, resticprofile or even zsh but it does not touch the bundle, so the cdhash holds and the FDA grant we attach to it in System Settings survives upgrades. At runtime, TCC walks up the exec chain (launcher ā zsh ā resticprofile ā restic) and finds the bundleās launcher as the responsible process, so the FDA grant applies to every read in the chain: zsh opening the wrapper script, resticprofile readingprofiles.tomlandexcludes.txt, restic walking$HOME. One grant on the bundle covers all of it. -
(2) Every Mach-O in the chain satisfies code-signing enforcement. The launchd
Programis now a real signed Mach-O, so itās accepted at launch. AMFI then runs on every subsequent process execution in the chain: zsh (Apple-signed), resticprofile and restic (at least ad-hoc signed) all pass without any extra work from us. The wrapper script doesnāt get an AMFI check because zsh reads it as data via something likeopen(), which is not a proper process execution.
Now that we know what we want, letās build the bundle! Layout for the
backup agent (the other three are identical except for names):
Hercules Backup.app/
āāā Contents/
āāā Info.plist
āāā MacOS/
āāā Hercules Backup
The launcher binary is twenty lines of C:
#include <unistd.h>
int main(int argc, char **argv) {
(void)argc;
argv[0] = "/bin/zsh";
execv("/bin/zsh", argv);
return 127;
}
Compiled for Apple Silicon (swap or add -arch flags for other
architectures):
clang -O2 -arch arm64 -o "Hercules Backup" bin/launcher.c
The launcher replaces argv[0] with /bin/zsh and execs zsh. The plistās
ProgramArguments is arranged as [<launcher>, <wrapper-script>, --no-prio, --no-ansi, --config, ..., run-schedule, backup@default], so
zsh inherits a modified argv that reads as a normal zsh <wrapper> <args...> invocation and runs the wrapper.
Every .app bundle carries a metadata file at Contents/Info.plist,
and macOS wonāt recognize the directory as a bundle without one. Itās
where the bundle declares its identifier (used by LaunchServices and TCC
to refer to the bundle) and which binary inside Contents/MacOS/ to
treat as the executable. Ours is minimal:
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key> <string>com.your-name.hercules.backup</string>
<key>CFBundleExecutable</key> <string>Hercules Backup</string>
<key>CFBundlePackageType</key> <string>APPL</string>
</dict>
</plist>
Now that we have our bundles ready, letās sign them! The example
commands below are for the backup bundle. forget, prune, and check
get the same treatment with their respective identifiers and paths.
Ad-hoc codesigning gives the bundle a valid Designated Requirement that TCC can record and match against at runtime. Without signing, the DR is malformed: TCC writes the grant but every subsequent lookup fails to match, and the visible symptom is a fresh stack of āX wants to access files in your Documents folderā prompts every morning even after clicking Allow the day before.
codesign --force --deep --sign - \
--identifier "com.your-name.hercules.backup" \
"$HOME/Documents/backups/bundles/Hercules Backup.app"
--sign - is the ad-hoc form (no Apple Developer certificate needed).
--deep signs everything inside the bundle, including the inner launcher
Mach-O. --identifier pins both the outer bundle and the inner Mach-O to
the same identifier (com.your-name.hercules.backup). Without it, the
outer bundle would still inherit its identifier from CFBundleIdentifier
in Info.plist, but the inner Mach-O would get one derived from its
filename, and the two pieces of the bundle would end up signed under
different identities. TCC matches against the responsible processās
signature, so keeping the identity consistent across the bundle and its
contents is what makes the FDA grant stick.
At this point each scheduled command has its own signed .app bundle,
each launchd plist points at the launcher inside its bundle, and one FDA
grant per bundle in System Settings is enough to unblock the whole exec
chain (launcher ā zsh ā resticprofile ā restic) across
~/Documents/.
To wire up those FDA grants: open System Settings ā Privacy & Security ā Full Disk Access, click the + button, and add each of the four .app bundles
from ~/Documents/backups/bundles/ in turn. macOS asks for Touch ID or the
admin password the first time. The grant is keyed to the bundleās Designated
Requirement, so it survives reboots and brew upgrade runs. However,
rebuilding a bundle invalidates it (the cdhash changes) and the bundle has to
be re-added.
Sleep, DarkWake, and the lock race
macOS doesnāt wake a sleeping Mac to fire a user-agent schedule, so the logical conclusion is:
If the laptop is asleep at 02:30, the job is silently skipped, and the next firing is the next time the laptop is awake past the next scheduled time.
At least thatās what seemed logical to me and what I thought happened. But it turns out MBPs actually sleep with one eye open!
On macOS, closing the lid (or letting the screen turn off on battery)
puts the machine into a layered sleep state. It alternates between
deep sleep and short DarkWake intervals every 15-30 minutes. Each DarkWake
lasts a few seconds to half a minute, brings the CPU online, services
background tasks (including launchd schedules!), and goes back to sleep. The
display stays off the entire time. We can see it in pmset -g log:
$ pmset -g log | grep -E "Sleep|DarkWake" | tail -8
2026-05-14 02:14:33 +0200 Sleep : Entering Sleep state Using Batt (Charge:91%)
2026-05-14 02:29:58 +0200 DarkWake from Deep Idle [CDNP] : due to RTC/SleepService Using Batt (Charge:91%) 5 secs
2026-05-14 02:30:03 +0200 Sleep : 'Sleep Service Back to Sleep' (917 secs)
2026-05-14 02:45:20 +0200 DarkWake from Deep Idle [CDNP] : due to RTC/SleepService Using Batt (Charge:90%) 8 secs
2026-05-14 02:45:28 +0200 Sleep : 'Sleep Service Back to Sleep' (892 secs)
2026-05-14 03:00:20 +0200 DarkWake from Deep Idle [CDNP] : due to RTC/SleepService Using Batt (Charge:90%) 6 secs
2026-05-14 03:00:26 +0200 Sleep : 'Sleep Service Back to Sleep' (914 secs)
2026-05-14 03:15:40 +0200 DarkWake from Deep Idle [CDNP] : due to RTC/SleepService Using Batt (Charge:90%) 4 secs
This shows about one hour of the laptop āasleepā: four DarkWake intervals of a few seconds each, roughly 15 minutes apart, with deep sleep in between. The 02:30 DarkWake is when the scheduled backup fires.
So at 02:30, lid closed, the backup does fire. It just fires during the next DarkWake, runs for two to twenty seconds, and gets suspended (not killed) when the laptop goes back to sleep. The next DarkWake resumes it. Restic accumulates active CPU time across many such slices.
The interesting consequence is the lock race. Backup is scheduled at 02:30, forget at 03:00. On a Mac thatās awake at 02:30, backup finishes well under 30 minutes and the schedule is fine. With DarkWake-fragmented execution, backup might still be running (suspended, mostly) at 03:00. Both restic and resticprofile use locks but the default behavior is āfail immediately if lockedā which is not ideal if the laptop is asleep or if a backup takes longer than usual for whatever reason.
The fix is two directives, as there are two locks:
[profiles.default.backup]
lock-wait = "4h" # resticprofile's profile lock
retry-lock = "4h" # passed through to restic as --retry-lock=4h
lock-wait is a resticprofile directive that controls how long it waits for
its own profile lock (which prevents two resticprofile invocations from
running the same profile concurrently). retry-lock is not a resticprofile
directive: keys in a command section that resticprofile doesnāt recognize are
forwarded to restic as command-line flags, so retry-lock = "4h" becomes
--retry-lock=4h on the underlying restic backup invocation. That flag
covers resticās repository lock, which is a different lock living inside the
B2 bucket. Both have to be set, on every command that touches the repo,
because there is no āthe job that holdsā and āthe job that waitsā: on wake,
either can hold and either can wait.
One last related gotcha. schedule-lock-mode has three valid values:
default, fail, ignore. default is the waiting mode: it waits up to
the configured lock-wait duration before giving up. fail aborts
immediately on a lock conflict. ignore skips resticprofileās lock entirely
(resticās repository lock is still honored). Leave it on default so the
lock-wait / retry-lock directives above can do their job.
Operational polish
This section covers notifications as well as some cosmetics.
Notifications
The system has been running cleanly for two nights when I realize I have no idea whether it has run at all. The log directory is the only signal, and I donāt really want to check logs every morning. š¬
I run a small JSON-receiver gateway on a hosted box. The
restic-notify.sh
script POSTs to it with a Bearer token (also stored in Keychain, this thing is
super convenient!) and the gateway pushes to Telegram. Any HTTP-accepting
notifier works: ntfy.sh is the easiest drop-in, Slack
webhooks work, Pushover works, an email-by-HTTP service works, etc.
Resticprofile allows us to run something after the success or the failure of any command:
[profiles.default.backup]
run-after = ["/Users/your-username/Documents/backups/bin/restic-notify.sh success"]
run-after-fail = ["/Users/your-username/Documents/backups/bin/restic-notify.sh failure"]
Two things worth noting:
run-after only fires on success while run-after-fail only on failure.
One might be tempted to chain a check in run-after as a post-backup
verification. But if check then fails, the notification doesnāt fire and we
lose visibility. Notifications should be the last thing in any chain, never
dependent on a fragile predecessor. Itās best to run check on its own
schedule.
Exit code 3 from restic backup is not a failure. Restic distinguishes
three exit codes:
0: success1: generic failure (canāt open repo, network down, credentials wrong)3: āsnapshot was saved, but some source files couldnāt be readā
The third shows up regularly on macOS, because there are system files that even FDA-granted processes cannot read, as we discussed before.
Resticprofile treats exit 3 as a non-zero exit and fires run-after-fail,
so the notify script needs to recognize this case and render it as a
warning rather than a failure. Something like:
if outcome == "failure" and command == "backup" and err_exit == "3":
level = "warning"
title = f"Restic {command} completed with warnings"
Login Items row names
By default, the System Settings ā General ā Login Items & Extensions pane
labels each launchd agent row with the basename of the plistās Program. With
four agents all pointing at the same launcher path, that pane shows four
identical rows with no way to tell backup from forget from prune from check.
The toggle next to each row becomes useless: turning one off is basically a
coin flip on which scheduled command stops running.
Two small additions on top of what we built above fix this. First, add
CFBundleName to each bundleās Contents/Info.plist:
<key>CFBundleName</key> <string>Hercules Backup</string>
Second, append one line to the plist patcher from earlier, so that
each agentās ~/Library/LaunchAgents/<label>.plist carries an
AssociatedBundleIdentifiers pointing at the matching bundle:
p["AssociatedBundleIdentifiers"] = ["com.your-name.hercules.backup"]
macOS resolves the AssociatedBundleIdentifiers to the bundle, reads
the bundleās CFBundleName, and renders that as the row label. Four
distinctly-named bundles, four named rows, four working toggles.
Custom icons
I wanted to set up custom icons in place of the generic āexecā macOS icon for our .app bundles. These icons are visible in multiple places: in Finder when browsing the folder containing the bundles, in the āOpen at Loginā Settings window, in the TCC grants window, etc.
In the end I got something which works everywhere, except for the Open at Login window. I donāt know why but I never managed to override this icon. I even ended up reversing a commercial .app bundle which manages to display a custom icon in this window (Tailscale) and the only difference I can see is that the commercial binary has been signed using a paid certificate you get by subscribing to Apple Developer. Iām not ready to pay just to customise icons (Iām not even sure thatās the root cause) so I just gave up.
Anyway, here is how to customise the icon of an .app bundle programmatically, starting from an emojiā¦
In order to avoid designing (or stealing) something, I decided to start from an
emoji and render š¾ into a 1024x1024 PNG, then assemble an .icns from it.
Here is emoji-to-icns.sh,
a zsh wrapper that embeds a small Swift program and leverages various
built-in CLI tools to produce the final .icns:
#!/bin/zsh
# emoji-to-icns.sh <emoji> <output.icns>
set -euo pipefail
emoji="$1"; out="$2"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
cat > "$tmp/render.swift" <<'SWIFT'
import AppKit
let emoji = CommandLine.arguments[1]
let outPath = CommandLine.arguments[2]
let size: CGFloat = 1024
let image = NSImage(size: NSSize(width: size, height: size))
image.lockFocus()
let font = NSFont(name: "Apple Color Emoji", size: size * 0.8)!
let attrs: [NSAttributedString.Key: Any] = [.font: font]
let s = emoji as NSString
let bbox = s.size(withAttributes: attrs)
s.draw(at: NSPoint(x: (size - bbox.width) / 2,
y: (size - bbox.height) / 2),
withAttributes: attrs)
image.unlockFocus()
let tiff = image.tiffRepresentation!
let rep = NSBitmapImageRep(data: tiff)!
let data = rep.representation(using: .png, properties: [:])!
try data.write(to: URL(fileURLWithPath: outPath))
SWIFT
swift "$tmp/render.swift" "$emoji" "$tmp/icon_1024.png"
set_dir="$tmp/icon.iconset"
mkdir "$set_dir"
for s in 16 32 128 256 512; do
sips -z $s $s "$tmp/icon_1024.png" --out "$set_dir/icon_${s}x${s}.png"
sips -z $((s*2)) $((s*2)) "$tmp/icon_1024.png" --out "$set_dir/icon_${s}x${s}@2x.png"
done
cp "$tmp/icon_1024.png" "$set_dir/icon_512x512@2x.png"
iconutil -c icns "$set_dir" -o "$out"
Run it with the emoji and the destination path:
./emoji-to-icns.sh š¾ icon.icns
Drop icon.icns into the bundleās Contents/Resources/ and reference it
from Info.plist:
<key>CFBundleIconFile</key> <string>icon.icns</string>
The repo
Everything in this post is published at
github.com/lucas-santoni/macos-backup-restic-b2.
On my Mac it lives at ~/Documents/backups/, but the path doesnāt matter.
The repo only commits the templates and the per-machine example:
macos-backup-restic-b2/
āāā README.md
āāā site.conf.example # per-machine values, copy ā site.conf
āāā bin/
ā āāā configure.sh # renders bin/*.tmpl + config/*.tmpl
ā āāā install-bundles.sh.tmpl # builds + signs the four .app bundles
ā āāā schedule-install.sh.tmpl # idempotent launchd installer/patcher
ā āāā restic-wrap.sh.tmpl # Keychain ā env ā resticprofile
ā āāā restic-notify.sh.tmpl # posts notification JSON via gateway
ā āāā launcher.c # tiny Mach-O launcher stub
ā āāā emoji-to-icns.sh # renders š¾ to icon.icns
ā āāā test-notify.sh # fixture-driven smoke test for notify
āāā config/
āāā profiles.toml.tmpl # resticprofile schedules, retention, hooks
āāā excludes.txt # backup exclusions
Everything else is generated locally and gitignored: site.conf itself,
the rendered .sh and .toml files next to their templates, the four
.app bundles built into bundles/, plus logs/ and cache/. This
matters because site.conf contains values you donāt want on GitHub
(B2 bucket URL, bundle ID prefix, notification endpoint) and the rendered
scripts inline those values verbatim.
site.conf itself holds the five values that vary per machine (username,
B2 bucket URL, bundle ID prefix, bundle display-name prefix, notification
endpoint). bin/configure.sh renders the .tmpl files against those
values, producing the runnable scripts and configs.
To adapt: clone, copy site.conf.example to site.conf, edit, run
bin/configure.sh, then the two install scripts in order
(install-bundles.sh, schedule-install.sh).