Intro

The library set from Buffy the Vampire Slayer, dramatic lighting
The Sunnydale High Library seems like a good place to start this one
Still from Buffy the Vampire Slayer (© 20th Century Fox Television). Used here under fair use for commentary.

Welcome to Daemonology — the (sometimes) dark art of getting programs to behave, run on schedule, and stick around without babysitting. Whether you’re on Linux with systemd or macOS with launchd, the idea is simple: if you want the operating system to manage your script—starting it, stopping it, logging it, or running it on a schedule—you define it as a service.

In Unix, daemons are background processes that run independently of any user session, usually starting at boot and quietly providing essential services like scheduling, networking, or logging. They don’t interact directly with the user but instead handle requests or monitor resources, often ending in a d (e.g. sshd, cron). Think of them as the invisible caretakers that keep the system’s gears turning.

People have debated the move from init.d/cron to systemd and launchd a lot over the years — if you want the lore, start here: systemd controversy. But if you’re running a modern Linux (or anything Apple), chances are you’re already in the world of systemd or launchd. These tools give you structured configs, unified logging, restart policies, and scheduling baked right in. The downisde is that they can sometimes be verbose and confusing.

Here’s a practical starter kit to tame them: bare-bones configs, restart policies, and cheatsheet.


Terminology

“Never try to fool children, they expect nothing, and therefore see everything.” — Harry Houdini

Before we dive into skeletons and configs, let’s set the stage. There are a few moving pieces here, and the terms get overloaded. A little clarity up front makes the rest fall into place.


The Old, Classic Way (init.d + cron)

For decades, Unix-like systems handled background jobs in two different ways:

  • System V init.d scripts
    Long-running services (daemons) like sshd or apache2 were defined in scripts that lived in /etc/init.d/.
    They were shell scripts with start, stop, and restart functions, called by the system at boot.

or

  • BSD init + rc scripts
    On BSD systems (and early macOS/Darwin), initialization was driven by a simpler BSD-style init.
    A master script (/etc/rc) or per-service scripts in /etc/rc.d/ launched daemons at startup, with settings controlled by rc.conf.

  • cron jobs
    Scheduled one-shot scripts ran from crontab -e.
    You wrote lines like 0 2 * * * /usr/local/bin/backup.sh, and cron would invoke them at the right time.
    Output usually vanished into the void unless you redirected it somewhere.

It worked, but it meant services and scheduled jobs were managed by different tools, with different conventions and almost no unified logging.


The New Way (systemd + launchd)

In an effort to unify the init and scheduling system, modern systems settled on a more structured approach:

  • On Linux, systemd runs the show.
  • On macOS, Apple’s launchd plays a similar role.

Like the old init process, they are still the first userspace process launched by the kernel (PID 1), responsible for starting everything else. The difference is that they replace the runlevel scripts with declarative unit definitions and a dependency graph. They also integrate scheduling for periodic jobs, so one system manages both daemons and one-shot tasks.

Both treat everything as a unit of work described by a small config file. Whether you want a daemon that runs forever, or a script that fires at intervals, you now describe it the same way — in a .service (systemd) or .plist (launchd). Scheduling is built-in (.timer in systemd, StartInterval in launchd), so you don’t need a separate cron subsystem.


Services vs Ad-hoc Scripts

Let’s clarify some key terminology to avoid confusion.

Ad-hoc Scripts

These are scripts you run manually as needed—no system management required. You can run them in the background during use, but they won’t persist after reboot or restart automatically if they crash.

Managed Services

Every program you want the system to manage needs a definition file:

  • Linux (systemd): .service unit files
  • macOS (launchd): .plist files

These definition files are simple wrappers that tell the system:

  • Which command or script to run
  • What user to run it as
  • The working directory
  • How to handle failures
  • Where to send logs

Two Types of Managed Services

Scheduled JobsRun automatically on a schedule Perfect for tasks like backup scripts that tar up your home directory.

  • systemd: Create a .service file + a .timer unit that points to it
  • launchd: Add scheduling keys like StartInterval or StartCalendarInterval to your .plist

DaemonsLong-running services that listen for requests Examples include Python web servers or shell loops writing heartbeats.

  • systemd: Just a .service file (no timer needed)
  • launchd: Just a .plist file (no scheduling keys needed)

The service stays alive on its own, while the init system handles the lifecycle—tracking the PID, restarting if needed, and capturing logs.


Quick Reference:

  • systemd: .service files for everything, add .timer for scheduled jobs
  • launchd: .plist files for everything, add scheduling keys for scheduled jobs
  • Daemons: Only need the basic service definition—they manage their own runtime

a pixellated video game wizard with glowing eyes in robes and a hat reading a book
feel like a wizard yet?

Script Patterns in Practice

Before we look at service definitions, it helps to see the kinds of scripts you might be managing. These are minimal examples that illustrate the difference between ad-hoc jobs and programs that behave like daemons. They aren’t production-ready (real daemons usually need logging, signal handling, and proper backgrounding), but they capture the basic patterns.

Run-once script — an ad-hoc job you’d pair with a timer:

#!/usr/bin/env bash
tar czf ~/backups/home-$(date +%F).tgz ~/Documents

This script runs once and exits. To have it run automatically, you’d wrap it in a .service/.plist and add a .timer or scheduling key.

Looping daemon — a service that drives itself:

#!/usr/bin/env bash
while true; do
  date >> /var/log/heartbeat.log
  sleep 60
done

The program never exits—it controls its own rhythm with sleep, waking up to do work periodically. Wrapped in a .service/.plist, the init system ensures it stays alive and restarts if it crashes.

This pattern is less visible than listener daemons, but it shows up in places like watchdogs, heartbeat loggers, systemd-timesyncd, cron/anacron, and plenty of user scripts that need to wake up every so often.

Listener daemon — a service that waits for external input:

import socket
s = socket.socket()
s.bind(("0.0.0.0", 8080))
s.listen(5)
while True:
    conn, addr = s.accept()
    conn.send(b"Hello, world!\n")
    conn.close()

Unlike the looping example, this doesn’t “wake itself up.” Instead, it sits idle until something connects—the program is event-driven, with the outside world driving the loop. With systemd or launchd managing it, you get lifecycle management, automated logging, and restarts on crash.

This is the pattern that a lot of well known servers like nginx, apache2, sshd and postgresql are based on. They listen for requests and respond to them.


Linux systemd Skeletons

3 pixel art, video game style skeletons dancing
templates / skeletons are really useful

Unit files are just plain text, but placement and naming matter.

  • System-wide units live under /etc/systemd/system/ (root-owned, affect all users).
  • User units live under ~/.config/systemd/user/ (run as your user, no root required).
  • Files must end in .service or .timer. The basename ties them together: mybackup.timer will trigger mybackup.service.

Below is a minimal example: a .service that runs a backup script, and a .timer that fires it every night at 2 AM. Yes, comments starting with # are allowed in unit files — you can omit these lines but using them liberally helps future you. Either way, feel free to copy any of the following examples as a starting point and modify them to suit your needs

Example: mybackup.service

# ==========================================================
# mybackup.service — Example systemd service
# Save to: ~/.config/systemd/user/mybackup.service
# ==========================================================

[Unit]
Description=Run home directory backup
# ↑ Human-friendly description (shows up in `systemctl status`).

# Optional ordering/dependencies: After=network.target ensures
# the service only runs once networking is available. This primarily matters on boot so
# you may or may not need it depending on the service
After=network.target

[Service]
# The actual command to run. Always use the absolute path.
ExecStart=/usr/local/bin/backup.sh

# Optional: directory to treat as "current working directory".
# If omitted, defaults to /.
WorkingDirectory=/usr/local/bin

# User account to run as. For user services, this is implied,
# but for system-wide services it’s important to set explicitly.
User=forfaxx

# Restart behavior:
# - no (default) → do not restart
# - always → restart unconditionally
# - on-failure → restart if the process exits with an error
Restart=on-failure

# Example of passing environment variables into the service:
# Environment="BACKUP_DIR=/mnt/backups"

[Install]
# This section defines what targets this unit hooks into when enabled.
# For simple run-once jobs, this usually isn’t needed unless you want it
# to also start at boot. For timer-driven jobs, the timer unit handles it.
WantedBy=default.target

mybackup.timer

# ==========================================================
# mybackup.timer — Example systemd timer
# Save to: ~/.config/systemd/user/mybackup.timer
# ==========================================================

[Unit]
Description=Run home directory backup daily at 2am
# ↑ Shown in `systemctl status` and logs.

[Timer]
# Calendar syntax:
#   Day-Month-Year Hour:Minute:Second
#   * means "any". So this is "every day at 02:00".
OnCalendar=*-*-* 02:00:00

# Optional: spread jobs randomly by up to this amount of time.
# Useful when many machines might start the same timer simultaneously.
RandomizedDelaySec=15m

# If true, systemd will run the job immediately on startup if it
# was missed while the machine was off or asleep.
Persistent=true

[Install]
# Tie this timer to the global "timers.target" so it runs when enabled.
WantedBy=timers.target

Usage

# Reload so systemd notices new units
systemctl --user daemon-reload

# Enable and start the timer (this implicitly links to the service)
# Note: --user is crucial here - without it, you'd be managing system services as root
systemctl --user enable --now mybackup.timer

# Check status: shows last and next run
systemctl --user status mybackup.timer

# See logs from the last run
journalctl --user -u mybackup.service -e

systemd .timer scheduling options

Timers in systemd can trigger units based on absolute calendar time, relative/monotonic delays, or a mix of both. You define them in a foo.timer file, usually paired with a foo.service. The calendar parser understands rich ISO-8601–style dates and times, making it far more expressive than cron.

Calendar-based
These options use calendar expressions to run jobs at exact times or repeating schedules. The syntax is flexible: you can specify wildcards (*), named days, or shorthand like daily.

  • OnCalendar= → ISO-8601–style calendar expressions. Examples:

    • OnCalendar=*-*-* 09:00 → every day at 9 AM

    • OnCalendar=Tue,Thu *-*-* 18:00 → every Tuesday and Thursday at 6 PM

    • OnCalendar=Mon..Fri *-*-* 08:30 → weekdays at 8:30 AM

    • Shorthands: hourly, daily, weekly, monthly, yearly

          # myweekly.timer
        [Unit]
        Description=Run weekly cleanup
      
        [Timer]
        # shorthand: every Monday at 00:00
        OnCalendar=weekly
      
        # optional: jitter and catch-up
        RandomizedDelaySec=1h
        Persistent=true
      
        [Install]
        WantedBy=timers.target
      

    Shorthand mappings
    Systemd provides a few convenient aliases for common schedules. They expand into explicit calendar expressions:

    Shorthand Equivalent expression Meaning
    hourly *-*-* *:00:00 every hour on the hour
    daily *-*-* 00:00:00 every day at midnight
    weekly Mon *-*-* 00:00:00 every Monday at midnight
    monthly *-*-01 00:00:00 first day of each month at midnight
    yearly / annually *-01-01 00:00:00 every January 1st at midnight

    👉 Use the shorthands for simplicity, or write explicit forms (Fri *-*-* 18:00) if you need more control.

Monotonic (relative) timers
These options measure time relative to certain events (boot, activation, or last run) and are useful for delays and repeating intervals.

  • OnActiveSec= → time since the timer was activated
    i.e. OnActiveSec=5min (run 5 minutes after enabling)

  • OnBootSec= → time since system boot
    i.e. OnBootSec=10min (run 10 minutes after boot)

  • OnStartupSec= → time since the systemd manager itself started

  • OnUnitActiveSec= → time since the unit was last active
    i.e. OnUnitActiveSec=1h (rerun one hour after the last run)

  • OnUnitInactiveSec= → time since the unit was last inactive
    i.e. OnUnitInactiveSec=30s (rerun if idle for 30 seconds)

Other controls (modifiers)
These don’t trigger timers on their own; they adjust how the above schedules behave.

  • AccuracySec= → how precise the firing time must be (default 1 min). Larger values allow batching.
    i.e. AccuracySec=5min

  • RandomizedDelaySec= → adds jitter to spread out jobs.
    i.e. RandomizedDelaySec=30s (fires randomly within a 30s window)

  • Persistent= → catch up on missed runs after downtime.
    i.e. a nightly backup still runs on next boot if the system was off at midnight

👉 In practice: .timer units combine calendar scheduling, relative delays, and resiliency features like persistence and jitter. They’re effectively a superset of cron, anacron, and manual sleep loops.


Docs

Canonical references for systemd units, services, and timers (freedesktop.org):

a pixelated video game style wizard in red robes, holding a book. playfun tone
a little knowledge can be a dangerous thing

macOS launchd Skeletons

On macOS, every managed job is described by an XML .plist file (property list).

  • Per-user jobs~/Library/LaunchAgents/ (run as your login user).
  • System-wide daemons/Library/LaunchDaemons/ (run as root by default, but you should set a dedicated service user <UserName>).
  • Filenames must end in .plist, and each must have a unique Label.
  • Comments use XML syntax: <!-- ... -->.

Unlike cron, scheduling is optional. If you omit it, the job won’t run automatically — you’ll trigger it manually with launchctl start. For daemons, you use KeepAlive instead of a schedule.


Example 1: Scheduled Job (cron-style replacement)

This runs a backup script at 2:00 AM every day.

<!-- ==========================================================
     com.user.backup.plist — Scheduled job example
     Save to: ~/Library/LaunchAgents/com.user.backup.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>

  <!-- Unique label for the job. Reverse-domain style is recommended. -->
  <key>Label</key>
  <string>com.user.backup</string>

  <!-- Absolute path to the script or binary to run. -->
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/backup.sh</string>
  </array>

  <!-- Redirect stdout/stderr so output isn’t lost. -->
  <key>StandardOutPath</key>
  <string>/tmp/com.user.backup.out</string>
  <key>StandardErrorPath</key>
  <string>/tmp/com.user.backup.err</string>

  <!-- Optional environment variables -->
  <!--
  <key>EnvironmentVariables</key>
  <dict>
    <key>BACKUP_DIR</key>
    <string>/Volumes/Backups</string>
  </dict>
  -->

  <!-- Schedule: run at 2:00 AM every day -->
  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key><integer>2</integer>
    <key>Minute</key><integer>0</integer>
  </dict>

  <!-- RunAtLoad would also trigger the job once when loaded -->
  <key>RunAtLoad</key>
  <false/>

</dict>
</plist>

Example 2: Daemon (always-on service)

This wraps a program like nginx or postgres — something that should stay alive forever.

<!-- ==========================================================
     com.user.daemon.plist — Always-on daemon example
     Save to: /Library/LaunchDaemons/com.user.daemon.plist
     (requires root privileges to install)
     ========================================================== -->

<?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>

  <!-- Unique label for the daemon -->
  <key>Label</key>
  <string>com.user.daemon</string>

  <!-- Absolute path to the daemon binary -->
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/sbin/nginx</string>
    <!-- Example arguments -->
    <string>-g</string>
    <string>daemon off;</string>
  </array>

  <!-- KeepAlive tells launchd to restart it if it crashes -->
  <key>KeepAlive</key>
  <true/>

  <!-- Run as a dedicated service account instead of root.
       Best practice: PostgreSQL runs as "postgres", nginx often as "www". -->
  <key>UserName</key>
  <string>www</string>

  <!-- Optional logging -->
  <key>StandardOutPath</key>
  <string>/var/log/com.user.daemon.out</string>
  <key>StandardErrorPath</key>
  <string>/var/log/com.user.daemon.err</string>

</dict>
</plist>

Event-Triggered Jobs (no schedule)

You don’t need a timer for everything—launchd can trigger on file changes or open a socket for you.

Run when files change (WatchPaths)

<!-- ~/Library/LaunchAgents/com.user.ingest.plist -->
<plist version="1.0">
<dict>
  <key>Label</key> <string>com.user.ingest</string>
  <key>ProgramArguments</key>
  <array><string>/usr/local/bin/ingest.sh</string></array>

  <!-- Fire when anything under ~/inbox changes -->
  <key>WatchPaths</key>
  <array><string>~/inbox</string></array>

  <key>StandardOutPath</key> <string>~/Library/Logs/com.user.ingest.out</string>
  <key>StandardErrorPath</key> <string>~/Library/Logs/com.user.ingest.err</string>
</dict>
</plist>

Usage

# Modern approach (macOS 10.10+)
# Load/enable the job
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.backup.plist

# Unload/disable it  
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.user.backup.plist

# Check if it's active
launchctl list | grep com.user.backup

# Manually trigger (this part stays the same)
launchctl start com.user.backup

# Legacy commands (still work but deprecated)
# launchctl load ~/Library/LaunchAgents/com.user.backup.plist
# launchctl unload ~/Library/LaunchAgents/com.user.backup.plist

Note Modern vs Legacy Syntax

Apple deprecated the legacy launchctl load/unload commands in macOS 10.10, replacing them with bootstrap/bootout. The old commands still work for now, but the modern syntax is more explicit and avoids ambiguity.

The key difference is domains: modern commands require you to specify where the job should be loaded.

  • For personal jobs in ~/Library/LaunchAgents/, use:
    launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/myjob.plist
    This targets your GUI user session ($(id -u) expands to your numeric UID).

  • For system daemons in /Library/LaunchDaemons/, use:
    launchctl bootstrap system /Library/LaunchDaemons/mydaemon.plist

Apple introduced this change to solve issues with Fast User Switching and multiple user sessions, where the older load/unload commands could be ambiguous.

Most existing tutorials still use the legacy syntax because it’s simpler and widely understood, but the modern domain-based approach is clearer and gives precise control over job placement.


Choosing a Label

Every launchd job needs a unique <Label>. Apple convention is to use reverse-DNS style names, like:

  • com.apple.sshd
  • org.postgresql.server
  • net.nginx.web

For your own jobs, you can safely pick something like:

  • com.yourname.backup
  • net.darkstar.heartbeat

The format doesn’t change how launchd behaves — it’s purely an identifier — but the reverse-DNS pattern avoids collisions. Two rules of thumb:

  • Unique per system: If two jobs share a label, launchd gets confused.
  • Readable: Make it obvious what the job does when you run launchctl list.

If in doubt, prefix with your username or domain, then the purpose. For example, instead of just backup, use something like com.forfaxx.backup to avoid clashing with system jobs.


launchd scheduling options (macOS)

Jobs in macOS are managed by launchd, defined in .plist XML files and loaded with launchctl. Instead of a single flexible expression like OnCalendar=, launchd breaks scheduling into separate keys. These can be combined, but each key is very explicit — no wildcards or ranges like you’d find in cron.

Calendar-based
StartCalendarInterval is the closest thing to cron. It accepts a dictionary (or an array of dictionaries) with any combination of these keys: Minute, Hour, Day, Weekday, and Month. You must spell out exact values, and if you want multiple schedules you add multiple dictionaries.

👉 Weekday mapping: 0 = Sunday, 1 = Monday, … 6 = Saturday

  • Every day at 9 AM

    <dict>
      <key>Hour</key><integer>9</integer>
      <key>Minute</key><integer>0</integer>
    </dict>
    
  • Every Tuesday and Thursday at 6 PM

    <array>
      <dict>
        <key>Weekday</key><integer>2</integer>
        <key>Hour</key><integer>18</integer>
        <key>Minute</key><integer>0</integer>
      </dict>
      <dict>
        <key>Weekday</key><integer>4</integer>
        <key>Hour</key><integer>18</integer>
        <key>Minute</key><integer>0</integer>
      </dict>
    </array>
    
  • Every weekday (Mon–Fri) at 8:30 AM

    <array>
      <dict><key>Weekday</key><integer>1</integer><key>Hour</key><integer>8</integer><key>Minute</key><integer>30</integer></dict>
      <dict><key>Weekday</key><integer>2</integer><key>Hour</key><integer>8</integer><key>Minute</key><integer>30</integer></dict>
      <dict><key>Weekday</key><integer>3</integer><key>Hour</key><integer>8</integer><key>Minute</key><integer>30</integer></dict>
      <dict><key>Weekday</key><integer>4</integer><key>Hour</key><integer>8</integer><key>Minute</key><integer>30</integer></dict>
      <dict><key>Weekday</key><integer>5</integer><key>Hour</key><integer>8</integer><key>Minute</key><integer>30</integer></dict>
    </array>
    
  • Every 2 weeks
    There’s no built-in “every 2 weeks” in launchd. You either:

    • Use StartInterval=1209600 (14 days in seconds), which repeats from when the job was loaded — but it drifts if the system is asleep or rebooted.
    • Or explicitly enumerate the calendar dates you want, using multiple StartCalendarInterval dictionaries. This is verbose, but guarantees alignment with real calendar weeks.

Interval-based
StartInterval runs a job every N seconds, regardless of the calendar. Think of it as a built-in sleep loop.

  • Example:
    <key>StartInterval</key><integer>3600</integer>
    
    → run every hour (from job load time)

Other controls (modifiers)
These don’t schedule jobs directly, but change how and when they launch.

  • RunAtLoad → run once immediately when the job is loaded
  • StartOnMount → trigger when a filesystem is mounted
  • KeepAlive → restart automatically; can be true or a dictionary with conditions (restart if a path exists, a network comes up, or the process exits unexpectedly)

👉 Launchd’s style is explicit and dictionary-driven: you define exact times or raw intervals, then add flags to control behavior. It’s less expressive than systemd’s OnCalendar (no ranges or modulo like “every 2 weeks”), but tightly integrated into macOS and good enough for most practical scheduling needs.


Troubleshooting launchd jobs

If your job isn’t firing:

  • Make sure the .plist is in the right folder with the right ownership:
    • ~/Library/LaunchAgents/ → per-user jobs (owned by you)
    • /Library/LaunchDaemons/ → system-wide jobs (owned by root, mode 644)
  • Validate the file with plutil -lint myjob.plist to catch XML errors.
  • Check whether launchd actually loaded it:
    launchctl print gui/$(id -u) | grep MyJobLabel
  • Look at logs for errors or crashes:
    log show --style syslog --predicate 'process == "myjob"' --last 1h

👉 Most failures come down to wrong location, wrong ownership, or a typo in the plist.



Key Distinctions

  • StartInterval / StartCalendarInterval → for cron-style jobs (run once and exit).
  • KeepAlive → for daemons (stay alive forever, restart if needed).
  • If you omit both, the job won’t run automatically — you’ll need to start it with launchctl. (see Sockets and WatchPaths for exceptions to this rule) 🔗launchd.plist
  • In /Library/LaunchDaemons/, always set <UserName> to a dedicated account instead of running everything as root.

Docs

Canonical references for scheduling on macOS with launchd:

a creepy wizard in purple robes with glowing eyes, holding a book with a skull on it. arcade game style
I've been known to RTFM

Tips & Tricks

Here are a few practical reminders and tools that save headaches when working with systemd and launchd.

Topic Linux (systemd) macOS (launchd)
Unit placement /etc/systemd/system/ (system-wide)
~/.config/systemd/user/ (per-user)
/Library/LaunchDaemons/ (system-wide, run as root or <UserName>)
~/Library/LaunchAgents/ (per-user)
Unit naming Basename ties .service to .timer (e.g. backup.service + backup.timer). <Label> must be unique. Use reverse-DNS style (com.user.job) to avoid collisions.
Validation systemd-analyze verify /path/to/unit plutil -lint com.user.job.plist
Check what’s loaded systemctl list-units --type=service --user
systemctl list-timers --user
launchctl list
Logs / debugging journalctl -u myjob.service -e
journalctl --user -xe
log show --predicate 'process == "backup.sh"' --last 1h
or check StandardOutPath / StandardErrorPath
Force a run systemctl start myjob.service launchctl start com.user.job
Enable at boot systemctl enable myjob.service
systemctl enable myjob.timer
Jobs in LaunchAgents/LaunchDaemons load automatically; use launchctl bootstrap for fine-grained control.

Pro-Tip: Best practices:

  • Don’t run everything as root. Use User= in systemd or <UserName> in launchd to assign a dedicated account for daemons.
  • Validate first. Both systemd-analyze verify and plutil -lint catch typos before you’re scratching your head at runtime.
  • Check logs early. journalctl and the unified macOS log are your best friends when things don’t behave.
  • Start manually once. Always systemctl start or launchctl start a job to make sure it works interactively before scheduling it forever.

Here are the canonical docs and references worth bookmarking:

Conclusion

That’s it — the basics of Daemonology in Unix-land. We’ve covered how to wrap a script into a managed job, how timers and intervals take the place of cron, and how daemons live under the supervision of systemd or launchd.

Next time you need a script to stick around, restart itself, or wake up on a schedule — you’ll know how to bind it to the system and let the OS do the babysitting.

Got any tips or tricks I missed? Corrections? I’d love to hear about them! feedback@adminjitsu.com