Bassam Ismail
Personal

Running background jobs on macOS with launchd

16 min read

If you asked me what my home server does, I would say "media and photos" and I would be underselling it by about twenty background jobs. The containers are the visible part: Jellyfin, the photo library, the dashboard. The part that actually earns the phrase "does a lot of work" is invisible, and it runs on the oldest, least fashionable piece of macOS plumbing there is. It is the service manager, launchd, quietly executing a small fleet of scripts that turn a media box into something that reconciles my finances and summarizes my week.

This is the third part of a series about running a home server on a spare MacBook. The earlier parts covered why a laptop and how to reach it. This part is about the layer that surprised me most once I started leaning on the machine: macOS as a genuine server, with launchd as its cron and init system, and the rough edges that come with asking a laptop OS to behave like a server.

TL;DR

macOS does not ship cron or systemd, but launchd covers both roles: it runs periodic jobs via StartInterval or StartCalendarInterval and persistent daemons via KeepAlive, all defined in .plist files stored under ~/Library/LaunchAgents/. The biggest hidden cost is the TCC privacy layer, where Full Disk Access grants are tied to a binary's code hash, so rebuilding and redeploying a binary silently revokes its permissions without any error in the UI. The reliable fix is a small, never-changing C launcher that execv's the real binary, inheriting TCC responsibility so redeploys never break the grant. Always set StandardOutPath and StandardErrorPath in every plist, because a launchd job with no log destination is impossible to debug.

launchd is the cron macOS does not advertise

On Linux you reach for cron or systemd timers without thinking. macOS has neither in the form you expect. What it has is launchd, which runs everything from the OS boot sequence to your login items, and which most people only ever meet through an app's auto-updater. It is also a perfectly good way to run your own scheduled jobs and long-lived daemons, once you accept its idioms.

A job is a .plist file describing what to run and when. Two patterns cover almost everything. A periodic task uses StartInterval (every N seconds) or StartCalendarInterval (at a clock time), like cron. A persistent service uses KeepAlive and RunAtLoad, so it starts at login and relaunches if it dies, like a tiny systemd unit. Mine live under ~/Library/LaunchAgents/ and all share one habit: they source a single secrets file before running, so credentials live in exactly one place rather than smeared across twenty plists.

Here is a real one, the job that refetches my portfolio every fifteen minutes. It is deliberately boring, which is the point. The whole job description is data, not code:

<dict>
  <key>Label</key><string>com.example.portfolio</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string><string>-c</string>
    <string>set -a; source $HOME/selfhost/.env; set +a; $HOME/selfhost/portfolio/portfolio -out $HOME/selfhost/portfolio/portfolio.json</string>
  </array>
  <key>StartInterval</key><integer>900</integer>
  <key>RunAtLoad</key><true/>
  <key>StandardOutPath</key><string>$HOME/selfhost/logs/portfolio.log</string>
  <key>StandardErrorPath</key><string>$HOME/selfhost/logs/portfolio.err</string>
</dict>

A few decisions are baked into those nine lines. The command is /bin/bash -c rather than the binary directly, because set -a; source $HOME/selfhost/.env; set +a is how every job pulls in credentials from the one secrets file before exec'ing the real program. StartInterval of 900 is fifteen minutes in launchd's only unit, seconds. RunAtLoad makes it fire once at login so the dashboard is warm immediately instead of blank for the first interval. And both StandardOutPath and StandardErrorPath are set, because a launchd job with nowhere to write its output is a job you cannot debug. That detail matters more than it looks, for reasons I will get to.

Loading and inspecting a job is its own small dialect, and macOS has quietly replaced the verbs once. The legacy form still works:

launchctl load   ~/Library/LaunchAgents/com.example.portfolio.plist
launchctl list | grep com.example

The modern form is per-domain and more explicit. You name the GUI domain for your user (gui/$UID) and the job by label. This is what I reach for when a job needs to be torn down and reloaded cleanly, or forced to run right now:

launchctl bootout   gui/$UID/com.example.expenses
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.example.expenses.plist
launchctl kickstart -k gui/$UID/com.example.expenses

bootout unloads, bootstrap loads, and kickstart -k kills any running copy and starts a fresh one immediately. I use that last command constantly, because the alternative is waiting up to fifteen minutes to find out whether a change worked.

The thing I did not expect is how much of my actual life ended up expressed this way. The jobs sort into a few clear groups:

WHAT THE BACKGROUND JOBS TURN RAW INPUTS INTORAW INPUTDERIVED, ON THE DASHBOARDbank textsbrokerage APIemail receiptsphone + watchexpense feedportfolioledgerhealth + battery[ each is one small script on a timer ]

The full inventory, with the intervals where they are fixed, looks like this:

JobScheduleWhat it does
expensesStartInterval 60sparse bank SMS out of the Messages DB into a categorized feed
batteryStartInterval 60slog pmset -g batt so the dashboard shows the "UPS" charge
portfolioStartInterval 900sfetch holdings from the broker API, compute P&L
stalled-torrent cleanupStartInterval 5mintwo-tier kill of downloads that have stopped progressing
jellyseerr cascade-cleanupStartInterval 5minremove a media request and its files, tracked via a state file
uptime-kuma snapshotStartInterval 3600shourly SQLite .backup of the monitoring DB
nightly backupStartCalendarIntervalrsync the important paths off the box
weekly reviewStartCalendarInterval (weekday)roll the week's data into a Sunday summary
broker token reauthdailyrefresh the broker access token before it expires
assistant daemonsKeepAlivea couple of persistent chat-driven gateways

None of these is impressive on its own. Each is a few dozen lines on a timer. The point is the aggregate: the server is not hosting my data, it is processing it, continuously, without me opening anything.

The finance pipeline, in more detail than is reasonable

The surprising group is the personal-finance one, so it is worth opening up. It has three feeds (expenses, portfolio, ledger) and they each fail differently, which taught me most of what I know about running this stuff unattended.

The expense feed starts from a fact about Apple Continuity: if your phone and Mac share an account, your SMS messages sync to a local SQLite database at ~/Library/Messages/chat.db. My bank texts me on every card swipe and UPI transfer, so the transaction stream is already on the laptop. The job reads it directly. The core query joins the message and handle tables and filters down to the bank's sender:

SELECT m.ROWID, m.date, m.text, h.id
FROM message m JOIN handle h ON h.ROWID = m.handle_id
WHERE h.service = 'SMS' AND h.id LIKE '%MYBANK%';

Two gotchas live in that table. The first is the timestamp. m.date is not a Unix epoch; it is an Apple "Core Data" epoch anchored at 2001-01-01, and on modern macOS it is in nanoseconds, not seconds. The conversion is to divide out the nanoseconds and add the 978307200-second offset between the two epochs:

SELECT datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime')
FROM message m;

The second is that the interesting data is unstructured: merchant and amount live inside the SMS body, and my bank uses two shapes. UPI debits read like "To <merchant> On <date>" and card transactions read like "At <merchant>", so the parser runs two regexes over m.text and takes whichever matches. The output is bucketed into day, week, and month debit-and-credit totals, a 30-day breakdown by category, and a latest balance scraped from the most recent "balance" line. That is the entire expense dashboard, derived from text messages a bank sends anyway.

The centerpiece: Full Disk Access, code signatures, and a job that silently reads nothing

~/Library/Messages/chat.db is not a path you can just read. It sits behind macOS's TCC privacy layer, so a binary needs Full Disk Access, granted under System Settings, Privacy & Security, Full Disk Access. I granted it, the job worked, and then weeks later the expense feed went quiet. Not error-quiet. Empty-quiet, which is far worse, because an empty expense feed looks exactly like a frugal week.

The cause is a detail of how FDA grants work: they are keyed to a binary by both its path and its code hash. My deploy step rebuilds the expense parser and scps the new binary over the old one at the same path. The path is unchanged, but the hash is not, and macOS treats a different hash at a granted path as a different program and silently revokes the access. The grant in the UI still looks present. The binary just no longer has it.

The symptom is precise once you know to look for it. A manual run over SSH works fine, because an interactive shell session inherits a different TCC context. The launchd run logs this and exits 1:

unable to open database file: operation not permitted

This is exactly why both StandardErrorPath and StandardOutPath are non-negotiable in every plist. Without that .err file, the job is a black box that "ran" and produced nothing, and you have no thread to pull.

There was a second twist. Because the plist invokes the job as /bin/bash -c '... ; binary', the process that actually touches chat.db under launchd is reached through /bin/bash, so /bin/bash itself also had to be on the Full Disk Access list for the read to succeed. Granting only the parser binary was not enough.

The procedural fallback, which is where I lived for a while, is unglamorous: re-add the rebuilt binary to the FDA list after every single deploy, and adopt the rule that a suspiciously empty expense feed is a permissions failure until proven otherwise. After re-granting, the job needs a clean reload to pick up the restored context, which is the other reason the modern launchctl verbs earn their place:

launchctl bootout   gui/$UID/com.example.expenses
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.example.expenses.plist
launchctl kickstart -k gui/$UID/com.example.expenses

The durable fix is to stop the deploy from ever changing the granted hash. I wrote a tiny C launcher whose entire body is a hardcoded-path execv to the real parser:

#include <unistd.h>
int main(int argc, char **argv) {
    char *path = "/Users/me/selfhost/expenses/expenses-real";
    execv(path, argv);
    return 127; /* execv only returns on failure */
}

I grant Full Disk Access to the launcher once. The launcher never changes, so its hash never changes, so the grant never lapses. The real parser, exec'd by the launcher, inherits TCC responsibility from it, which means I can rebuild and redeploy expenses-real as often as I like without macOS revoking anything. The plist then points /bin/bash at the launcher instead of the parser. It is a few lines of C standing in for a thing systemd would never make me think about, and it is the single most satisfying fix in the whole setup.

The broker job: degrade to stale, never to garbage

The portfolio feed talks to my broker's REST API, and the interesting engineering there is about authentication and about how it fails. The broker uses a two-step token flow: a short-lived request token is exchanged for an access token, and the exchange is authenticated with a SHA-256 checksum of api_key + request_token + api_secret. The resulting access token is cached at ~/.broker-token and reused on every subsequent run, so the 15-minute fetcher does not re-authenticate fifteen times an hour.

Access tokens expire, though, typically once a day, so there is a small daily reauth job (the one in the inventory table) whose only purpose is to walk the request-token-to-access-token exchange again and rewrite the cache. The secrets it needs come from ~/selfhost/.env, with the option to pull them from the 1Password CLI (op) instead of having them sit in a file.

The part I am proudest of is the failure behavior. When the API returns a 401 or 403, the fetcher does not panic and it does not overwrite anything. It emits a small marker and stops:

{"status": "token_expired"}

Crucially, it leaves the last good portfolio.json exactly where it was. The dashboard reads that file, so on an auth failure it shows yesterday's real numbers rather than zeros or an error blob. The rule I settled on across all the finance jobs is the same: degrade to stale-but-real, never to garbage. A number that is a few hours old is fine. A number that is silently wrong is the thing that erodes your trust in the whole dashboard.

The ledger: double-entry as a smoke alarm

The third finance feed is a ledger. It is a plain-text double-entry Beancount file, served through Fava at ledger.home.example.dev. I did not choose double-entry for accounting purity. I chose it because it fails loudly.

In a single-column "list of expenses" file, a typo or a dropped line just makes a total quietly wrong, and you never find out. In Beancount every transaction must balance: what leaves one account has to arrive in another. A bad import or a fat-fingered amount does not skew a total silently. It produces books that do not balance, and Fava puts that in front of me in red. The accounts are generic and readable, which is most of the appeal:

2026-06-15 * "School fees"
  Assets:Bank:Savings        -1200.00 INR
  Expenses:Education:School   1200.00 INR
 
2026-06-16 * "Monthly internet"
  Assets:Bank:Savings         -999.00 INR
  Expenses:Bills:Internet      999.00 INR

The investment side reconciles against Assets:Investments:Broker, which is the same portfolio the broker job tracks, from the other direction. Two independent paths to the same number is itself a check.

macOS does not want to be a server

This is the part of the series where the laptop-as-server bet shows its scars. macOS is a wonderful desktop OS and a slightly grudging server, and the friction is concentrated in a few places.

The sharpest one is Full Disk Access, which I have already spent a whole section on. The summary is that a privacy model designed to protect a single human at a desktop fights a model where unattended jobs read protected paths after every rebuild, and the only real win is to make the granted binary something that never changes.

The second is sleep. A laptop's whole instinct is to sleep when the lid is closed and nothing is happening. A server must not. macOS has the tool for this, caffeinate -d -i -m -s, which holds off display, idle, disk, and system sleep at once. I keep the machine awake with it, but I will be honest: I run it outside a tracked launchd job rather than as a hardened, managed service, and "the laptop went to sleep" remains a failure mode I have not fully engineered away. It is the most embarrassing gap in the setup and the most laptop-specific.

The third is subtler: launchd is powerful and poorly documented, and its failure mode for a malformed plist is to silently not run. There is no friendly error and no startup complaint. You learn to lean on launchctl kickstart -k to force a run, to read the StandardErrorPath log first and the code second, and to treat "the job exists" and "the job actually fired" as two different facts you have to verify separately. Most of my bad evenings with this setup were really me trusting the first fact and never checking the second.

Why this layer, and not a container for everything

I could have containerized these jobs too. I deliberately did not. They are the things that need the host: the Messages database at ~/Library/Messages/chat.db, the macOS keychain and 1Password CLI for credentials, the system battery via pmset, the native model server behind the assistant daemons. Pushing any of them into a container would mean fighting the sandbox to reach exactly the host resources that make them work, and several of them (TCC-protected paths, the keychain) you simply cannot reach from inside one. So the rule became: containers for anything portable, launchd for anything that genuinely needs to be the Mac. That split keeps the Compose file clean and the host jobs honest about why they are not containers.

The honest caveat on the whole layer is that it is bespoke and undocumented in any sense that would survive me. It is a pile of small programs held together by my own conventions, a single .env, and a habit of reading .err files. None of it would onboard a second person without a long conversation. But that is also the deal I signed up for.

The takeaway is that the interesting part of a home server is rarely the apps you install. It is the small, boring automation you accrete around your own data once you have a machine that is always on and always yours. launchd is an unglamorous way to run it, and macOS makes you work for it, but the result is a server that does my filing while I am asleep. The next part goes back to the visible layer and the one most people build a home server for in the first place: the media stack, and the equally instructive list of things I added and then deleted.

FAQ

How do I schedule a recurring job on macOS without cron?

Use a launchd plist in ~/Library/LaunchAgents/ with a StartInterval key (value in seconds) for fixed intervals, or StartCalendarInterval for clock-based schedules like cron. Load it with launchctl bootstrap gui/$UID ~/Library/LaunchAgents/your.job.plist.

Why does my launchd job silently stop working after I redeploy the binary?

macOS Full Disk Access grants are keyed to both the binary path and its code hash. Replacing the binary at the same path changes the hash, and macOS silently revokes the TCC grant even though the entry still appears in System Settings. Re-add the rebuilt binary to the Full Disk Access list, then reload the job with launchctl bootout and bootstrap.

How do I force a launchd job to run immediately without waiting for its timer?

Use launchctl kickstart -k gui/$UID/com.example.yourlabel. The -k flag kills any currently running instance and starts a fresh one right away, so you do not have to wait for the next scheduled interval.

Why does my launchd job read nothing from ~/Library/Messages/chat.db even though I granted Full Disk Access?

Two things can cause this. First, if you invoke the job via /bin/bash -c, then /bin/bash itself also needs Full Disk Access, not just the binary it launches. Second, if the binary was rebuilt after the grant was made, the code hash changed and macOS silently revoked access.

What is the difference between launchctl load and launchctl bootstrap?

launchctl load is the legacy form and still works, but launchctl bootstrap is the modern per-domain equivalent. Use bootstrap gui/$UID path/to/plist to load and bootout gui/$UID/label to unload; this form is more explicit about which user session owns the job and is required for kickstart to work reliably.