Skip to content

Run a scheduled task on macOS with launchd

Published: | 4 min read

Table of contents

Open Table of contents

Intro

I needed to run a scheduled task periodically on macOS, and this is how I did it with launchd. At the time of writing I am on macOS Sequoia 15.1.

The task itself is a simple backup shell script.

About launchd

Launchd comes builtin with macOS, and it is an open source service management framework. It is responsible of starting, stopping and managing daemons and agents. This little thing is the first process the kernel runs on system start up, which then starts all the deamons and other apps running automatically when macOS starts.

To interface with launchd, we need to use the CLI app launchctl.

Create the shell script to run

The task I wanted to run periodically is a simple backup script, which copies one file from source location to destination with timestamped name.

#!/bin/bash

source_file="/Users/me/Library/Application Support/App/content.txt"

# Get current date and time formatted as 2024-11-10_17-01-02
timestamp=$(date +%Y-%m-%d_%H-%M-%S)

target_folder="/Users/me/backups/App"

cp "$source_file" "$target_folder/content_$timestamp.txt"

I saved this file as /Users/me/backups/scripts/app-backup.sh and made sure it is executable with chmod +x /Users/me/backups/scripts/app-backup.sh.

This script of course could be anything, but let’s stick with this simple example.

Note: The script will run outside of your “normal” shell context / session, e.g. anything you do in .bashrc will not be loaded.

Define the agent task

Tasks are defined in plist format, which is a specific definition on top of xml. The key difference between an agent and a daemon is that an agent runs on behalf of logged in user while a daemon runs on behalf of root.

I want to run the task as a logged in user, while the laptop is on.

<?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>net.janik6n.backups</string>
    <key>ProgramArguments</key>
    <array>
      <string>/bin/bash</string>
      <string>/Users/me/backups/scripts/app-backup.sh</string>
    </array>
    <key>StandardErrorPath</key>
    <string>/Users/me/backups/logs/app-backup-error.log</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>MY_KEY</key>
        <string>hey</string>
    </dict>
    <key>StartInterval</key>
    <integer>3600</integer>
  </dict>
</plist>

The format is a rather simple key-value based file. These definitions can contain a lot more than I needed here. To walk through each key:

The file need to be saved to a specific location depending on what context the task needs to run:

Since the last option is the one I want, and most likely is always what should be used, I saved the file as ~/Library/LaunchAgents/net.janik6n.backups.plist.

Launch the agent task

Now that everything is in place, the agent can be started with launchctl by running:

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/net.janik6n.backups.plist

Where

Note: If agent with same “name” already exists, it must be enabled before it can be bootstrapped again.

And just like that I had the backups automated!

Other useful launchctl commands

There are few other launchctl commands, which are necessary to know.

List launch agents:

launchctl print gui/$(id -u) | grep "janik6n"

Remove agent task:

# Unload launch agent
# Note: Unloaded agent will run again after reboot!
launchctl bootout gui/$(id -u) net.janik6n.backups

# Disable launch agent
# Note: Disabled agent will not run after reboot, until it is enabled
launchctl disable gui/$(id -u)/net.janik6n.backups

# Enable launch agent
# Note: will not run until bootstrapped again or computer is rebooted
launchctl enable gui/$(id -u)/net.janik6n.backups

So, always bootout & disable, so that the launch agent will not start again after reboot unless you want to disable a task temporarily.

Reading list