Introduction
launchd is macOS’s service management framework — the equivalent of systemd on Linux. It starts and manages background processes, daemons, and agents. To run a program automatically at startup (before or after login), you create a property list (.plist) file and register it with launchctl.
LaunchDaemons vs LaunchAgents
| LaunchDaemons | LaunchAgents | |
|---|---|---|
| Location | /Library/LaunchDaemons/ |
/Library/LaunchAgents/ or ~/Library/LaunchAgents/ |
| Runs as | root (or specified user) | Logged-in user |
| Starts when | System boot (before login) | User logs in |
| Requires sudo | Yes | No |
| Use for | System services, servers | User-level apps, tools |
LaunchDaemons start at boot, run as root, have no GUI access — use for system services like web servers or databases. LaunchAgents start at user login, run as the user, can access the GUI — use for user-facing tools. A service needing GUI access will fail silently in a LaunchDaemon; a service that must start before login will never load as a LaunchAgent.
Naming Conventions
Use reverse domain notation for plist filenames and Label values:
com.example.myapp.plist → Label: com.example.myapp
org.mycompany.myservice.plist → Label: org.mycompany.myservice
This prevents naming conflicts — two plists with the same Label will fail to load. Always match the filename to the Label, use lowercase letters and dots only, and keep Labels descriptive but short.
Creating a LaunchDaemon (System-Wide Service)
Step 1: Create the plist file
sudo vim /Library/LaunchDaemons/com.example.myprogram.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.example.myprogram</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/myprogram</string>
<string>--config</string>
<string>/etc/myprogram/config.yaml</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/myprogram.log</string>
<key>StandardErrorPath</key>
<string>/var/log/myprogram.error.log</string>
<key>WorkingDirectory</key>
<string>/var/lib/myprogram</string>
</dict>
</plist>
Step 2: Set correct permissions
sudo chown root:wheel /Library/LaunchDaemons/com.example.myprogram.plist
sudo chmod 644 /Library/LaunchDaemons/com.example.myprogram.plist
Step 3: Load the service
# Load and start immediately
sudo launchctl load /Library/LaunchDaemons/com.example.myprogram.plist
# macOS 10.11+ (El Capitan and later) — preferred
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.myprogram.plist
Creating a LaunchAgent (User-Level Service)
For user-level services (no sudo required):
mkdir -p ~/Library/LaunchAgents
vim ~/Library/LaunchAgents/com.example.mytool.plist
<key>Label</key><string>com.example.mytool</string>
<key>ProgramArguments</key>
<array>
<string>/Users/username/bin/mytool</string>
<string>--daemon</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/Users/username/Library/Logs/mytool.log</string>
<key>StandardErrorPath</key><string>/Users/username/Library/Logs/mytool.error.log</string>
# Load (no sudo needed for user agents)
launchctl load ~/Library/LaunchAgents/com.example.mytool.plist
Managing Services with launchctl
# Load a service (registers and starts it)
sudo launchctl load /Library/LaunchDaemons/com.example.myprogram.plist
# Unload a service (stops and unregisters it)
sudo launchctl unload /Library/LaunchDaemons/com.example.myprogram.plist
# Start a loaded service
sudo launchctl start com.example.myprogram
# Stop a running service
sudo launchctl stop com.example.myprogram
# List all loaded services
launchctl list
# Check a specific service
launchctl list | grep myprogram
# Print service info
sudo launchctl print system/com.example.myprogram
Modern vs Legacy launchctl
macOS 10.11 (El Capitan) introduced the bootstrap/bootout command set. The old load/unload commands still work but the new set is preferred.
Legacy (not recommended for new work):
sudo launchctl load /Library/LaunchDaemons/com.example.myprogram.plist
sudo launchctl unload /Library/LaunchDaemons/com.example.myprogram.plist
Modern (macOS 10.11+, preferred):
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.myprogram.plist
sudo launchctl bootout system/com.example.myprogram
sudo launchctl enable system/com.example.myprogram
sudo launchctl disable system/com.example.myprogram
sudo launchctl kickstart -k system/com.example.myprogram
Migration path: Replace load → bootstrap system, unload → bootout system. Use kickstart -k instead of start for forcing a start.
Target domains: system for daemons, gui/UID for user agents (e.g. sudo launchctl bootstrap gui/501 ~/Library/LaunchAgents/foo.plist), user for current user session.
Viewing Logs
# Follow the log in real-time
tail -f /var/log/myprogram.log
tail -f /var/log/myprogram.error.log
# View system logs for the service
log show --predicate 'process == "myprogram"' --last 1h
# Stream live logs
log stream --predicate 'process == "myprogram"'
Debugging with Unified Logging
Beyond stdout/stderr, launchd services log to Apple’s unified log system:
# Stream launchd messages or filter for your service
log stream --predicate 'subsystem == "com.apple.launchd"'
log stream --predicate 'eventMessage contains "com.example.myprogram"'
# View logs from the last boot
log show --predicate 'subsystem == "com.apple.launchd"' --last boot
# Print full service state and debug
sudo launchctl print system/com.example.myprogram
sudo launchctl debug system/com.example.myprogram --stdout
plutil -lint /Library/LaunchDaemons/com.example.myprogram.plist
Common plist errors
| Error | Likely cause |
|---|---|
| “Service is disabled” | Previously disabled. Re-enable with launchctl enable. |
| “Domain for gui/501 is not ready” | Agent loaded before user session is ready. |
| “Path had bad ownership/permissions” | Daemon plist must be root:wheel, agent plist user:staff. |
| “Could not find service” | Label doesn’t match filename. Verify with plutil -lint. |
launchd vs cron
Before launchd, macOS (and classic Unix) used cron for scheduled tasks. launchd is now the preferred replacement. Here is how they compare:
| Feature | launchd | cron |
|---|---|---|
| Scheduling | StartInterval (seconds) or StartCalendarInterval (cron-like) |
Five-field crontab syntax |
| Dependency management | KeepAlive, WatchPaths, QueueDirectories |
None (runs and exits) |
| Auto-restart on crash | Built-in (KeepAlive) |
Not supported |
| Logging | Structured StandardOutPath/StandardErrorPath + unified log |
Mail-based (MAILTO) |
| Persists across OS updates | Yes (in /Library/LaunchDaemons/) |
Crontabs can be overwritten |
| Per-user scheduling | LaunchAgents (~/Library/LaunchAgents/) |
Each user has their own crontab |
| Resource limits | SoftResourceLimits / HardResourceLimits |
Not supported |
| On-demand / event-driven | WatchPaths, QueueDirectories, StartOnMount |
Not supported |
When to use each
Use launchd for services that stay running (KeepAlive), need restart on failure, are event-driven (WatchPaths), or require resource limits. Use it for anything new on modern macOS.
Cron is still useful for portable scripts across Linux/BSD/macOS, legacy infrastructure, or simple one-off scheduled tasks with no monitoring needs.
Note: macOS still ships
cronfor compatibility, but Apple recommendslaunchdfor all new work.
Advanced plist Options
Run on a Schedule (Cron-like)
<!-- Run every hour -->
<key>StartInterval</key>
<integer>3600</integer>
<!-- Run at specific times (like cron) -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>2</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
<!-- Run at multiple times -->
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>0</integer>
</dict>
<dict>
<key>Hour</key><integer>17</integer>
<key>Minute</key><integer>0</integer>
</dict>
</array>
Binary Path Issues
launchd runs with a minimal PATH — typically just /usr/bin:/bin. Commands like brew, git, or python3 that work in Terminal will fail in a launchd job because /usr/local/bin and /opt/homebrew/bin are missing.
Always use absolute paths in ProgramArguments and set PATH via EnvironmentVariables:
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>APP_ENV</key>
<string>production</string>
</dict>
If your script calls other commands, they inherit this PATH — without it you will see “command not found” errors in the log.
Conditional KeepAlive
<!-- Only restart on non-zero exit code -->
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<!-- Restart with throttle (wait 10s between restarts) -->
<key>ThrottleInterval</key>
<integer>10</integer>
Run as Specific User
<!-- For LaunchDaemons only -->
<key>UserName</key>
<string>www</string>
<key>GroupName</key>
<string>www</string>
WatchPaths — React to File Changes
Trigger the job whenever a file or directory changes — useful for config reloads, dotfile sync, or triggering backups:
<key>WatchPaths</key>
<array>
<string>/etc/myapp/config.yaml</string>
<string>/Users/username/.dotfiles</string>
</array>
Combine with ThrottleInterval to avoid rapid restarts from frequent writes:
<key>ThrottleInterval</key>
<integer>30</integer>
Resource Limits
Prevent runaway processes from consuming system resources:
<key>SoftResourceLimits</key>
<dict>
<key>Core</key>
<integer>0</integer>
</dict>
Available limit keys: Core (core dump size), CPU (seconds), Data (memory), FileSize, NumberOfFiles, NumberOfProcesses, Stack.
Example — restrict a backup script to 2 GB memory, 1 GB file writes, 256 open files:
<key>HardResourceLimits</key>
<dict>
<key>Data</key>
<integer>2147483648</integer>
<key>FileSize</key>
<integer>1073741824</integer>
<key>NumberOfFiles</key>
<integer>256</integer>
</dict>
Real-World Examples
1. Homebrew Service (nginx)
brew services start nginx creates a LaunchAgent at ~/Library/LaunchAgents/homebrew.mxcl.nginx.plist:
<key>Label</key><string>homebrew.mxcl.nginx</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/opt/nginx/bin/nginx</string>
<string>-g</string>
<string>daemon off;</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>WorkingDirectory</key><string>/opt/homebrew/var</string>
Notice the absolute paths and KeepAlive — this is the pattern for any long-running server.
2. CI Runner Agent at Boot
A GitHub Actions or GitLab runner as a LaunchDaemon:
<key>Label</key><string>com.example.gitlab-runner</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/gitlab-runner</string>
<string>run</string>
<string>--working-directory</string>
<string>/var/gitlab-runner</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/var/log/gitlab-runner.log</string>
<key>StandardErrorPath</key><string>/var/log/gitlab-runner.error.log</string>
<key>UserName</key><string>gitlab-runner</string>
3. Periodic Backup Script
Run restic backup every night at 2 AM:
<key>Label</key><string>com.example.nightly-backup</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/restic</string>
<string>backup</string>
<string>/Users/username/Documents</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>2</integer>
<key>Minute</key><integer>0</integer>
</dict>
<key>StandardOutPath</key><string>/var/log/backup.log</string>
<key>StandardErrorPath</key><string>/var/log/backup.error.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>RESTIC_REPOSITORY</key>
<string>s3:https://s3.amazonaws.com/mybackup</string>
</dict>
4. Git Sync for Dotfiles or Obsidian Vault
Sync a notes vault via git every 5 minutes using StartInterval + WatchPaths:
<key>Label</key><string>com.example.git-sync</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/git</string>
<string>-C</string>
<string>/Users/username/vault</string>
<string>push</string>
</array>
<key>StartInterval</key><integer>300</integer>
<key>WatchPaths</key>
<array><string>/Users/username/vault</string></array>
<key>StandardOutPath</key><string>/Users/username/Library/Logs/git-sync.log</string>
<key>StandardErrorPath</key><string>/Users/username/Library/Logs/git-sync.error.log</string>
Register as a LaunchAgent:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.git-sync.plist
Troubleshooting
Service won’t start
# Check exit code (non-zero = error)
launchctl list | grep myprogram
# Output: PID ExitCode Label
# -1 means not running, exit code shows why
# Check error log
cat /var/log/myprogram.error.log
# Validate plist syntax
plutil -lint /Library/LaunchDaemons/com.example.myprogram.plist
Permission issues
# Daemon plist must be owned by root:wheel
sudo chown root:wheel /Library/LaunchDaemons/com.example.myprogram.plist
sudo chmod 644 /Library/LaunchDaemons/com.example.myprogram.plist
# The program itself must be executable
chmod +x /usr/local/bin/myprogram
Common exit codes
| Exit Code | Meaning |
|---|---|
| 0 | Success (normal exit) |
| 1 | General error |
| 78 | Configuration error |
| 126 | Permission denied |
| 127 | Command not found |
SIP (System Integrity Protection) Considerations
SIP protects /System/Library/LaunchDaemons/ and /System/Library/LaunchAgents/ — do not place custom plists there. Use /Library/LaunchDaemons/ for daemons and /Library/LaunchAgents/ or ~/Library/LaunchAgents/ for agents.
SIP can also restrict sudo launchctl debug on protected services. If a service keeps failing without clear errors, use sudo launchctl print system/com.example.app to inspect service state.
Resources
- Apple: launchd Documentation
- launchd.info — comprehensive launchd reference
- launchctl man page
Comments