Zsh Shell Tutorial #24: Automate with cron & launchd
Video: Zsh Shell Tutorial #24: Automate with cron & launchd by Taught by Celeste AI - AI Coding Coach
Zsh Lesson 24: Schedule Tasks with cron and launchd
crontab -eedits the cron table. Format:min hour day month dow command.*/5 * * * *every 5 min,0 9 * * 1-5weekdays 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:
- Check
crontab -l— is it actually there? - Check the syntax —
crontabis strict. - Check the time on the machine:
date. - Add logging:
>> /tmp/cron.log 2>&1. - 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.