The Mac Desk Notes on macOS, the small stuff included
Guides

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.

· The Mac Desk · 1 min read

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