macOS uses launchd instead of systemd or SysV init. Every long-running service — Apple's, third-party, and yours — is a .plist managed by launchctl. This guide covers loading, unloading, listing, and writing your own.
Prerequisites
- macOS 12 Monterey or newer.
- A script or binary you want to run as a daemon — we will use
/usr/local/bin/skyline-collectoras the example.
Step 1: Understand the plist locations
| Path | Scope |
| --- | --- |
| /System/Library/LaunchDaemons/ | Apple system daemons. Don't touch. |
| /Library/LaunchDaemons/ | Third-party, runs as root, on boot. |
| /Library/LaunchAgents/ | Third-party, runs per-user, on user login. |
| ~/Library/LaunchAgents/ | Personal, current user only. |
The two you actually write: /Library/LaunchDaemons/ for system services, ~/Library/LaunchAgents/ for personal jobs.
Step 2: Write a LaunchDaemon plist
/Library/LaunchDaemons/com.skyline.collector.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>com.skyline.collector</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/skyline-collector</string>
<string>--config</string>
<string>/etc/skyline/collector.yaml</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/skyline-collector.out.log</string>
<key>StandardErrorPath</key>
<string>/var/log/skyline-collector.err.log</string>
<key>UserName</key>
<string>skyline</string>
</dict>
</plist>
LaunchDaemons must be root-owned with 644 perms:
sudo chown root:wheel /Library/LaunchDaemons/com.skyline.collector.plist
sudo chmod 644 /Library/LaunchDaemons/com.skyline.collector.plist
Step 3: Load and start
The modern syntax uses bootstrap + kickstart (macOS 10.10+):
sudo launchctl bootstrap system /Library/LaunchDaemons/com.skyline.collector.plist
sudo launchctl kickstart -k system/com.skyline.collector
To unload:
sudo launchctl bootout system /Library/LaunchDaemons/com.skyline.collector.plist
Step 4: Inspect status
# List one
sudo launchctl print system/com.skyline.collector | head -30
# List everything
sudo launchctl list | grep -v com.apple | head
# Find by partial name
sudo launchctl list | grep skyline
Look at the third column of launchctl list — it is the last exit code. 0 is healthy; anything else means the daemon crashed.
Step 5: Trigger on schedule (the cron equivalent)
StartCalendarInterval runs at a specific time:
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key> <integer>2</integer>
<key>Minute</key> <integer>30</integer>
</dict>
StartInterval runs every N seconds:
<key>StartInterval</key>
<integer>300</integer>
WatchPaths triggers when a file changes:
<key>WatchPaths</key>
<array>
<string>/var/spool/skyline/queue/</string>
</array>
Step 6: Per-user LaunchAgents
For personal jobs (auto-start an SSH tunnel when you log in):
mkdir -p ~/Library/LaunchAgents
cat > ~/Library/LaunchAgents/com.you.tunnel.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>Label</key><string>com.you.tunnel</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/ssh</string>
<string>-N</string>
<string>-L</string><string>5432:db.corp.example.sa:5432</string>
<string>jumphost</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
</dict></plist>
EOF
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.you.tunnel.plist
Verify
sudo launchctl print system/com.skyline.collector
sudo log show --predicate 'subsystem == "com.skyline.collector"' --last 1h
tail -f /var/log/skyline-collector.err.log
Conclusion
launchd is more expressive than cron — calendar, interval, file-watch, plus auto-restart with crash-loop detection. The plist XML is verbose, but launchctl bootstrap/bootout/kickstart are the only three verbs you need day-to-day.
Next steps
- For package management see Homebrew on macOS.
- For remote access lock down SSH on macOS.
- For the Linux counterpart see systemd services.
Comments
0 total · 0 threads