Back to Blog

Zsh Shell Tutorial #24: Automate with cron & launchd

Sandy LaneSandy Lane

Video: Zsh Shell Tutorial #24: Automate with cron & launchd by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Zsh Lesson 24: Schedule Tasks with cron and launchd

crontab -e edits the cron table. Format: min hour day month dow command. */5 * * * * every 5 min, 0 9 * * 1-5 weekdays at 9am, @daily, @reboot. On macOS, launchd is the modern alternative — more features but more setup.

When you need a script to run on schedule, cron is the universal answer. macOS prefers launchd, but cron still works.

crontab basics

crontab -e          # edit your crontab
crontab -l          # list current jobs
crontab -r          # remove ALL jobs (careful!)

Each user has their own crontab. The file format:

min hour day-of-month month day-of-week command

Each field is a number, range, list, or * (any).

The five fields

┌────── minute (0-59)
│ ┌──── hour (0-23)
│ │ ┌── day of month (1-31)
│ │ │ ┌ month (1-12)
│ │ │ │ ┌ day of week (0-7, Sun = 0 or 7)
│ │ │ │ │
* * * * * command-to-run

Examples:

0 * * * *        Every hour (at :00)
0 9 * * *        Daily at 9:00 AM
0 9 * * 1        Every Monday at 9:00 AM
0 0 1 * *        First of each month at midnight
0 0 * * 0        Every Sunday at midnight
*/5 * * * *      Every 5 minutes
0 9-17 * * *     Hourly from 9am to 5pm
0 9 * * 1-5      Weekdays at 9am
0 0,12 * * *     Midnight and noon

Special strings

@reboot          Run once at startup
@hourly          0 * * * *
@daily           0 0 * * *
@weekly          0 0 * * 0
@monthly         0 0 1 * *
@yearly          0 0 1 1 *

Easier to read than the field syntax for common schedules.

Adding a job

# Append to your crontab
(crontab -l 2>/dev/null; echo "0 3 * * * /usr/local/bin/backup.sh") | crontab -

# Or interactively
crontab -e          # opens in $EDITOR; add line; save

The (crontab -l; echo "...") | crontab - pattern is idempotent for scripts that install themselves.

Logging

Cron has no terminal. Output goes to mail (or nowhere). Always redirect:

0 * * * * /path/to/job.sh >> /var/log/job.log 2>&1

>> file 2>&1 appends both stdout and stderr. Without it, debug is impossible.

Environment

Cron runs with a minimal environment. $PATH is usually just /usr/bin:/bin. No ~/.zshrc. Variables you set in your shell are gone.

Workarounds:

# Set PATH explicitly
PATH=/usr/local/bin:/usr/bin:/bin

0 9 * * * cd /home/user && ./script.sh

Or write the script to use absolute paths:

#!/usr/bin/env zsh
# Don't rely on PATH
export PATH=/usr/local/bin:/usr/bin:/bin

cd /home/user/project
./run.sh

Always test scripts in cron's minimal environment before depending on them:

env -i HOME=$HOME PATH=/usr/bin:/bin /usr/bin/zsh -l -c './my_script.sh'

A backup task

# /usr/local/bin/backup.sh
#!/usr/bin/env zsh
set -euo pipefail

LOG=/var/log/backup.log
DEST=/backup/$(date +%Y%m%d-%H%M%S).tar.gz
SRC=/home/user/data

{
  echo "[$(date)] Starting backup"
  tar -czf "$DEST" "$SRC"
  echo "[$(date)] Finished: $DEST ($(du -h "$DEST" | cut -f1))"
} >> "$LOG" 2>&1

Crontab entry:

0 3 * * * /usr/local/bin/backup.sh

Daily at 3am. The script handles its own logging — no need for cron-level redirection.

Removing a job

crontab -e          # delete the line, save

# Or programmatically
crontab -l | grep -v "/usr/local/bin/backup.sh" | crontab -

crontab -r removes the entire crontab. Don't use unless you mean it.

launchd: macOS's modern scheduler

macOS has cron, but Apple recommends launchd for new code. It's more powerful (can run on file changes, network events, etc.) but more setup.

A LaunchAgent is an XML "plist" file:

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

  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/backup.sh</string>
  </array>

  <key>StartInterval</key>
  <integer>3600</integer>

  <key>StandardOutPath</key>
  <string>/tmp/backup.log</string>

  <key>StandardErrorPath</key>
  <string>/tmp/backup.err</string>
</dict>
</plist>

Install:

mkdir -p ~/Library/LaunchAgents
cp com.example.backup.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.example.backup.plist

Verify:

launchctl list | grep backup

Unload:

launchctl unload ~/Library/LaunchAgents/com.example.backup.plist

launchd schedule options

Instead of StartInterval (seconds), you can use StartCalendarInterval for cron-like schedules:

<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key>
  <integer>9</integer>
  <key>Minute</key>
  <integer>0</integer>
  <key>Weekday</key>
  <integer>1</integer>    <!-- Monday -->
</dict>

Or run on triggers:

  • WatchPaths — when a file changes.
  • QueueDirectories — when a queue dir gets a new file.
  • KeepAlive — restart if it dies (service-style).

launchctl commands

launchctl list                                # all services
launchctl list | grep MY_LABEL
launchctl load PATH                           # load a plist
launchctl unload PATH                         # unload
launchctl start LABEL                         # run now
launchctl stop LABEL
launchctl bootstrap gui/$(id -u) PATH        # modern equivalent of load
launchctl bootout gui/$(id -u)/LABEL         # modern equivalent of unload

The bootstrap/bootout are newer; load/unload still work.

When to use which

  • cron — simple, portable, fine for most periodic tasks. Works the same on Linux and macOS.
  • launchd — macOS-specific. Use when you need: file-change triggers, restart-on-crash, GUI integration, system-level (vs user-level) services.

For day-to-day "run this every hour": both are fine. Pick one and stick with it.

Linux equivalent: systemd timers

On modern Linux, systemd timers are the cron alternative:

# /etc/systemd/system/backup.service
[Service]
ExecStart=/usr/local/bin/backup.sh

# /etc/systemd/system/backup.timer
[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl enable --now backup.timer
sudo systemctl list-timers

More features, more setup. cron is still fine for most things.

Anacron: missed jobs

Cron doesn't run if the machine was off. anacron does — it tracks when each job last ran and catches up.

# /etc/anacrontab (Linux)
1   5   daily.task    /usr/local/bin/daily.sh
7   10  weekly.task   /usr/local/bin/weekly.sh

Format: period (days), delay (min after boot), name, command. macOS doesn't ship anacron; install via brew.

For laptops that aren't always on, anacron is essential.

Troubleshooting

Cron job doesn't run:

  1. Check crontab -l — is it actually there?
  2. Check the syntax — crontab is strict.
  3. Check the time on the machine: date.
  4. Add logging: >> /tmp/cron.log 2>&1.
  5. Check that the script works manually with cron's minimal env.

Output disappearing:

Cron mails output. If mail isn't configured, it's lost. Always redirect explicitly.

Path issues:

./script.sh won't work — cron's CWD is your home. Use absolute paths or cd first.

A complete cron deployment

#!/usr/bin/env zsh
set -euo pipefail

# Install a self-contained backup job
SCRIPT=/usr/local/bin/backup.sh
LOG=/var/log/backup.log

# Write the script
sudo tee "$SCRIPT" > /dev/null <<'EOF'
#!/usr/bin/env zsh
set -euo pipefail
SRC=/home/alice/work
DEST=/backup/$(date +%Y%m%d-%H%M%S).tar.gz
mkdir -p "$(dirname "$DEST")"
tar -czf "$DEST" "$SRC"
echo "[$(date)] backed up to $DEST"
EOF
sudo chmod +x "$SCRIPT"

# Install the cron entry
(crontab -l 2>/dev/null | grep -v "$SCRIPT"; echo "0 3 * * * $SCRIPT >> $LOG 2>&1") | crontab -

echo "Installed. Verify:"
crontab -l | grep "$SCRIPT"

Idempotent — running again replaces the entry rather than duplicating.

Common stumbles

% in crontab. Treated as newline. Escape with \%. Common in date +%Y-%m-%d — wrap in quotes or escape.

No PATH. Set explicitly at the top of your crontab or use absolute paths.

Script works manually but not in cron. Almost always env or PATH. Recreate cron's env to test.

Time zone. Cron runs in the system time zone. TZ=America/Los_Angeles at the top of your crontab to override (per user).

@reboot doesn't fire. Some systems run it before networking is up. For network-dependent tasks, prefer systemd or wait inside the script.

Editing other users' crontab. sudo crontab -e -u USER.

launchd plist permissions. chmod 644 on the plist; chown to your user.

What's next

Lesson 25: Oh My Zsh. Plugins, themes, and the most popular Zsh framework.

Recap

crontab -e to edit, crontab -l to list, crontab -r to wipe. Format: min hour dom mon dow cmd. */5 step, 1-5 range, 1,3,5 list. Special: @daily, @hourly, @reboot. Always redirect output: >> log 2>&1. Cron has minimal env — set PATH or use absolute paths. launchd is macOS's modern scheduler — XML plist + launchctl. systemd timers on Linux are the equivalent. anacron for missed jobs on laptops.

Next lesson: Oh My Zsh.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.