← BACK TO HOME

The Shell Crossover, Part 5: LaunchDaemons Are Not Windows Services

Translate Windows Services and Scheduled Tasks into macOS launchd concepts, including LaunchDaemons, LaunchAgents, plist keys, and launchctl.

Windows administrators usually start with two mental models for background work: Windows Services and Scheduled Tasks. macOS uses a different model. The center of that model is launchd.

A LaunchDaemon is not exactly a Windows service. A LaunchAgent is not exactly a scheduled task. They can satisfy similar operational needs, but the execution context, file placement, permissions, and troubleshooting model are different.

The launchd map

Windows conceptmacOS conceptTypical locationExecution context
Windows ServiceLaunchDaemon/Library/LaunchDaemonsSystem context, often root.
Computer startup taskLaunchDaemon with RunAtLoad/Library/LaunchDaemonsLoaded in the system launchd domain.
User logon taskLaunchAgent/Library/LaunchAgents or ~/Library/LaunchAgentsLoaded in a user GUI session.
Task Scheduler triggerlaunchd keysplist fileTime, interval, path, or load triggers.
services.msc or sc.exelaunchctlNative commandInspect, load, unload, and troubleshoot jobs.

The first decision is not the schedule. The first decision is the context.

Use a LaunchDaemon when the job must run outside a user session or must run with system privileges. Use a LaunchAgent when the job requires a logged-in user GUI session.

File locations matter

Common locations are:

/Library/LaunchDaemons        Local system daemons
/Library/LaunchAgents         Agents available to user sessions
~/Library/LaunchAgents        Agents for one user
/System/Library/LaunchDaemons Apple-owned system daemons
/System/Library/LaunchAgents  Apple-owned system agents

For admin-created jobs, stay in /Library, not /System/Library.

A LaunchDaemon placed in /Library/LaunchDaemons runs in the system launchd domain. If the plist omits a UserName key, launchd runs the job as root. That is common for system-level jobs, but it should be an intentional choice, not an accident.

A LaunchAgent runs in a user session. A LaunchAgent that needs the user’s GUI context should be bootstrapped into the user’s GUI domain, not into the system domain with sudo.

A valid LaunchDaemon example

The following example writes a timestamp to a log file at load and every fifteen minutes.

<?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>dev.admincrossover.example.heartbeat</string>

  <key>ProgramArguments</key>
  <array>
    <string>/bin/zsh</string>
    <string>/usr/local/admincrossover/heartbeat.zsh</string>
  </array>

  <key>RunAtLoad</key>
  <true/>

  <key>StartInterval</key>
  <integer>900</integer>

  <key>StandardOutPath</key>
  <string>/var/log/admincrossover-heartbeat.log</string>

  <key>StandardErrorPath</key>
  <string>/var/log/admincrossover-heartbeat.err</string>
</dict>
</plist>

The Label is the job identity. ProgramArguments is the command and its arguments as an array. RunAtLoad starts it when loaded. StartInterval repeats it every 900 seconds.

This example omits UserName, so it runs as root when loaded as a LaunchDaemon. That is why it can write to /var/log. If the job does not require root, use a dedicated least-privilege account and set the UserName key rather than running everything as root by default.

Install the script and plist

Create a simple script.

sudo mkdir -p /usr/local/admincrossover

sudo tee /usr/local/admincrossover/heartbeat.zsh >/dev/null <<'EOF'
#!/bin/zsh
/bin/date >> /var/log/admincrossover-heartbeat.log
EOF

sudo chown root:wheel /usr/local/admincrossover/heartbeat.zsh
sudo chmod 755 /usr/local/admincrossover/heartbeat.zsh

Create the plist.

sudo tee /Library/LaunchDaemons/dev.admincrossover.example.heartbeat.plist >/dev/null <<'EOF'
<?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>dev.admincrossover.example.heartbeat</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/zsh</string>
    <string>/usr/local/admincrossover/heartbeat.zsh</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>StartInterval</key>
  <integer>900</integer>
  <key>StandardOutPath</key>
  <string>/var/log/admincrossover-heartbeat.log</string>
  <key>StandardErrorPath</key>
  <string>/var/log/admincrossover-heartbeat.err</string>
</dict>
</plist>
EOF

sudo chown root:wheel /Library/LaunchDaemons/dev.admincrossover.example.heartbeat.plist
sudo chmod 644 /Library/LaunchDaemons/dev.admincrossover.example.heartbeat.plist
sudo plutil -lint /Library/LaunchDaemons/dev.admincrossover.example.heartbeat.plist

The ownership and mode are not cosmetic. Incorrect ownership or permissions can prevent launchd from loading the job.

Bootstrap, inspect, and restart the job

Modern launchctl syntax works with domains. A system LaunchDaemon loads into the system domain.

sudo launchctl bootstrap system /Library/LaunchDaemons/dev.admincrossover.example.heartbeat.plist
sudo launchctl print system/dev.admincrossover.example.heartbeat

Start the job immediately without waiting for the next interval.

sudo launchctl kickstart system/dev.admincrossover.example.heartbeat

If the job is already running and you want to terminate that instance before starting it again, add -k.

sudo launchctl kickstart -k system/dev.admincrossover.example.heartbeat

The -k flag kills a running instance before kickstarting it. Omit -k when you only want to start an idle job.

Unload the job when you are done testing.

sudo launchctl bootout system/dev.admincrossover.example.heartbeat

PowerShell can call the same native commands.

$PlistPath = "/Library/LaunchDaemons/dev.admincrossover.example.heartbeat.plist"
$ServiceTarget = "system/dev.admincrossover.example.heartbeat"

& /usr/bin/sudo /bin/launchctl bootstrap system $PlistPath
& /usr/bin/sudo /bin/launchctl print $ServiceTarget
& /usr/bin/sudo /bin/launchctl kickstart $ServiceTarget

LaunchAgents are user-session jobs

A LaunchAgent is for work tied to a user session. That is closer to a logon task than a service.

Common locations are:

/Library/LaunchAgents       Available to users on the Mac
~/Library/LaunchAgents      Available only to that user

When loading a LaunchAgent for a user session, bootstrap it as the user, not as root. Loading a LaunchAgent with sudo can bind it to the wrong domain and bypass the intended per-user context. For user agents, think in terms of the user’s GUI domain, such as gui/<uid>, rather than the system domain.

Example domain discovery:

uid=$(id -u)
launchctl print "gui/$uid"

That command inspects the current user’s GUI launchd domain. Do not translate every LaunchAgent into a LaunchDaemon just because sudo makes it load.

Common failure modes

Most LaunchDaemon failures come from a small set of mechanical issues.

SymptomFirst checks
Job will not loadplutil -lint, owner root:wheel, mode 644, path under /Library/LaunchDaemons.
Job loads but does not runlaunchctl print, RunAtLoad, trigger keys, script executable bit.
Script runs manually but not under launchdFull paths, environment assumptions, working directory assumptions.
No useful outputStandardOutPath, StandardErrorPath, permissions on log path.
User interaction failsThe job probably belongs as a LaunchAgent, not a LaunchDaemon.

Check the plist first.

sudo plutil -lint /Library/LaunchDaemons/dev.admincrossover.example.heartbeat.plist
ls -l /Library/LaunchDaemons/dev.admincrossover.example.heartbeat.plist
sudo launchctl print system/dev.admincrossover.example.heartbeat

Then check the output files.

tail -n 50 /var/log/admincrossover-heartbeat.log
tail -n 50 /var/log/admincrossover-heartbeat.err

The operating rule

Do not ask, “How do I make a Windows service on macOS?” Ask what context the work needs.

If it must run at the system level, use a LaunchDaemon, make privilege explicit, validate the plist, and inspect it with launchctl. If it depends on a logged-in user’s session, use a LaunchAgent and load it into the user context. The trigger is secondary. The execution domain is the design decision.