Local AI Infrastructure Notes (3/15) — Automating Services with launchd

A practical guide to automating home server processes with macOS's native service manager, launchd


Key Takeaways

  • On macOS, launchd is the correct tool for running services automatically. cron and manual execution are not suitable for server operation.
  • A single plist file handles both boot-time auto-start and crash auto-restart. The two critical keys are RunAtLoad and KeepAlive.
  • Running scripts from an external drive will hit TCC restrictions. The workaround is to keep an execution copy on internal storage and sync from the external drive.

Overview

launchd is the tool for reliably automating home server processes on macOS. It is macOS's native service manager — the equivalent of Linux's systemd — running as PID 1 from boot and managing the lifecycle of all processes on the system.

cron works for simple periodic tasks, but it cannot satisfy server operation requirements such as boot-time auto-start, crash recovery, and environment variable management. launchd handles all of this with a single plist file.


Body

1. launchd Fundamentals — Comparison with Linux systemd

launchd is macOS's PID 1 process. It manages every process lifecycle from system boot through user login.

Comparison with systemd:

Item systemd (Linux) launchd (macOS)
Config file .service (INI format) .plist (XML format)
Registration command systemctl enable launchctl load (or bootstrap)
Status check systemctl status launchctl list
Logs journalctl File-based (path specified in plist)
Dependency management After=, Requires= Limited (manual handling)

The key difference is dependency management. systemd supports precise ordering between services; launchd does not. In return, launchd configuration is considerably simpler.

2. Writing plist Files — Five Keys Are Enough

launchd configuration files are .plist (Property List) XML files. They look verbose at first glance, but only five keys are actually required.

<?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.my-service</string>

    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/python3</string>
        <string>/Users/username/scripts/listener.py</string>
    </array>

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

    <key>KeepAlive</key>
    <true/>

    <key>WorkingDirectory</key>
    <string>/Users/username/scripts</string>
</dict>
</plist>

Key roles:

  • Label: Unique service identifier. Reverse-domain format is conventional (com.myserver.listener).
  • ProgramArguments: The command to execute. First array element is the binary; the rest are arguments.
  • RunAtLoad: When true, the service launches automatically when the plist is loaded (i.e., at boot).
  • KeepAlive: When true, the process is restarted automatically if it exits.
  • WorkingDirectory: The working directory for the process.

The RunAtLoad + KeepAlive combination is the core pattern. Setting both to true delivers "auto-start on boot + auto-recovery on crash" in one step.

3. LaunchAgents vs LaunchDaemons — Placement Matters

Behavior differs depending on where the plist file is placed:

Path Type Launch trigger Privilege
~/Library/LaunchAgents/ Agent User login User-level
/Library/LaunchDaemons/ Daemon System boot root

LaunchAgents is recommended for home server use. Reasons:

  • Most services run fine at user privilege.
  • Daemons cannot access the GUI and have restricted environment variables.
  • With macOS auto-login enabled, Agents start immediately after boot — effectively equivalent to daemon behavior.

Configure auto-login at System Settings → Users & Groups → Automatic Login. With this in place, LaunchAgents behave the same as a boot-time daemon for all practical purposes.

4. External Drive and TCC Restrictions — The Biggest Pain Point

Running a script located on an external drive via launchd will trigger TCC (Transparency, Consent, and Control) restrictions.

Symptoms: - The plist loads successfully but the process dies immediately. - Logs show "Operation not permitted" or permission-related errors. - The same script runs normally when executed directly from Terminal.

The cause is macOS's security policy. Processes launched by launchd run in a different permission context than those launched interactively from a terminal. External drives are classified as "removable volumes" and are subject to additional access restrictions.

Solution — internal storage copy + sync script:

#!/bin/bash
EXTERNAL="/Volumes/ExternalDrive/project/scripts"
INTERNAL="$HOME/local-mirror/scripts"

rsync -a --delete "$EXTERNAL/" "$INTERNAL/"
cd "$INTERNAL"
exec python3 listener.py

Place this wrapper script on internal storage and point the plist at it. It syncs the latest code from the external drive to internal storage, then executes from there.

Adding the relevant binary to System Settings → Privacy & Security → Full Disk Access is an alternative, but this setting tends to reset after macOS updates. The sync approach is more stable.

5. Log Management — The Lifeline for Debugging

launchd has no centralized log system comparable to journalctl. Instead, log file paths are specified directly in the plist:

<key>StandardOutPath</key>
<string>/Users/username/logs/my-service.stdout.log</string>

<key>StandardErrorPath</key>
<string>/Users/username/logs/my-service.stderr.log</string>

Log management notes:

  • Separating stdout and stderr simplifies debugging.
  • The log directory must exist before launchd starts — it is not created automatically.
  • Log rotation must be managed separately. A simple approach is a cron job for periodic truncation instead of logrotate.
0 0 * * 0 : > /Users/username/logs/my-service.stdout.log

6. Real-World Example — Registering a Python Listener with launchd

The following is the actual process for registering a Python-based message listener with launchd.

Step 1: Write the 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.homeserver.message-listener</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/python3</string>
        <string>/Users/username/services/listener/main.py</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>WorkingDirectory</key>
    <string>/Users/username/services/listener</string>
    <key>StandardOutPath</key>
    <string>/Users/username/logs/listener.stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/username/logs/listener.stderr.log</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/usr/local/bin:/usr/bin:/bin</string>
    </dict>
</dict>
</plist>

Step 2: Register and start

cp com.homeserver.message-listener.plist ~/Library/LaunchAgents/

launchctl load ~/Library/LaunchAgents/com.homeserver.message-listener.plist

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.homeserver.message-listener.plist

Step 3: Verify

launchctl list | grep homeserver


Common Pitfalls

Missing EnvironmentVariables. The launchd execution environment has a minimal PATH — unlike a user's terminal session. Packages installed via pip install will not be on the default PATH, causing import errors. Specify all required paths explicitly using the EnvironmentVariables key in the plist.

KeepAlive restart loop. If the process has a bug that causes it to exit immediately on startup, KeepAlive will trigger repeated restarts, leading to CPU saturation and log file explosion. The ThrottleInterval key (default: 10 seconds) controls the restart interval, but the correct fix is to verify the process runs stably in isolation before registering it with launchd.

launchctl load vs bootstrap confusion. Depending on the macOS version, the load/unload commands may display deprecation warnings. bootstrap/bootout is the officially recommended approach. Both commands work, but use bootstrap for all new configurations.


Conclusion

launchd is the operational foundation for a macOS home server. It does not match systemd in feature depth, but it satisfies the essential server requirement — boot-time auto-start and crash auto-recovery — with a single plist file.

Summary: 1. Set four keys in the plist: Label, ProgramArguments, RunAtLoad, KeepAlive. 2. Place the file in ~/Library/LaunchAgents/ and register with launchctl bootstrap. 3. For scripts on an external drive, use the internal storage sync pattern to avoid TCC restrictions. 4. Always configure StandardOutPath and StandardErrorPath for log output.

Series overview: Series index

๋Œ“๊ธ€

์ด ๋ธ”๋กœ๊ทธ์˜ ์ธ๊ธฐ ๊ฒŒ์‹œ๋ฌผ

Agent Memory Engine (2/10) — Building an AI Agent Memory System with SQLite Alone

"ML Foundations (9/9) — PyTorch vs TensorFlow, and the Road to Local LLMs"

"RAG Core Study (14/26) — Evaluation Sets with RAGAS & DeepEval"

"ML Foundations (8/9) — Deep Learning Architectures: CNN, RNN, Attention"

"ML Foundations (7/9) — Deep Learning Training: Optimizers, Regularization, Initialization"

OpenClaw to Hermes Migration (2/13) — What to Preserve, Partially Port, or Discard

AI Agents I Built (5/7) — Building an Automated Blogger API Publishing System