A deep dive into cron

By

Learn cron on Linux and macOS: crontab syntax, schedules, the cron environment, logs, overlapping runs, time zones, launchd and systemd timers.

~~~

Cron runs commands on a schedule. Every night at 2:15, every 15 minutes, every Monday at 9. You write one line, and the machine does the work for you from then on.

It has been part of Unix since the early days, and it’s still everywhere. Every Linux server has it, and so does your Mac. The version you’ll find on Debian, Ubuntu and macOS is Paul Vixie’s cron, and Fedora and Red Hat ship cronie, a fork of it. They all read the same format.

In this tutorial we’ll start with a one-line job, learn the schedule syntax, and then build a real nightly backup job step by step. Along the way we’ll hit every problem that makes people say “it works in my terminal but not in cron”, and fix each one.

I wrote a short intro to the crontab command a few years ago. This is the full version.

Your first cron job

Each user has their own list of jobs, called a crontab (cron table). You manage it with the crontab command.

Let’s see what’s in yours:

crontab -l

If you’ve never used cron, you’ll see something like this:

crontab: no crontab for flavio

To add a job, open your crontab in an editor:

crontab -e

On macOS this opens vi. On Ubuntu, the first time, it asks you to pick an editor. If you prefer nano, set the EDITOR variable:

EDITOR=nano crontab -e

Add this line, then save and close the editor:

* * * * * date >> /tmp/cron-test.txt

The five stars mean “every minute”. The rest of the line is the command. When you exit the editor, crontab checks the syntax and installs the new table:

crontab: installing new crontab

Wait a couple of minutes, then look at the file:

cat /tmp/cron-test.txt
Thu Sep 24 10:41:00 CEST 2026
Thu Sep 24 10:42:00 CEST 2026

Cron ran date once a minute and appended the output to the file. Notice the seconds are always 00. Cron wakes up once a minute, checks every job, and starts the ones that match the current minute.

Fig 1Cron
Click to advance one minuteMon 02:12

Now run crontab -e again, delete the line, and save. The job is gone.

You don’t need to restart anything. Cron notices when a crontab changes and reloads it on its own.

Your crontab is stored in a system folder: /var/spool/cron/crontabs/flavio on Debian and Ubuntu, /var/spool/cron/flavio on Fedora, /usr/lib/cron/tabs/flavio on macOS. Don’t edit those files directly. Always go through crontab, which validates the file before cron reads it.

Reading a cron line

A cron line has five time fields followed by the command:

┌───────────── minute (0-59)
│ ┌─────────── hour (0-23)
│ │ ┌───────── day of month (1-31)
│ │ │ ┌─────── month (1-12)
│ │ │ │ ┌───── day of week (0-7, both 0 and 7 are Sunday)
│ │ │ │ │
15 2 * * * /home/flavio/bin/backup-notes.sh

Read it from left to right: at minute 15, of hour 2, on any day of the month, in any month, on any day of the week. So every night at 2:15.

The hour uses the 24-hour clock. 14 is 2 in the afternoon.

The smallest unit is the minute. Cron can’t run something every 10 seconds. If you need every 30 seconds, the usual trick is two lines, one of them waiting 30 seconds first:

* * * * * /home/flavio/bin/check-queue.sh
* * * * * sleep 30; /home/flavio/bin/check-queue.sh

Anything more frequent than that is a job for a long-running process, not cron.

Writing schedules

Each field accepts a few kinds of values:

  • * matches every value
  • a number matches exactly that value: 5
  • a list matches any of the values: 1,15
  • a range matches every value in between, both ends included: 1-5
  • a step matches every Nth value: */15, or 0-30/10 inside a range
  • names work for months and days of the week: jan, mon

Names can’t be used in ranges or lists with Vixie cron (Debian, Ubuntu, macOS), so write 1-5 instead of mon-fri.

Here are schedules you’ll use all the time:

*/15 * * * *      every 15 minutes
0 * * * *         every hour, on the hour
30 8 * * 1-5      8:30 on weekdays
0 9 * * mon       9:00 every Monday
0 18 * * 0,6      18:00 on Saturday and Sunday
0 9-17 * * 1-5    every hour from 9:00 to 17:00, on weekdays
0 */6 * * *       00:00, 06:00, 12:00 and 18:00
0 0 1 * *         midnight on the first day of every month
0 3 1 1,4,7,10 *  3:00 on the first day of every quarter
Fig 2 · 1/7Cron line
Click for the next scheduleevery night at 2:15

The most common mistake is leaving the minute as *:

* 2 * * * /home/flavio/bin/backup-notes.sh

This doesn’t run once at 2am. It runs every minute from 2:00 to 2:59, sixty times. If you want a job to run once in a given hour, always set the minute.

Steps restart in every hour, day or month. */7 in the minute field means 0, 7, 14, and so on up to 56, and then 0 again at the top of the next hour. The gap between 56 and 0 is 4 minutes, not 7. */45 runs at minute 0 and minute 45, which is not “every 45 minutes”. Stick to steps that divide the range evenly: 5, 10, 15, 20 or 30 for minutes, and 2, 3, 4, 6, 8 or 12 for hours.

The day-of-month and day-of-week trap

Here’s a schedule that looks like “9:00 on every Friday the 13th”:

0 9 13 * 5 /home/flavio/bin/friday-13th.sh

It isn’t. When both day fields are set (neither is *), cron runs the job when either of them matches. This runs on the 13th of every month, and also on every Friday.

Fig 3 · 1/4Day fields
Click for the next scheduleruns on 1 day: the 13th

The fix is to schedule on one field and check the other inside the command. date +%u prints the day of the week as a number, with Monday as 1 and Friday as 5:

0 9 13 * * [ "$(date +\%u)" = 5 ] && /home/flavio/bin/friday-13th.sh

The job starts on every 13th, and the test lets the script run only when that 13th is a Friday. The backslash before % is needed, and we’ll see why in a moment.

The same trick solves “the second Tuesday of the month”. The second Tuesday always falls between the 8th and the 14th:

0 9 8-14 * * [ "$(date +\%u)" = 2 ] && /home/flavio/bin/team-report.sh

Schedules cron can’t express

Some schedules don’t fit the five fields. You can still get them with a small test or a second line.

The last day of the month changes from month to month. Run the job on the 28th to the 31st, and only do the work if tomorrow is the 1st. On Linux, GNU date understands -d tomorrow:

0 23 28-31 * * [ "$(date -d tomorrow +\%d)" = 01 ] && /home/flavio/bin/monthly-report.sh

macOS uses BSD date, which spells it -v+1d:

0 23 28-31 * * [ "$(date -v+1d +\%d)" = 01 ] && /Users/flavio/bin/monthly-report.sh

Every 90 minutes doesn’t divide a day in a way one line can describe. Two lines can:

0 0-21/3 * * * /home/flavio/bin/sync-photos.sh
30 1-22/3 * * * /home/flavio/bin/sync-photos.sh

The first line runs at 0:00, 3:00, 6:00 and so on. The second runs at 1:30, 4:30, 7:30. Together they run every 90 minutes.

When you’re unsure about a schedule, check it before you install it. I built a free cron expression builder that explains a schedule in plain English and shows the next run times. crontab.guru is another good one.

Shortcuts

Instead of the five fields, you can use one of these special strings:

@reboot     once, when cron starts at boot
@hourly     same as 0 * * * *
@daily      same as 0 0 * * *
@midnight   same as @daily
@weekly     same as 0 0 * * 0
@monthly    same as 0 0 1 * *
@yearly     same as 0 0 1 1 *
@annually   same as @yearly

@reboot is handy to start something after the machine boots:

@reboot /home/flavio/bin/start-tunnel.sh

The others are only easier to read. Be careful with them on a server that runs many jobs, because every @daily job on the machine starts at exactly midnight. My advice is to write real times, like 15 2 * * *, so jobs don’t all start in the same minute.

The command part

Everything after the five fields is the command. Cron runs it with /bin/sh, not with your login shell.

That matters. On Ubuntu and Debian, /bin/sh is dash, a small shell without Bash features. [[ ... ]], source, arrays and {1..5} don’t work there. For anything beyond a simple command, put the logic in a script with a #!/bin/bash line at the top, and call the script from cron.

The command must fit on one line. There’s no way to continue it on the next line.

Comments go on their own line. A # after a command is not a comment, it becomes part of the command:

# nightly backup of my notes
15 2 * * * /home/flavio/bin/backup-notes.sh

The percent sign

This one catches everyone. In a crontab, % means “newline”. Cron cuts the command at the first % and sends everything after it to the command as input.

So this line, which works fine in your terminal, is broken in cron:

0 2 * * * tar -czf /home/flavio/backups/notes-$(date +%F).tar.gz -C /home/flavio notes

Cron runs tar -czf /home/flavio/backups/notes-$(date + and fails. Escape every % with a backslash:

0 2 * * * tar -czf /home/flavio/backups/notes-$(date +\%F).tar.gz -C /home/flavio notes

This only applies to the crontab line. Inside a script, % is a normal character. That’s another reason to move anything non-trivial into a script.

Cron’s environment

This is the reason most cron jobs fail.

When you open a terminal, your shell reads .zshrc or .bashrc, and sets up your PATH, your aliases, your version managers and your variables. Cron does none of that. It starts your command with an almost empty environment.

Let’s look at it. Add this job:

* * * * * env > /tmp/cron-env.txt

After a minute, read the file (and remove the job):

cat /tmp/cron-env.txt

On Ubuntu you’ll see something like this:

HOME=/home/flavio
LOGNAME=flavio
PATH=/usr/bin:/bin
SHELL=/bin/sh
PWD=/home/flavio

Compare it with env in your terminal, which probably prints dozens of lines. A few consequences:

  • PATH is /usr/bin:/bin. Anything installed elsewhere is “command not found”: /usr/local/bin, Homebrew’s /opt/homebrew/bin, ~/.local/bin, Node from nvm, Python from a virtual environment.
  • The job starts in your home folder. A relative path like ./data.json is relative to HOME, not to the folder the script lives in.
  • Aliases and shell functions don’t exist.
  • There’s no terminal. Commands that ask a question or expect input hang or fail.
  • There’s no ssh-agent, so ssh and git push can’t use a key that needs a passphrase.
Fig 4 · 1/3Environment
Click to run it somewhere elseyour terminal · node runs

There are two fixes, and you’ll want both.

The first is to use absolute paths. Find where a program lives with command -v:

command -v node
/opt/homebrew/bin/node

Then use that full path in the job or in the script.

The second is to set variables at the top of the crontab. They apply to every job below them:

PATH=/usr/local/bin:/usr/bin:/bin
SHELL=/bin/bash

15 2 * * * /home/flavio/bin/backup-notes.sh

Be careful, because crontab variables are not expanded. PATH=$HOME/bin:$PATH sets PATH to that literal text, dollar signs included. Write the full value.

To catch environment problems before cron does, run your command with a cron-like environment. env -i starts with an empty environment, and we add only what cron provides:

env -i HOME="$HOME" LOGNAME="$LOGNAME" PATH=/usr/bin:/bin SHELL=/bin/sh /bin/sh -c '/home/flavio/bin/backup-notes.sh'

If the command works here, it will very likely work in cron too.

Building a real job: a nightly backup

Let’s put everything together. We want to back up a notes folder every night, keep two weeks of backups, and know when something goes wrong.

The logic goes in a script. Create ~/bin/backup-notes.sh:

#!/bin/sh
set -eu

SOURCE="$HOME/notes"
DEST="$HOME/backups"

mkdir -p "$DEST"
tar -czf "$DEST/notes-$(date +%F).tar.gz" -C "$SOURCE" .
find "$DEST" -name 'notes-*.tar.gz' -mtime +14 -delete

echo "$(date '+%F %T') backup done"

set -eu stops the script at the first failing command or undefined variable, instead of carrying on. tar creates an archive named after today’s date, like notes-2026-09-24.tar.gz. find deletes the archives older than two weeks. The script uses only POSIX commands, so it works on Linux and macOS.

Make it executable and run it by hand first:

chmod +x ~/bin/backup-notes.sh
~/bin/backup-notes.sh
2026-09-24 10:52:13 backup done

Now run it with cron’s environment:

env -i HOME="$HOME" LOGNAME="$LOGNAME" PATH=/usr/bin:/bin SHELL=/bin/sh /bin/sh -c "$HOME/bin/backup-notes.sh"

Both work. Create a folder for the log, then open crontab -e and add the job:

mkdir -p ~/logs
15 2 * * * /home/flavio/bin/backup-notes.sh >> /home/flavio/logs/backup-notes.log 2>&1

Every night at 2:15 the script runs, and both its output and its errors go to the log file. On a Mac, use /Users/flavio instead of /home/flavio.

Before trusting it, test the real schedule once. Change the time to two minutes from now, wait, check the log and the backups folder, then set it back to 15 2.

Output, logs and email

Anything a job prints needs somewhere to go, because there’s no terminal.

By default cron emails the output to the owner of the crontab. That was a great idea when every Unix machine had working email. Today, on a fresh VPS, there’s usually no mail server, and on Debian and Ubuntu the output is thrown away with this line in the log:

CRON[48213]: (CRON) info (No MTA installed, discarding output)

On macOS, the output lands in your local mailbox at /var/mail/flavio, and your terminal starts saying “You have mail”. Run mail to read it.

In practice you choose what happens to the output with redirections at the end of the line.

Append everything to a log file:

15 2 * * * /home/flavio/bin/backup-notes.sh >> /home/flavio/logs/backup-notes.log 2>&1

>> appends the normal output to the file, and 2>&1 sends errors to the same place. The order matters: 2>&1 >> file sends errors to the old output, which is nowhere.

Throw away everything:

*/5 * * * * php /var/www/sendy/scheduled.php > /dev/null 2>&1

Many apps tell you to add a line like this, and it’s fine for a job that runs every few minutes and logs on its own. The downside is that when it fails, nothing tells you.

Throw away the normal output and keep the errors:

15 2 * * * /home/flavio/bin/backup-notes.sh > /dev/null

Only errors are left, and cron emails them to you, if email works on the machine. The MAILTO variable at the top of the crontab picks the recipient, and MAILTO="" turns email off:

[email protected]

On Linux you can also send messages to the system log with logger, and read them with journalctl:

logger -t backup-notes "backup done"
journalctl -t backup-notes

A log file you append to forever keeps growing. For a line per night, that takes years to matter. For a job that prints a lot every minute, set up logrotate on Linux, or have the script truncate its own log.

Did it run?

On Debian and Ubuntu, cron writes a line to the system log every time it starts a job:

journalctl -u cron --since today
Sep 24 02:15:01 box CRON[48213]: (flavio) CMD (/home/flavio/bin/backup-notes.sh >> /home/flavio/logs/backup-notes.log 2>&1)

If your server still writes classic log files, grep CRON /var/log/syslog shows the same lines. On Fedora and Red Hat the service is called crond, so use journalctl -u crond, and the log file is /var/log/cron.

No line at the expected time means cron never started the job. The schedule is wrong, the crontab didn’t install, or cron isn’t running. Check with systemctl status cron.

A line there only proves that cron started the command. It says nothing about whether the command worked. For that you need your own log file, or the result of the job itself: is today’s backup in the folder?

For jobs that matter, add a check that fails loudly. Services like Healthchecks.io give you a URL to ping when the job succeeds. If the ping doesn’t arrive on time, they email you:

15 2 * * * /home/flavio/bin/backup-notes.sh >> /home/flavio/logs/backup-notes.log 2>&1 && curl -fsS -m 10 https://hc-ping.com/8f3e5c1a-2b4d-4e6f-9a7b-1c2d3e4f5a6b > /dev/null

The && means the ping only happens if the script exits without an error. This catches all the silent failures: the job that stopped running, the server that’s off, the crontab that someone deleted.

Stopping overlapping runs

Cron doesn’t know whether the previous run of a job has finished. If a job runs every 5 minutes and one run takes 7 minutes, cron starts a second copy while the first is still working. Two copies of a sync script writing to the same files is how you corrupt data.

On Linux, flock fixes this. It takes a lock on a file, and runs the command only if it got the lock:

*/5 * * * * flock -n /tmp/sync-photos.lock /home/flavio/bin/sync-photos.sh

-n means “don’t wait”. If the previous run still holds the lock, the new one exits immediately and tries again in 5 minutes.

Fig 5 · 1/2Overlap
Click to toggle flockno lock · two copies run at once

macOS doesn’t have flock, but it has lockf, which does the same. -t 0 means “don’t wait”:

*/5 * * * * lockf -t 0 /tmp/sync-photos.lock /Users/flavio/bin/sync-photos.sh

A job can also hang forever, waiting on a network call that never returns, and then hold its lock forever. On Linux, timeout kills it after a time limit:

*/5 * * * * flock -n /tmp/sync-photos.lock timeout 10m /home/flavio/bin/sync-photos.sh

macOS doesn’t ship timeout, but Homebrew’s coreutils package adds it.

Write jobs so that running them twice does no harm. Our backup script is a good example: a second run on the same day overwrites the same file. A job that sends emails or charges customers needs to remember what it already did.

Time zones and daylight saving time

Cron uses the machine’s time zone. Check it with date, or with timedatectl on Linux:

timedatectl

Many servers run on UTC. If your server is on UTC and you want a job at 9:00 in Rome, you have to do the math, and the math changes twice a year.

You can set the server’s time zone and then restart cron, which reads the time zone when it starts:

sudo timedatectl set-timezone Europe/Rome
sudo systemctl restart cron

Daylight saving time brings its own problems. In most of Europe and the US, clocks jump forward at night once a year, skipping an hour, and go back once a year, repeating an hour. A job at 2:30 falls exactly in that window.

Debian and Ubuntu cron handle this carefully. When the clock jumps forward, jobs in the skipped hour run right after the change. When it goes back, jobs in the repeated hour don’t run twice. This only applies to jobs with a fixed time. Jobs like */15 * * * * follow the new clock right away. macOS cron doesn’t do this by default, so a job in that window can be skipped or run twice. My advice is to not schedule jobs between 1:00 and 3:00 local time, or to keep servers on UTC, which has no daylight saving time.

Fig 6 · 1/5Daylight saving
Click for the next nighta normal night · runs at 2:30

What about a job-specific time zone? cronie, on Fedora and Red Hat, supports a CRON_TZ variable in the crontab. Vixie cron on Debian, Ubuntu and macOS doesn’t, and runs every job in the system time zone.

Setting TZ in a crontab is a different thing. It changes the time zone the command sees, which affects what date prints inside the job. It doesn’t change when cron runs the job.

System-wide cron on Linux

So far we used our own crontab. A Linux server has a few more places where scheduled jobs live, and you need to know them to understand what a machine does on its own.

Root and other users

sudo crontab -e edits root’s crontab. Jobs there run as root.

You can edit another user’s crontab with -u. Web apps usually run as www-data on Ubuntu, and their jobs should run as that user too, so files they create have the right owner:

sudo crontab -u www-data -e

/etc/crontab and /etc/cron.d

/etc/crontab is the system crontab. Its lines have an extra field after the schedule: the user to run the command as.

On Debian and Ubuntu it contains lines like this one:

17 * * * * root cd / && run-parts --report /etc/cron.hourly

At minute 17 of every hour, as root, it runs every script in /etc/cron.hourly. Similar lines run /etc/cron.daily, /etc/cron.weekly and /etc/cron.monthly. Run cat /etc/crontab to see the exact times on your machine.

/etc/cron.d is a folder of files in the same format. Packages drop their jobs there, and it’s a good place for your own server jobs too, because each job gets its own file that you can add and remove with a deploy script. Create /etc/cron.d/backup-notes:

SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin

15 2 * * * flavio /home/flavio/bin/backup-notes.sh >> /home/flavio/logs/backup-notes.log 2>&1

Files in /etc/cron.d have to follow some rules, and cron silently ignores the ones that don’t:

  • The file must be owned by root and not writable by group or others.
  • The name can only contain letters, digits, hyphens and underscores. A name with a dot is ignored, so backup-notes.cron never runs. This is how Debian avoids running leftover package files like something.dpkg-old.
  • Each file is independent. It doesn’t inherit variables from /etc/crontab, so set PATH in every file.
  • The last line must end with a newline, or cron may not read it.

/etc/cron.daily and friends

For a job that only needs to run “once a day, some time”, drop an executable script into /etc/cron.daily. There’s no schedule to write.

The same name rule applies here. backup.sh won’t run because of the dot, so call it backup. The script must also be executable.

run-parts --test lists which scripts in a folder would actually run:

run-parts --test /etc/cron.daily

If your script isn’t in the list, check its name and permissions.

Who can use cron

/etc/cron.allow and /etc/cron.deny control which users can have a crontab. If cron.allow exists, only the users listed in it can. If only cron.deny exists, everyone except the users listed can. On a standard Debian or Ubuntu system neither file exists, and every user can use cron.

Installing cron

Ubuntu Server and Debian install cron by default. Minimal images and containers often don’t. On Debian and Ubuntu:

sudo apt install cron
sudo systemctl enable --now cron

In Docker, the usual approach is not to run cron inside the container, but to schedule docker exec or docker run from the host, or to use your platform’s scheduler.

Finding everything that runs on a server

Before you migrate, clone or clean up a server, list every scheduled job on it:

sudo ls /var/spool/cron/crontabs
sudo cat /etc/crontab
ls /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly
systemctl list-timers --all

The first command lists the users who have a personal crontab. Read each one with sudo crontab -l -u www-data, replacing www-data with each name. The last command lists systemd timers, which we’ll see in a moment. I wrote about the other commands I check on a Linux server after moving my newsletter server.

Managing your crontab as a file

crontab -e is fine for quick edits. For jobs you care about, keep the crontab in a file you can version.

Save the current crontab:

crontab -l > ~/crontab.txt

Install a crontab from a file, replacing the current one:

crontab ~/crontab.txt

Now the file can live in your dotfiles repository or in the app’s repository, and you know exactly what’s installed.

A script can add a line without opening an editor:

(crontab -l 2>/dev/null; echo '15 2 * * * /home/flavio/bin/backup-notes.sh') | crontab -

crontab -l prints the current table, echo adds the new line, and crontab - installs the result from standard input. 2>/dev/null hides the “no crontab” message when the table is empty.

Be careful with crontab -r. It deletes your whole crontab, with no confirmation and no undo. r sits right next to e on the keyboard. On Debian and Ubuntu, crontab -i -r asks first. A saved copy like ~/crontab.txt is the real protection.

Cron on macOS

Cron works on macOS. Everything above applies, and the first job we wrote at the beginning runs just fine on a Mac. You don’t start cron yourself: launchd, the macOS service manager, starts it as soon as a crontab exists.

Apple’s own man page says cron’s job has been absorbed into launchd. Cron still works, but there are Mac-specific issues to know about.

Full Disk Access

macOS protects folders like Desktop, Documents, Downloads and iCloud Drive. A cron job that reads or writes there fails with “Operation not permitted”, even though the same command works in Terminal. You gave Terminal access to those folders at some point, but cron is a different program and doesn’t have it.

To fix it, open System Settings, go to Privacy & Security, then Full Disk Access. Click +, press Cmd-Shift-G, type /usr/sbin/cron, and add it.

This gives every cron job on the Mac access to all your files. If your job only touches folders outside the protected ones, like ~/notes and ~/backups in our example, you don’t need it.

Sleep

Cron doesn’t run jobs while the Mac is asleep, and it doesn’t catch up later. If the backup is scheduled for 2:15 and the lid is closed at 2:15, that night’s backup never happens.

For a laptop, launchd is the better scheduler. It runs a missed job when the Mac wakes up.

Battery

macOS cron supports a special prefix, @AppleNotOnBattery, that skips a job when the Mac runs on battery:

0 * * * * @AppleNotOnBattery /Users/flavio/bin/sync-photos.sh

Homebrew

Cron’s PATH doesn’t include Homebrew. On Apple Silicon Macs, Homebrew installs into /opt/homebrew/bin, so add it at the top of the crontab, or use full paths:

PATH=/opt/homebrew/bin:/usr/bin:/bin

The launchd way

A launchd job is a small XML file in ~/Library/LaunchAgents. Here’s our backup as a launchd job, in ~/Library/LaunchAgents/com.flavio.backup-notes.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>
  <key>Label</key>
  <string>com.flavio.backup-notes</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Users/flavio/bin/backup-notes.sh</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key>
    <integer>2</integer>
    <key>Minute</key>
    <integer>15</integer>
  </dict>
  <key>StandardOutPath</key>
  <string>/Users/flavio/logs/backup-notes.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/flavio/logs/backup-notes.log</string>
</dict>
</plist>

StartCalendarInterval is the schedule. Keys you leave out work like * in cron. Paths must be absolute, because launchd doesn’t expand ~ or $HOME.

Load it, and run it once right away to test it:

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.flavio.backup-notes.plist
launchctl kickstart gui/$(id -u)/com.flavio.backup-notes

It’s more typing than a cron line, but you get catch-up after sleep and built-in log redirection. My free Automate macOS course covers launchd jobs from scratch, including unloading, testing and preventing overlapping runs.

My advice on the Mac is cron for a quick job you’ll delete next week, and launchd for anything that has to keep running.

systemd timers on Linux

Most Linux distributions use systemd, and systemd has its own scheduler: timers. Newer software tends to schedule its work with timers instead of cron, which is why systemctl list-timers is on my server checklist.

A timer needs two files. The service describes what to run. Create /etc/systemd/system/backup-notes.service:

[Unit]
Description=Back up my notes

[Service]
Type=oneshot
User=flavio
ExecStart=/home/flavio/bin/backup-notes.sh

The timer describes when. Create /etc/systemd/system/backup-notes.timer:

[Unit]
Description=Back up my notes every night

[Timer]
OnCalendar=*-*-* 02:15:00
Persistent=true

[Install]
WantedBy=timers.target

OnCalendar is the schedule, written as year-month-day hour:minute:second with an optional weekday in front. Persistent=true means that if the machine was off at 2:15, the job runs as soon as it boots.

Enable it:

sudo systemctl daemon-reload
sudo systemctl enable --now backup-notes.timer

Check when it runs next, and read its output:

systemctl list-timers backup-notes.timer
journalctl -u backup-notes.service

systemd-analyze calendar explains a schedule and shows the next run, which is handy while you learn the syntax:

systemd-analyze calendar "Mon..Fri 08:30"

Compared to cron, timers give you logs in the journal without any redirection, catch-up after downtime, and no overlapping runs, because systemd won’t start a service that’s still running. The price is two files instead of one line.

Cron is fine for simple jobs and personal machines. When a job is part of how a server works, like backups or cleanup for an app, I’d go with a timer.

If you’re setting up your own server, my free Ubuntu VPS course and Linux Basics course cover services, logs and scheduled work.

anacron, for machines that aren’t always on

Cron assumes the machine is always running. anacron is built for laptops and desktops that are off at night. Instead of an exact time, it runs daily, weekly and monthly jobs once per period, some time after the machine is on. Ubuntu Desktop installs it, and when it’s installed it takes over running /etc/cron.daily, /etc/cron.weekly and /etc/cron.monthly.

Schedulers in the cloud

If your code doesn’t run on a server you manage, you don’t have a crontab. Platforms give you their own scheduler instead, and most of them use the same five-field syntax:

Two things to know. These schedulers run in UTC, not in your time zone. And they’re not always punctual: GitHub, for example, warns that scheduled workflows can be delayed when its runners are busy.

You can also schedule jobs inside a long-running app with a library. I wrote about running cron jobs in a Node.js app. Those jobs only run while the app is running, and if you run three copies of the app, each job runs three times.

How I use cron

My newsletter goes out from a Sendy server, and Sendy does its work through cron. Its settings page lists the lines to add to the crontab: scheduled.php every 5 minutes to send scheduled campaigns, autoresponders.php and import-csv.php every minute, and update-segments.php every 15 minutes. All of them end with > /dev/null 2>&1, because Sendy tracks its own status in its database.

When I moved that server to a new machine, I had Codex do the migration. It disabled the cron jobs on the new server until the switch, because two live servers would have sent every scheduled email twice. I hadn’t thought of that. The cutover went in this order: stop cron on the old server, do a final database sync, enable cron on the new server, point DNS to the new IP. Since then, listing the scheduled jobs is the first thing I do before touching a server.

The scheduled posts on this site go live through a cron too. A small Cloudflare Worker triggers a rebuild of the site at 9:15, 12:15, 15:15 and 17:15 Rome time. I described the first version in how I rebuild the site on a schedule. Cloudflare Cron Triggers only speak UTC, and Rome is UTC+2 in summer and UTC+1 in winter. So the Worker has eight crons, a summer one and a winter one for each slot:

"crons": [
  "15 7 * * *", // 09:15 CEST
  "15 8 * * *", // 09:15 CET
  "15 10 * * *", // 12:15 CEST
  "15 11 * * *", // 12:15 CET
  "15 13 * * *", // 15:15 CEST
  "15 14 * * *", // 15:15 CET
  "15 15 * * *", // 17:15 CEST
  "15 16 * * *" // 17:15 CET
]

Both crons of a pair fire every day. The Worker checks the current time in Rome and only triggers the rebuild when it’s really 9:15, 12:15, 15:15 or 17:15. This way nothing changes when daylight saving time starts or ends.

My Cloudflare apps use Cron Triggers for cleanup work. Sitebase runs uptime checks and a nightly cleanup, and waitinglists.dev runs a daily job that removes expired data.

On my Mac I once skipped cron on purpose. I wanted to back up a SQLite database once a day for a couple of weeks, and a crontab with a shell script is exactly the kind of thing I set up and then forget. So I built an Automator app and had a repeating Calendar alert open it. Since macOS 26 Tahoe, Shortcuts can do the same with a Time of Day automation.

When a job doesn’t run

When a cron job doesn’t do what you expect, go through this list in order:

  1. Is the job installed? Run crontab -l as the right user. Root’s crontab and yours are different.
  2. Is the schedule right? Paste it in a cron expression builder and look at the next run times. Check for * in the minute field and for both day fields being set.
  3. Is it the right time? Check the machine’s time zone with date.
  4. Did cron start it? Look for the CMD line with journalctl -u cron on Linux.
  5. Did the command fail? Redirect its output to a log file with >> file 2>&1 and read it.
  6. Is it the environment? Run the command with env -i as shown above. Look for commands that aren’t in /usr/bin or /bin, relative paths, and % signs you didn’t escape.
  7. On a Mac, is it permissions or sleep? “Operation not permitted” means Full Disk Access. A job that never ran at night means the Mac was asleep.
  8. For files in /etc/cron.d or /etc/cron.daily, does the name have a dot? Is the file owned by root?

Steps 5 and 6 catch most problems, so if you’re in a hurry, start there.

Tagged: CLI · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about cli: