A backup you scheduled last month never ran. A cleanup job ran twice and ate yesterday’s files. Both look like “the scheduler is broken.” They usually are not. The gap is between writing a line of schedule and proving the next run, the last run, and the job’s own log.

You do not need every calendar expression on day one. Start with a tiny script you own, schedule it from your user account, then learn the two Linux schedulers you will actually meet: crontab (five fields and @reboot / @daily) and systemd timers (OnCalendar= vs boot/monotonic delays). The sections below walk through the commands you will reach for most often — with enough context that each one feels intentional, not magical.

Note: Do not drop timers or /etc/cron.d files as root on a production host without intent. Practice with your user crontab and systemctl --user. Enabling, starting, and inspecting units in general is covered in systemctl; this post only enables the timer so the schedule exists.

Warm-up: a script you can schedule

Give yourself a job that is safe to fire over and over: append one timestamped line to a log. Every later example points at this script.

mkdir -p "$HOME/bin" "$HOME/lab-cron"
cat > "$HOME/bin/heartbeat.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
stamp=$(date --iso-8601=seconds)
echo "$stamp heartbeat pid=$$" >> "$HOME/lab-cron/heartbeat.log"
EOF
chmod +x "$HOME/bin/heartbeat.sh"
"$HOME/bin/heartbeat.sh"
cat "$HOME/lab-cron/heartbeat.log"

Typical first line (your home path and clock will differ):

2026-09-08T01:55:12+05:30 heartbeat pid=18421

If that file grows when you run the script by hand, the job itself works. Failures after this point are almost always PATH, timezone, or “the scheduler never loaded the unit.”

Crontab: five fields, then special nicknames

crontab -e opens your crontab. There is no username column. Five fields, then the command:

FieldMeaningRange
1minute0–59
2hour0–23
3day of month1–31
4month1–12
5day of week0–7 (0 and 7 are Sunday)

A job every five minutes with an absolute path (copy-paste into crontab -e, then save):

*/5 * * * * /home/alice/bin/heartbeat.sh

List what cron actually stored:

crontab -l
*/5 * * * * /home/alice/bin/heartbeat.sh

Nicknames skip the five fields when wall-clock precision does not matter:

@reboot /home/alice/bin/heartbeat.sh
@daily  /home/alice/bin/heartbeat.sh

@hourly, @weekly, @monthly, and @yearly exist too. @reboot fires once after cron itself starts — useful for a user job you want after login/boot, not for “every five minutes.”

System drop-ins live in /etc/cron.d. Those files add a sixth field: the user who should run the command.

*/5 * * * * alice /home/alice/bin/heartbeat.sh

Leave /etc/cron.d for machines you administer on purpose. The extra username is the usual copy-paste bug if you drop a user crontab into that directory, or the reverse.

Note: Cron’s environment is thinner than your interactive shell. A command that works in the terminal can fail in crontab because PATH is often just /usr/bin:/bin. heartbeat.sh is not on that path unless you spell the full file name, as above. If you prefer short names, set PATH at the top of the crontab:

PATH=/home/alice/bin:/usr/local/bin:/usr/bin:/bin
*/5 * * * * heartbeat.sh

Two other classic misfires:

  • Unescaped % in a crontab command becomes a newline (the rest of the line is fed to the job as stdin). Write date +\%F, not date +%F.
  • Cron uses the host’s local timezone (some implementations also honor CRON_TZ=). If you expected 03:00 IST and the box is UTC, the job is not “late” — it is on a different clock. Check with timedatectl before rewriting the schedule.

systemd timer plus a oneshot service

A systemd schedule is a pair: a .timer that decides when, and a .service that decides what. For a user lab, drop both under ~/.config/systemd/user/ so you never need root.

mkdir -p "$HOME/.config/systemd/user"

The companion service is a oneshot that runs the same script. Keep it that small — this is not a production unit from scratch:

cat > "$HOME/.config/systemd/user/heartbeat.service" <<EOF
[Unit]
Description=Append a heartbeat line

[Service]
Type=oneshot
ExecStart=$HOME/bin/heartbeat.sh
EOF

A calendar timer that matches the crontab (*:0/5 is “every five minutes”):

cat > "$HOME/.config/systemd/user/heartbeat.timer" <<'EOF'
[Unit]
Description=Run heartbeat every five minutes

[Timer]
OnCalendar=*:0/5
Persistent=true

[Install]
WantedBy=timers.target
EOF

Reload the user manager, then enable the timer so it survives the next login (the systemctl guide owns start/stop/enable in general — here the unit you enable is the timer, not the service):

systemctl --user daemon-reload
systemctl --user enable --now heartbeat.timer

Confirm the pair is loaded:

systemctl --user list-timers heartbeat.timer --no-pager

Typical shape (NEXT / LAST are host-specific):

NEXT                        LEFT     LAST                        PASSED  UNIT            ACTIVATES
Mon 2026-09-08 02:00:00 IST 4min     Mon 2026-09-08 01:55:00 IST 12s ago heartbeat.timer heartbeat.service

Persistent=true catches up a missed calendar firing after the machine (or the user manager) was down. It does nothing for boot/monotonic timers. If list-timers shows n/a under LAST, the job has never run under this timer yet.

Calendar vs boot and monotonic delays

OnCalendar= is the cron-shaped choice: wall-clock events. systemd understands English-like stamps as well as the five-minute form above:

OnCalendar=hourly
OnCalendar=daily
OnCalendar=*-*-* 03:00:00
OnCalendar=Mon *-*-* 09:00:00

Those times follow the host timezone unless you pin one (OnCalendar=*-*-* 03:00:00 Asia/Kolkata). Same trap as cron: the timer is exact; your mental timezone may not be.

Boot and monotonic settings do not use the calendar. They count from an event:

DirectiveFires
OnBootSec=once, that long after boot
OnStartupSec=once, that long after this manager started
OnUnitActiveSec=repeatedly, that long after the service last activated

A “five minutes after this user systemd starts, then every ten minutes” timer:

cat > "$HOME/.config/systemd/user/heartbeat.timer" <<'EOF'
[Unit]
Description=Heartbeat after startup, then every ten minutes

[Timer]
OnStartupSec=5min
OnUnitActiveSec=10min

[Install]
WantedBy=timers.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now heartbeat.timer

Use calendar when humans care about “03:00 every day.” Use OnBootSec= / OnStartupSec= / OnUnitActiveSec= when you care about “after this machine (or session) is up,” not about wall-clock alignment. Mixing OnCalendar= with monotonic delays on the same timer is legal but harder to reason about — pick one story per unit.

systemctl list-timers (add --user in this lab; omit it for system timers) is the at-a-glance proof. --all includes timers that are loaded but idle.

systemctl --user list-timers --all --no-pager

Finding the run that never fired

When the log file is unchanged, do not start by rewriting the calendar. Ask three questions: is the timer active, when was LAST, and did the service log anything?

systemctl --user list-timers heartbeat.timer --no-pager
journalctl --user -u heartbeat.service --no-pager

The journal command is the log path for this pair. Filter syntax, boots, and priorities live in the journalctl guide — do not pile flags on until list-timers and a plain -u dump have spoken.

How to read the miss:

  • Timer missing from list-timers — it was never enabled, or daemon-reload never ran after you wrote the files.
  • LAST is n/a — the schedule has not fired yet (too new, or the calendar is in the future in this timezone).
  • LAST is recent, journal is empty or status=203/EXEC — the ExecStart= path is wrong, or the script is not executable.
  • LAST is recent, journal shows the script, log file still empty — the process ran as a different $HOME / user than you are reading.
  • User timer dies after logout — the user systemd instance went away. User timers are not system timers: they run while that user manager is up. To keep them after logout, linger the account (loginctl enable-linger "$USER") or move the pair to a system unit you actually intend to run as root.

Cron has no list-timers. Proof is the crontab line (crontab -l), the host clock, and whatever your script wrote. Mail from cron (or /var/mail) often holds the PATH and % failures that never reached heartbeat.log.

If a job ran twice, look for two schedulers on the same script (user crontab and a timer), or a calendar timer plus a monotonic one both pointing at the same service. list-timers plus crontab -l in the same breath usually names the duplicate.

Quick reference card

Keep this nearby until the split becomes muscle memory:

GoalCommand / snippet
Edit user crontabcrontab -e
Show user crontabcrontab -l
Five-field every 5 min*/5 * * * * /full/path/job
Boot / daily nicknames@reboot /full/path/job / @daily …
System crontab/etc/cron.d/… (extra user field)
User unit dir~/.config/systemd/user/
Enable user timersystemctl --user enable --now name.timer
CalendarOnCalendar=*:0/5 or daily
After boot / repeatOnBootSec= / OnUnitActiveSec=
Catch up missed calendarPersistent=true
What fires nextsystemctl --user list-timers
Job logjournalctl --user -u name.service
Linger user timersloginctl enable-linger "$USER"

Practice drills

Use the heartbeat script (or your own) and try these without peeking. The point is to choose crontab vs timer, calendar vs monotonic, and a proof command — not to memorize every OnCalendar= dialect under pressure.

  1. Add a user crontab line that runs heartbeat.sh every 10 minutes using an absolute path, then prove it with crontab -l.
  2. Put PATH at the top of that crontab so the short name heartbeat.sh works, and escape a date +%F example so % does not split the line.
  3. Install the user oneshot service plus a calendar timer every five minutes with Persistent=true, enable the timer, and show list-timers.
  4. Replace that timer with OnStartupSec=1min and OnUnitActiveSec=10min, reload, and say in one sentence why Persistent= would not help this pair.
  5. The log did not grow. Write the two commands that distinguish “timer never fired” from “service ran and failed.”

When you are ready to compare, here are solid answers — not the only ones, but clear and portable (swap alice for your user and home):

# 1
crontab -e   # */10 * * * * /home/alice/bin/heartbeat.sh
crontab -l

# 2
# PATH=/home/alice/bin:/usr/bin:/bin
# */10 * * * * heartbeat.sh
# 0 9 * * * echo "ok $(date +\%F)" >> /home/alice/lab-cron/heartbeat.log

# 3
systemctl --user daemon-reload
systemctl --user enable --now heartbeat.timer
systemctl --user list-timers heartbeat.timer --no-pager

# 4
# Persistent= only catches up OnCalendar= misses, not OnStartupSec=/OnUnitActiveSec=

# 5
systemctl --user list-timers heartbeat.timer --no-pager
journalctl --user -u heartbeat.service --no-pager

If you can work through those five comfortably, you already cover most real schedule work: a user crontab with an honest PATH, a timer/service pair you can enable, calendar vs boot delays, and a proof path when the job never shows up. Start with the script by hand, then add one scheduler — crontab or a timer — and read list-timers plus the service journal before you add a second.