The Shell Crossover
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 concept | macOS concept | Typical location | Execution context |
|---|---|---|---|
| Windows Service | LaunchDaemon | /Library/LaunchDaemons | System context, often root. |
| Computer startup task | LaunchDaemon with RunAtLoad | /Library/LaunchDaemons | Loaded in the system launchd domain. |
| User logon task | LaunchAgent | /Library/LaunchAgents or ~/Library/LaunchAgents | Loaded in a user GUI session. |
| Task Scheduler trigger | launchd keys | plist file | Time, interval, path, or load triggers. |
services.msc or sc.exe | launchctl | Native command | Inspect, 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 agentsFor 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.zshCreate 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.plistThe 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.heartbeatStart the job immediately without waiting for the next interval.
sudo launchctl kickstart system/dev.admincrossover.example.heartbeatIf 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.heartbeatThe -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.heartbeatPowerShell 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 $ServiceTargetLaunchAgents 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 userWhen 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.
| Symptom | First checks |
|---|---|
| Job will not load | plutil -lint, owner root:wheel, mode 644, path under /Library/LaunchDaemons. |
| Job loads but does not run | launchctl print, RunAtLoad, trigger keys, script executable bit. |
| Script runs manually but not under launchd | Full paths, environment assumptions, working directory assumptions. |
| No useful output | StandardOutPath, StandardErrorPath, permissions on log path. |
| User interaction fails | The 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.heartbeatThen check the output files.
tail -n 50 /var/log/admincrossover-heartbeat.log
tail -n 50 /var/log/admincrossover-heartbeat.errThe 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.