Phase 1 · FOUNDATIONS
Cron & scheduled jobs — crontab, systemd timers
By the end of today
- Read any five-field cron line and write your own with crontab -e
- Log a cron job's output so failures are never silent
- Schedule the same work with a systemd .timer and .service
Cron and systemd timers: work that runs on a clock
Plenty of operations work is not something you do once — it is something that must happen again and again on a schedule: rotate logs at midnight, back up a database every hour, renew a TLS certificate before it expires, clear a cache every Sunday. Doing it by hand does not scale and does not repeat reliably — a human forgets, sleeps, or goes on leave. A scheduler is the part of the system that runs a command on a timetable, whether or not anyone is logged in.
cron is the classic Unix scheduler, and it is still on nearly every Linux box. Each user has a crontab — a table of jobs — that you edit with crontab -e and print with crontab -l. Every non-comment line is one job: five time fields, then the command to run.
┌───────── minute (0–59)
│ ┌─────── hour (0–23)
│ │ ┌───── day of month (1–31)
│ │ │ ┌─── month (1–12)
│ │ │ │ ┌─ day of week (0–6, Sun = 0)
│ │ │ │ │
0 2 * * * /usr/local/bin/backup.sh → runs at 02:00 every day
Read the fields left to right: minute, hour, day-of-month, month, day-of-week. A * means “every”. So 0 2 * * * is 2 a.m. daily, */15 * * * * is every fifteen minutes, and 0 9 * * 1 is 09:00 every Monday. The command runs in a bare shell with a minimal PATH and none of your interactive setup — the single biggest source of “works in my terminal, silent in cron” surprises.
That bare environment is also why logging cron output matters. cron captures whatever the command prints and, by default, tries to mail it to you; on a server with no mail configured, that output simply vanishes. Every professional redirects it to a file instead — append >> /var/log/backup.log 2>&1 so normal output and errors land somewhere you can read tomorrow.
Real world: cron is the timer-switch on an office’s lights. Someone sets “on at 07:00, off at 21:00” once, and the lights obey the clock forever, whether or not the caretaker is in the building. It never asks why — it just fires at the set time.
The modern equivalent: systemd timers
On any systemd machine — every current Ubuntu, Debian, Fedora and RHEL — the modern way to schedule work is a systemd timer. Instead of one cron line you write two small units: a .service that says what to run, and a .timer that says when. You list every active timer with systemctl list-timers.
Why bother, when cron still works? A timer plugs into the rest of systemd. Its output goes straight to the journal, so you read it with journalctl -u backup.service — the same tool from Day 10, no manual redirect. Persistent=true runs a job that was missed while the machine was off. You also get dependencies, resource limits, and per-run success or failure tracking, exactly like any other service. cron gives you none of that.
A named example makes it concrete. Certbot, the tool that renews Let’s Encrypt TLS certificates for a huge share of the web, ships a certbot.timer on Ubuntu — not a cron job. Twice a day the timer wakes certbot.service, which renews any certificate near expiry. Millions of HTTPS sites stay valid because of a systemd timer nobody ever thinks about.
You will still meet cron on older hosts and inside containers, so learn to read a crontab line at a glance — but reach for a timer whenever the box runs systemd.
Hands-On Lab
Budget about 20 minutes. Open your WSL2 Ubuntu 24.04 terminal (if you have not set up WSL2 yet, do Day 0 first). The systemd steps need systemd running as PID 1 — modern WSL2 enables it by default; if a systemctl command says systemd is not booted, see Day 10. Type each command yourself and read every line of output.
# 1. Look at your crontab. A fresh user has none — that is a normal answer, not an error.
crontab -l
# Output:
# no crontab for pushkar
# 2. Install a job WITHOUT the editor: pipe one line into crontab. This is exactly what
# `crontab -e` would save. It appends the date to a log every minute.
echo '* * * * * date >> /home/pushkar/cron.log 2>&1' | crontab -
crontab -l
# Output:
# * * * * * date >> /home/pushkar/cron.log 2>&1
# 3. Make sure the cron daemon is actually running — on WSL2 and in minimal containers it
# often is not, and an inactive daemon reads no crontab (see Common Error 2). Give it a
# minute or two, then read the log the job has been writing.
sudo systemctl enable --now cron
systemctl is-active cron
cat /home/pushkar/cron.log
# Output (is-active prints "active", then two lines as two minutes passed —
# your timestamps will differ):
# active
# Sat Jul 11 12:01:01 UTC 2026
# Sat Jul 11 12:02:01 UTC 2026
# 4. Clean up: remove the crontab so it stops firing, then confirm it is gone.
crontab -r
crontab -l
# Output:
# no crontab for pushkar
# 5. Now the modern equivalent. Write the .service unit — it says WHAT to run.
sudo tee /etc/systemd/system/hello-backup.service > /dev/null <<'EOF'
[Unit]
Description=Demo backup job (Mission 90 Day 19)
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo "backup complete at $(date -Is)"'
EOF
echo "service unit written"
# Output:
# service unit written
# 6. Write the .timer unit — it says WHEN to run. OnCalendar=*:0/10 = every 10 minutes;
# Persistent=true re-runs a missed job the next time the machine is up.
sudo tee /etc/systemd/system/hello-backup.timer > /dev/null <<'EOF'
[Unit]
Description=Run the demo backup every 10 minutes
[Timer]
OnCalendar=*:0/10
Persistent=true
[Install]
WantedBy=timers.target
EOF
echo "timer unit written"
# Output:
# timer unit written
# 7. Reload systemd so it reads the new units, then enable + start the TIMER (not the service).
sudo systemctl daemon-reload
sudo systemctl enable --now hello-backup.timer
# Output:
# Created symlink '/etc/systemd/system/timers.target.wants/hello-backup.timer' → '/etc/systemd/system/hello-backup.timer'.
# 8. Confirm the timer is scheduled. NEXT is the next fire time; LAST is empty until it runs.
systemctl list-timers hello-backup.timer --no-pager
# Output (your NEXT/LEFT values differ):
# NEXT LEFT LAST PASSED UNIT ACTIVATES
# Sat 2026-07-11 12:10:00 UTC 3min left - - hello-backup.timer hello-backup.service
#
# 1 timers listed.
# 9. Trigger the SERVICE by hand to see the output — no waiting for the schedule.
sudo systemctl start hello-backup.service
journalctl -u hello-backup.service -n 4 --no-pager
# Output (hostname trimmed; your PID and timestamp differ):
# Jul 11 12:06:14 systemd[1]: Starting hello-backup.service - Demo backup job (Mission 90 Day 19)...
# Jul 11 12:06:14 sh[1543]: backup complete at 2026-07-11T12:06:14+00:00
# Jul 11 12:06:14 systemd[1]: hello-backup.service: Deactivated successfully.
# Jul 11 12:06:14 systemd[1]: Finished hello-backup.service - Demo backup job (Mission 90 Day 19).
# 10. Clean up: stop and disable the timer, delete both units, reload.
sudo systemctl disable --now hello-backup.timer
sudo rm /etc/systemd/system/hello-backup.service /etc/systemd/system/hello-backup.timer
sudo systemctl daemon-reload
# Output:
# Removed '/etc/systemd/system/timers.target.wants/hello-backup.timer'.
Read the two halves back: cron scheduled a job with one five-field line and you had to redirect its output yourself, while the systemd timer split what from when into two units and captured every run in the journal for free — same job, two eras of the same idea.
Common Errors & Fixes
These three catch almost everyone the first time they schedule a job. Read the error text slowly — parsing it is the actual skill.
Common error: A backup script that runs perfectly by hand does nothing from cron, and the job’s log holds:
/bin/sh: 1: backup.sh: not foundWhy: cron runs commands in a bare shell with a minimal
PATH(typically just/usr/bin:/bin) and none of your interactive profile. A command that works in your terminal because yourPATHincludes~/binor/usr/local/binis simply not found by cron, so the job fails before it starts.Fix: Use the absolute path to every binary and file —
/home/pushkar/bin/backup.sh, notbackup.sh— or setPATH=...at the top of the crontab. And always append>> /var/log/backup.log 2>&1so the failure is written down instead of mailed into a void.How you’d spot it in prod: “It works when I run it manually but the cron job does nothing” is the classic report. It is nearly always an environment gap — check the job’s own log first, and compare
envin an interactive shell against the near-empty environment cron provides.
Common error: cron is set up correctly but the job never fires, because on a WSL2 box or a minimal container the daemon simply is not running:
$ crontab -l * * * * * /home/pushkar/backup.sh >> /home/pushkar/cron.log 2>&1 $ systemctl is-active cron inactiveWhy: The crontab is just a table; something has to read it and fire the jobs. That something is the
cronservice. If it was never started — common on WSL2 and in slimmed-down images — the schedule is stored but nothing ever acts on it.Fix: Start and enable it:
sudo systemctl enable --now cron. Then re-check withsystemctl is-active cron, which should printactive.How you’d spot it in prod: A perfectly valid crontab whose log file stays empty is the tell. Before debugging the job itself, confirm the scheduler is alive —
systemctl status cronon a systemd host — because a dead daemon looks exactly like a broken job.
Common error: Editing a
.timeror.servicefile directly, then starting it, and seeing:Warning: The unit file, source configuration file or drop-ins of hello-backup.timer changed on disk. Run 'systemctl daemon-reload' to reload units.Why: systemd caches unit files in memory. When you edit a file under
/etc/systemd/system/, the copy on disk and the copy systemd is using no longer match, so systemd warns that it is still running the old definition.Fix: Run
sudo systemctl daemon-reloadafter any edit to a unit file, then start or restart the timer. Reload re-reads the files from disk; it does not restart anything on its own.How you’d spot it in prod: A config change that “did not take” — a new
OnCalendarschedule or an updatedExecStartthat behaves like the old one — usually means someone edited the unit and forgot the reload. Makedaemon-reloada reflex after every unit edit.
Scheduling Interview Questions
Cover the answers below and say your own version out loud first — read a five-field cron line aloud, then explain when you would reach for a systemd timer instead. Recalling before revealing is what makes these stick when an interviewer asks them cold. The four questions and answers render right after this note.
Go Deeper
Optional extras if you have ~20 more minutes today:
- 5 min — Run
man 5 crontaband skim the fields and macros section; note the@daily,@hourlyand@rebootshortcuts that replace common five-field patterns. - 5 min — Paste a cron expression into the Cron Expression Tester to see it explained in plain English plus the next run times — a fast way to sanity-check a schedule before you trust it.
- 5 min — Turn a crontab line into unit files with the Cron to systemd Converter, then compare its
OnCalendaroutput againstsystemd-analyze calendar '*:0/10'in your terminal. - 5 min — Read the “Managing services” section of the Linux for DevOps guide — a systemd timer is just a service that runs on a schedule.
What do the five fields in a cron line mean? Both
Left to right they are minute, hour, day-of-month, month, and day-of-week, followed by the command. A star means 'every' for that field, so 0 2 * * * runs at 2 a.m. every day, and */15 * * * * runs every fifteen minutes. The field I always flag is the fifth: day-of-week counts from Sunday as 0. And there is a genuine trap — if you set both day-of-month and day-of-week to something other than star, cron treats them as OR, not AND, so 0 0 13 * 5 fires on the 13th and on every Friday, not only on Friday the 13th. Reading a crontab line correctly is half the skill.
A script runs fine in your terminal but does nothing under cron — why? Both
Almost always the environment. cron runs your command in a bare shell: a minimal PATH, no .bashrc or .profile loaded, HOME set but little else. So a tool you call by its short name is not found, or a relative path resolves from the wrong directory, and the job dies. The tell is that it works when you run it by hand — because your interactive shell has a rich PATH the cron shell does not. I fix it by using absolute paths for every binary and file, setting any variables the script needs inside the script, and always redirecting output with >> /path/log 2>&1 so the failure gets written down instead of mailed into the void.
When would you choose a systemd timer over cron? Service
On any host running systemd — which is every current Ubuntu, Debian, or RHEL — I default to a timer. You write a .service for what to run and a .timer for when, and you get things cron cannot give you: output goes straight to the journal, so journalctl -u myjob.service shows every run with no manual redirect; Persistent=true catches up a job that was missed while the machine was off; and you get dependencies, resource limits, and proper failure tracking. In a services company that standardises on systemd, timers keep scheduling consistent with how every other service is run and observed. I still reach for cron inside containers or minimal images where systemd is not present.
How do you capture a scheduled job's output so you can debug it later? Product
It depends on the scheduler. With cron, the job's stdout and stderr are by default emailed to the user — and on a server with no mail set up that output just disappears, which is why people think cron 'ran nothing'. So I always redirect explicitly: append >> /var/log/myjob.log 2>&1 to the crontab line, capturing both streams. With a systemd timer there is nothing to wire up — the service's output is captured in the journal automatically, and I read it with journalctl -u myjob.service, narrowing by time with --since. Either way the rule is the same: a scheduled job you cannot see the output of is a job you cannot trust.
Mark Day 19 complete
You've finished the Linux toolkit — tomorrow you drill all of Phase 1 into interview-ready answers.
Stuck on today’s lab? Ask in Mission 90 Q&A