Skip to main content

Run a Program at Startup on macOS with launchd

Published: August 7, 2023 Updated: May 24, 2026 Larry Qu 7 min read

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 loadbootstrap system, unloadbootout 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 cron for compatibility, but Apple recommends launchd for 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

Comments

👍 Was this article helpful?