Run scheduled jobs with launchd, not cron
cron still works on macOS, right up until the job touches a protected folder. launchd is the supported way and the syntax is not hard.
A backup script that runs at 8:30 every morning is four lines of plist and three commands. The reason to bother is that cron on macOS runs in a context with no access to Documents, Desktop, Downloads or your photo library, and the job fails with a permission error that looks like a broken script.
The plist
Save this as ~/Library/LaunchAgents/local.backup.notes.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>local.backup.notes</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>/Users/you/bin/backup-notes.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>8</integer>
<key>Minute</key><integer>30</integer>
</dict>
<key>StandardOutPath</key>
<string>/tmp/backup-notes.log</string>
<key>StandardErrorPath</key>
<string>/tmp/backup-notes.err</string>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
The label must match the file name and stay unique; the reverse-domain style is a convention, not a requirement.
Load it
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.backup.notes.plist
launchctl print gui/$(id -u)/local.backup.notes | head -20
bootstrap replaces the old launchctl load. print shows the state, the last
exit code and the next scheduled run, which is the fastest way to confirm that
launchd actually parsed your file.
To run it once without waiting for the clock:
launchctl kickstart -k gui/$(id -u)/local.backup.notes
-k kills a running instance first. To remove the job:
launchctl bootout gui/$(id -u)/local.backup.notes
Logs are not optional
StandardOutPath and StandardErrorPath are the difference between “it did not
run” and “it ran and failed at line 12”. Point them at files in /tmp or
~/Library/Logs, then read them:
tail -n 40 /tmp/backup-notes.err
If the log file is never created, launchd did not start the job at all: check
launchctl print and the plist syntax with plutil -lint.
Permissions
The job runs as you, but the process it starts inherits the privacy permissions
of the binary named in ProgramArguments. If your script reads from Documents,
grant Full Disk Access to /bin/sh in System Settings > Privacy & Security.
That is a blunt grant, and it is exactly why the script should also live in a
folder you control rather than in a shared location.
StartCalendarInterval fires when the Mac is awake. A laptop asleep at 8:30
runs the job at the next wake, once, not four times to catch up. For work that
must survive sleep, StartInterval with a short period plus a timestamp check
inside the script is more predictable.
Keep reading
What actually eats your disk space, measured
Four commands that show where the gigabytes went, before you install anything that promises to find them.
Homebrew on Apple silicon, without the usual PATH mess
The installer gets the files right and leaves your shell half-configured. Five lines fix it for good.