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:
| Field | Meaning | Range |
|---|---|---|
| 1 | minute | 0–59 |
| 2 | hour | 0–23 |
| 3 | day of month | 1–31 |
| 4 | month | 1–12 |
| 5 | day of week | 0–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). Writedate +\%F, notdate +%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 withtimedatectlbefore 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:
| Directive | Fires |
|---|---|
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, ordaemon-reloadnever 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— theExecStart=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:
| Goal | Command / snippet |
|---|---|
| Edit user crontab | crontab -e |
| Show user crontab | crontab -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 timer | systemctl --user enable --now name.timer |
| Calendar | OnCalendar=*:0/5 or daily |
| After boot / repeat | OnBootSec= / OnUnitActiveSec= |
| Catch up missed calendar | Persistent=true |
| What fires next | systemctl --user list-timers |
| Job log | journalctl --user -u name.service |
| Linger user timers | loginctl 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.
- Add a user crontab line that runs
heartbeat.shevery 10 minutes using an absolute path, then prove it withcrontab -l. - Put
PATHat the top of that crontab so the short nameheartbeat.shworks, and escape adate +%Fexample so%does not split the line. - Install the user oneshot service plus a calendar timer every five minutes with
Persistent=true, enable the timer, and showlist-timers. - Replace that timer with
OnStartupSec=1minandOnUnitActiveSec=10min, reload, and say in one sentence whyPersistent=would not help this pair. - 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.