What Is a Cron Job? How Cron Works and What It Won't Do
Updated
Contents
A cron job is a command that the cron daemon runs automatically on a schedule, such as every night at 02:30 or every five minutes. Each job is one line in a crontab: five time fields, then the command. The daemon wakes once a minute, starts every line whose fields match the current time, and mails any output to the crontab's owner. That is nearly the whole contract. Cron doesn't retry a failed run, make up a run it missed while the server was down, stop a run that hangs, or tell anyone when a job stops running.
Linux servers run one of three implementations, and this guide names the one behind each behavior: cronie (Fedora, RHEL and its derivatives, Arch), Debian's cron package (Debian, Ubuntu), and BusyBox crond (Alpine and other BusyBox-based images). Cronie is "based on the original vixie-cron" (cronie README), and Debian's package is Vixie cron 3.0 with Debian's patches (source package), so the two agree on most things. BusyBox's is a separate, smaller program, a port of dcron (crond.c). A history of cron covers where Vixie cron and its descendants came from.
A Cron Job, Line by Line#
# min hour day-of-month month day-of-week command
30 2 * * * /opt/app/backup.sh >> /var/log/app/backup.log 2>&130 2 * * *is the schedule: minute 30, hour 2, any day of the month, any month, any day of the week. Cron reads it in the server's local time zone. Cron expression syntax covers every field and operator./opt/app/backup.shis the command. Everything after the fifth field is handed to the shell as one string.>> /var/log/app/backup.log 2>&1appends the job's output and errors to a file. Without it, the output is mailed, as described below.
Three words get used interchangeably. Cron is the daemon, named cron on Debian and Ubuntu and crond on most other systems. A crontab (cron table) is a file of job lines, and crontab is also the command that installs per-user crontabs: crontab -e edits yours and crontab -l prints it. A cron job is one line. The crontab guide covers the command and the files in full.
How the Cron Daemon Works#
Where It Finds Jobs#
Cronie and Debian's cron read three places:
- User crontabs in a spool directory, one file per account, named after the user and installed with
crontab. The paths are in the distribution table below. Don't edit these files in place. /etc/crontab, the system crontab. Its lines carry an extra user field between the schedule and the command:30 2 * * * appuser /opt/app/backup.sh./etc/cron.d/, drop-in files in the same system format, where packages and configuration tools put their jobs. Debian's cron ignores files there whose names contain anything other than letters, digits, underscores, and hyphens, so a file namedbackup.cronnever runs (Debian cron(8)).
The daemon doesn't read /etc/cron.hourly, cron.daily, cron.weekly, or cron.monthly directly. On Debian, lines in /etc/crontab run them with run-parts, leaving the daily, weekly, and monthly ones to anacron when it is installed. On cronie, /etc/crontab is empty by default; /etc/cron.d/0hourly runs cron.hourly, and a script in that directory starts anacron, which runs the other three (cronie cron(8), Fedora's cronie package).
BusyBox crond reads only its crontab directory: no /etc/crontab and no /etc/cron.d (crond.c; unchanged in this respect through BusyBox 1.38.0). Alpine starts it with -c /etc/crontabs, and its default root crontab runs run-parts on /etc/periodic/15min, hourly, daily, weekly, and monthly (crond options, default crontab).
None of them needs a restart after an edit. Cronie watches its files with inotify and falls back to checking modification times every minute. Debian's cron checks the spool directory's modification time every minute. BusyBox rereads its directory when it changes, and rescans it hourly regardless.
What Happens Each Minute#
Every minute, the daemon compares each loaded line with the current local time. (Cronie also accepts a CRON_TZ variable that gives one crontab its own time zone.) For each line that matches, it:
- Starts a separate process as the crontab's owner, or as the user named in a system crontab line. It doesn't wait for one job to finish before starting the next.
- Runs the command with
/bin/shon cronie and Debian, not your login shell, unless the crontab setsSHELL(crontab(5)). - Gives it a minimal environment.
HOMEandLOGNAMEcome from/etc/passwd.PATHdefaults to/usr/bin:/binon Debian's cron (pathnames.h) and/usr/bin:/bin:/usr/sbin:/sbinon cronie (entry.c). Nothing from.bashrcor.profileis loaded. You can set variables withNAME=valuelines above the jobs, but BusyBox crond honors onlyMAILTO,SHELL, andPATHlines and skips any other. - Mails the output, if there is any, to the crontab's owner or to the
MAILTOaddress. No output means no mail, whatever the exit code (cronie do_command.c).
Mail needs a working mail transfer agent on the host. Without one, cronie sends the output to syslog instead (cron(8)), Debian's cron logs No MTA installed, discarding output and drops it (do_command.c), and BusyBox mails only when the crontab sets MAILTO. Each start is also written to the system log; how to know if a cron job ran shows where to read it and what it can't prove.
Which Cron Your Distribution Runs#
Checked October 24, 2026, from each distribution's package sources:
| Distribution | Implementation | Service | User crontabs | Reads /etc/crontab and /etc/cron.d |
|---|---|---|---|---|
| Debian 13 | Debian cron 3.0pl1-197 |
cron |
/var/spool/cron/crontabs/ |
Yes |
| Ubuntu 26.04 LTS | Debian's cron, 3.0pl1-200ubuntu1 |
cron |
/var/spool/cron/crontabs/ |
Yes |
| Fedora 43 and 44 | cronie 1.7.2 | crond |
/var/spool/cron/ |
Yes |
| RHEL 9 and 10 | cronie 1.5.7 and 1.7.0 (CentOS Stream 9 and 10) | crond |
/var/spool/cron/ |
Yes |
| Arch Linux | None installed by default; cronie is packaged | cronie, not enabled on install |
/var/spool/cron/ |
Yes |
| Alpine | BusyBox crond |
crond (OpenRC) |
/etc/crontabs/ |
No |
Sources: Debian, Ubuntu, Fedora, CentOS Stream, and the Arch wiki, which notes that Arch's base system uses systemd timers instead of cron. If a host has no cron daemon running, its scheduled work may live in systemd timers too. Cron implementations compared shows how to check which cron a host runs and where cronie, Debian's cron, BusyBox crond, fcron, and supercronic differ.
What Cron Won't Do#
Cron's contract fits in one sentence: at this minute, start this command. Each gap below is something people assume cron handles, with what fills it.
No Retries#
A run that exits non-zero has failed, and cron starts the command again only at its next scheduled time. Retries belong inside the job, with a cap and a delay between attempts, or in a scheduler that has them.
No Catch-Up After Downtime#
When the daemon starts, after a reboot, a crash, or a container restart, it begins at the current minute: cronie and Debian's cron set their internal clock to the present on startup (cronie cron.c). Red Hat's documentation puts it plainly: "A cron job is only executed if the system is running on the scheduled time" (RHEL 7 guide). A 02:30 backup on a server that was rebooting at 02:30 doesn't happen that night. The only look-back is for clock changes: cronie and Debian run skipped fixed-time jobs after a forward jump of under three hours, such as the start of daylight saving time, and BusyBox does the same for jumps under an hour.
Two tools exist for catch-up. Anacron, included with cronie and packaged separately on Debian, runs jobs whose period is measured in days and keeps a timestamp per job, so a daily job missed overnight runs once the machine is back (anacron(8)). A systemd timer with Persistent=true runs a missed OnCalendar= activation as soon as the timer starts again (systemd.timer(5), systemd 212 and later).
No Overlap Prevention#
Cronie and Debian's cron start a new copy at every matching minute, whether or not the last one has finished. Their job queue only stops the same line from being queued twice within one minute (cronie job.c). A five-minute job that takes seven minutes ends up with two copies running against the same files and tables. BusyBox crond is the exception: it won't start a line while that line's previous run is still going, and it doesn't make up the skipped run later.
The fix is a lock. flock -n /var/lib/app/sync.lock /opt/app/sync.sh runs the command only if it can take the lock immediately, and otherwise exits with status 1 without running it (flock(1), util-linux).
No Timeouts#
A job that hangs on a network call or a database lock runs until something kills it; cron has no maximum-runtime setting. On cronie and Debian, the hung copy keeps running while new ones start beside it. On BusyBox, it blocks every later run of that line. Wrap the command: timeout 30m /opt/app/backup.sh sends SIGTERM after 30 minutes, and GNU coreutils' timeout exits with status 124 when it does (timeout(1)).
Alerts Only by Mail, Only for Output#
The output mail is cron's only notification. A job that fails without printing anything sends nothing. A job that prints on every success buries the one mail that matters. A job that never started has no output, so it sends no mail either.
No Dependencies Between Jobs#
Each line is independent. "Run the report after the import finishes" can't be written as two entries; scheduling the report an hour after the import is a guess that breaks the day the import is slow. Chain dependent steps in one entry or script, such as /opt/app/import.sh && /opt/app/report.sh, or move them to a tool that models dependencies.
Nothing Finer Than a Minute#
Cron examines its entries once a minute, and its schedule has no seconds field (crontab(5)). The FAQ below covers the workarounds.
No Coordination Across Servers#
Each daemon reads only its own machine's files. Copy a crontab to three app servers and the job runs three times; keep it on one and the job stops when that server does. Cronie has an opt-in clustering mode: with crond -c, hosts share a network-mounted /var/spool/cron, and only the host named in /var/spool/cron/.cron.hostname runs those crontabs (cron(8)). It picks a host by name. If that host goes down, nothing moves its jobs elsewhere, and /etc/crontab and /etc/cron.d still run on every host.
Cron-Like Schedulers and What They Add#
Each of these fills some of cron's gaps and leaves others:
| Scheduler | Adds over cron | Still needs care |
|---|---|---|
| systemd timers | Persistent= catch-up; a service that is still active isn't started again; TimeoutStartSec= limits a oneshot service's run, which is unlimited by default (systemd.service(5)); OnFailure= starts another unit on failure (systemd.unit(5)) |
One host, like cron; the OnFailure= unit that notifies someone is yours to write |
| Kubernetes CronJob | concurrencyPolicy, startingDeadlineSeconds, Job retries (backoffLimit) and run limits (activeDeadlineSeconds) |
Creates a Job "approximately once" per scheduled time, so occasionally two or none; see Kubernetes CronJob monitoring |
GitHub Actions schedule |
Hosted runners; UTC by default or an IANA timezone |
At most every 5 minutes; runs can be delayed or dropped under load; disabled after 60 days without activity in public repositories (checked October 24, 2026) |
| Cloud schedulers | AWS EventBridge Scheduler: a retry policy and an SQS dead-letter queue for failed invocations; Google Cloud Scheduler: retries with backoff | Cloud Scheduler retries are off by default (retryCount 0, at most 5; checked October 24, 2026); a retry repeats the call, so make the target safe to call twice |
| In-app schedulers | Hangfire retries a failed job 10 times by default (docs); Laravel offers withoutOverlapping() and onOneServer() (docs) |
Run only while the app's scheduler process does; Celery needs exactly one beat scheduler per schedule, or tasks run twice (docs); Laravel's scheduler is itself a cron entry running every minute |
A timer's AccuracySec= defaults to one minute, so systemd timers aren't more precise than cron until you lower it (systemd.timer(5)).
Why "It Silently Stopped Running" Is the Classic Cron Failure#
Every gap above fails the same way: quietly. Cron reports only by mailing output from a job that ran, so the failures that matter most produce nothing:
- The daemon stopped, or a container restarted without it.
- A deploy or configuration-management run overwrote the crontab.
- The server was replaced, and the new one never got the entry.
- The host was down or rebooting at the scheduled minute.
- The job hangs, and on BusyBox every later run waits behind it.
- The job fails without printing, or its mail goes to a mailbox nobody reads.
Each one leaves an absence: no output, no mail, often no log line. The longer the interval, the longer the absence lasts; a monthly job can be broken for a month before anyone needs what it produces. Why a cron job isn't running helps once you know something is wrong. The hard part is knowing.
A heartbeat inverts the check. The job reports to an external monitor each time it runs, the monitor knows the schedule, and when an expected report doesn't arrive within a grace period, it alerts. Because the alert comes from a missing signal and the monitor runs somewhere else, it fires even when the server itself is gone. Dead man's switch monitoring explains the pattern in full.
One Wrapper for the Gaps#
This CronRadar check-in for a nightly backup also closes the timeout and overlap gaps. There is no CronRadar SDK for crontab and no auto-discovery of crontab entries, so each cron job reports with its own HTTP check-in:
#!/bin/sh
# /opt/app/nightly-backup-monitored.sh
# The key lives in a file only the job's account can read: cronie and Debian's cron
# copy crontab variables into the headers of the mail they send.
CRONRADAR_API_KEY=$(cat /etc/cronradar/api-key 2>/dev/null)
url="https://cron.life/api/v1/ping/nightly-backup"
run_id="nightly-backup-$(date +%s)-$$"
report() {
event=$1
shift
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=30 2 * * *" \
--data-urlencode "gracePeriod=900" \
--data-urlencode "run_id=$run_id" \
"$@" "$url/$event" || true
}
report start
timeout -k 1m 30m /opt/app/backup.sh
rc=$?
if [ "$rc" -eq 0 ]; then
report complete
else
report fail --data-urlencode "message=backup.sh exited with code $rc"
fi
exit "$rc"Make it executable and schedule it behind a lock:
30 2 * * * flock -n /var/lib/app/nightly-backup.lock /opt/app/nightly-backup-monitored.shWhat each piece covers:
- Missed runs. If no check-in arrives by 02:30, the monitor goes to
warning, and tocriticalwhen the 15-minute grace period ends. That includes the night the server is off. - Failures. A non-zero exit sends
fail, which alerts immediately, with the exit code in the message. - Hangs.
timeoutstops the job after 30 minutes, and-k 1mfollows with SIGKILL a minute later if the job ignores SIGTERM. Either way the wrapper sees a non-zero status (124 when SIGTERM ends the job), which reaches CronRadar as afailthe same night. Without the timeout, astartwith no finish is flagged as stuck only when the next scheduled time plus the grace period passes with thatstartstill the last event, which for a nightly job is the next night; if the next run'sstartarrives first, it resets that window. CronRadar has no per-job maximum runtime, so the limit belongs in the job. - Overlap.
flock -nmakes any second copy started through the same lock file exit at once instead of running beside the first, so start manual runs the same way. The lock file's directory must be writable by the job's user. - Monitoring outages.
--max-time 5and|| truekeep CronRadar out of the job's result: an unreachable monitor can't fail or stall the backup, and the wrapper exits with the job's own code.
Because every check-in carries the schedule, the first run creates the monitor, and a deleted monitor is recreated by the next run. CronRadar reads schedule in UTC, so this assumes the server clock is on UTC; otherwise, send the UTC equivalent of the crontab time. BusyBox crond skips the CRONRADAR_API_KEY= line, so on Alpine set the key inside the wrapper instead, for example by sourcing a file that only the job's user can read. Alerts go to email, Slack, a webhook, Discord, or PagerDuty.
Frequently Asked Questions#
What is the difference between cron and crontab?#
Cron is the daemon that runs scheduled commands: cron on Debian and Ubuntu, crond on cronie and BusyBox systems. A crontab is a file of schedule-and-command lines that the daemon reads, and crontab is also the command that installs and edits per-user crontabs. A cron job is a single line in a crontab.
Does cron run missed jobs?#
No. If the machine is off or the daemon isn't running at the scheduled minute, that run is skipped for good, and cron carries on from the current minute. Use anacron for daily, weekly, or monthly jobs on machines that aren't always on, or a systemd timer with Persistent=true. A heartbeat monitor won't rerun the job, but it tells you the run was missed.
Can cron run a job every second?#
No. Cron's smallest unit is one minute. One workaround pairs * * * * * /opt/app/poll.sh with a second line, * * * * * sleep 30; /opt/app/poll.sh, for a 30-second rhythm that keeps all of cron's other gaps. For real sub-minute work, run a long-lived worker with its own loop, or use a systemd timer, whose calendar expressions accept seconds (systemd.time(7)) once you lower AccuracySec=.
What user does a cron job run as?#
The owner of the crontab it's in. A job in your crontab runs as you, one added with sudo crontab -e runs as root, and a line in /etc/crontab or /etc/cron.d/ runs as the user named in its sixth field. It gets that user's HOME but not their login environment, so set PATH and any other variables the job needs in the crontab or the script.
Where are cron jobs stored?#
Per-user crontabs live in the daemon's spool directory: /var/spool/cron/crontabs/ on Debian and Ubuntu, /var/spool/cron/ on cronie systems such as Fedora and RHEL, and /etc/crontabs/ on Alpine. System jobs live in /etc/crontab and /etc/cron.d/ on cronie and Debian systems. Use crontab -l and crontab -e to read and edit your own instead of opening the spool file directly.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.