Checked from regions with auto-retry. No single-location false alarms.
All systems operational. Steady pulse across every region.
api.example.com returned no response from all 6 regions. Auto-retry confirmed the outage, then we alerted your team.
api.example.com is back up. The incident is logged to your status page history automatically.
90-day uptime · branded · your domain
Live demo · drive it, no signup needed
When a cron job does not run at its scheduled time, the cause is almost never the schedule. In practice it is the environment: cron runs your command with a minimal PATH, no shell profile, a different working directory and no terminal, so a script that works when you type it fails silently under cron. The other frequent causes are a crontab file with no trailing newline, a missing execute bit, the machine being asleep or the container not running at that moment, and a time zone that is not the one you assumed. Check the cron log first, then run the command with a stripped environment, and only then suspect the expression.
The frustrating part of a cron failure is how little it tells you. A web request that fails returns a status code you can look up. A cron job that fails returns nothing to anybody, because there is nobody there to return it to. The job either ran or it did not, and by the time you are asking the question, the moment has passed.
What follows is the order I actually work through, arranged by how often each cause turns out to be the answer rather than by how interesting it is.
First, find out whether cron tried at all
This single check splits the problem in half. Either cron fired your command and the command failed, or cron never fired at all. Those are completely different investigations and it is worth thirty seconds to know which one you are in.
On most distributions cron logs every invocation through syslog:
grep CRON /var/log/syslog | tail -20
# or, on systemd based systems
journalctl -u cron --since "2 hours ago"
# on RHEL, CentOS, Rocky and Fedora
journalctl -u crond --since "2 hours ago"
If you see a CMD line at the expected time, cron did its job and the schedule is correct. Skip ahead to the environment section, because your problem is in the command. If there is no line at all, cron never ran it, and the next three sections are where the answer lives.
One warning about reading these logs: they record that cron started the command. They say nothing about whether it succeeded. A script that exits with status 1 half a second later produces exactly the same log line as one that works perfectly.
Why does my cron job not run automatically?
Cron never ran it. There are five reasons this happens, and they are quick to rule out in this order.
The crontab file has no trailing newline. This is the classic. Every line in a crontab must end with a newline character, including the last one. If the final line has no newline, most cron implementations ignore that entire line without any error. It has been the answer often enough that it is worth checking before anything clever: open the file with crontab -e, go to the end, press enter, and save.
The cron daemon is not running. Obvious, and easy to skip past. systemctl status cron or systemctl status crond settles it. This is far more common inside containers than on servers, because most base images do not start a cron daemon and many do not include one.
The crontab belongs to a different user. Running crontab -l shows your own crontab. If the job was installed under root or a service account, you will not see it, and if you added it as yourself but expected it to run as root, it will run with your permissions instead. Check sudo crontab -l and sudo crontab -l -u www-data, and remember that /etc/crontab and files in /etc/cron.d use a different format with an extra user column between the schedule and the command.
The machine was not running. Cron does not catch up. If a server is off, suspended or rebooting at the scheduled moment, the run is simply lost and there is no attempt to make it up later. On laptops and spot instances this is the single most likely explanation. On Kubernetes there is a related behaviour: a CronJob that misses more than 100 scheduled times stops scheduling entirely and reports that it cannot determine the last run.
The user is denied cron access. If /etc/cron.allow exists, only users listed in it may use cron, and everyone else is refused. If it does not exist but /etc/cron.deny does, users listed there are refused. This rarely changes on its own, but it does catch people on hardened or managed hosts.
The environment is different, and that is usually the answer
If the log shows cron ran the command and nothing happened, you are in the most common failure of all. Cron does not give your command the environment you are used to. It does not read .bashrc, .bash_profile or .profile. It sets a deliberately minimal PATH, often just /usr/bin:/bin. It starts in the user's home directory regardless of where the script lives. There is no terminal attached.
The practical consequences show up in a specific order.
Commands are not found. Anything installed somewhere other than /usr/bin or /bin will not resolve. That includes almost every version manager: node under nvm, python under pyenv, ruby under rbenv, and anything in /usr/local/bin on some systems. The fix is to use absolute paths for every binary, which you can find with which node from your normal shell.
Relative paths point at the wrong place. A script that opens config/settings.yml works when you run it from the project directory and fails under cron, which starts you in the home directory. Either cd to the project first or make every path absolute.
Environment variables are missing. Database URLs, API keys and anything else exported in a shell profile will not be there. Source the environment file explicitly at the start of the command.
The reliable way to reproduce all of this without waiting for the next scheduled run is to strip your own environment and try:
env -i /bin/sh -c '/usr/local/bin/myscript.sh'
If that fails and your normal shell succeeds, the environment is your problem and you now have a fast feedback loop instead of a fifteen minute one.
The habit that avoids the whole category is to keep the crontab line trivial and put the complexity in the script. Have the crontab call one absolute path with no arguments, and let the script set its own PATH, change to its own directory and source whatever it needs. Then the thing you test by hand is the same thing cron runs.
Cron job not running at scheduled time, but running at a different one
If the job runs, just not when you expected, there are three candidates and they are easy to tell apart.
The time zone is not what you think. Cron uses the system time zone of the machine, which on cloud servers is very often UTC even when the team is not. timedatectl or date tells you what the machine believes. A job written for a quiet 02:00 maintenance window lands at 21:00 or 22:00 local in US time zones when the server runs on UTC, which is the middle of the evening peak rather than the middle of the night. Most cron implementations let you set CRON_TZ=America/New_York at the top of the crontab. Kubernetes has .spec.timeZone, stable since v1.27. AWS EventBridge does not have the option at all: scheduled rules always run on UTC.
Daylight saving skipped or repeated the run. On the spring forward date, the local clock jumps from 02:00 to 03:00, so a job scheduled for 02:30 has no moment to run in and most implementations skip it. On the autumn date, 02:30 happens twice and some implementations run the job twice. Anything doing billing, invoicing or reconciliation should either run in UTC or be safe to run twice, because "it ran twice in November" is a much worse conversation than "it ran an hour late".
The expression does not mean what you read it as. Two specific traps account for most of these. The first is the day of week numbering, which is not consistent: Unix cron, Kubernetes, Spring and Azure treat 0 as Sunday, while Quartz and AWS EventBridge treat 1 as Sunday. A schedule copied from a Linux crontab into a Quartz trigger or an EventBridge rule keeps working and runs one day early, with no error anywhere. The second is that when both the day of month and the day of week fields are restricted, Unix cron runs the job when either one matches, not both, which is stated plainly in the crontab man page and surprises nearly everyone. That is why 0 0 1 * MON is not "the first of the month if it is a Monday" but "the first of the month, and also every Monday". Pasting the expression into a cron expression generator and explainer that reads it back in plain English and shows the next run times settles both questions in a few seconds.
The job runs, fails, and nobody finds out
This is the failure that costs real money, because from the outside it is indistinguishable from success.
Cron's only built in reporting is email. When a job writes anything to standard output or standard error, cron mails that output to the user, using the local mail transfer agent. On a modern server there usually is no local mail transfer agent, so the output goes nowhere. Cron does not warn you about this. The job fails, produces a perfectly good error message, and the message is discarded.
Two things fix the visibility. Redirect the output somewhere you can read, and stop discarding the exit status:
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
Note that 2>&1 has to come after the redirect, not before it, or standard error still goes to the old destination. And be careful with the percent sign: in a crontab, an unescaped % is turned into a newline and everything after the first one is fed to the command as standard input. A date format like date +%Y-%m-%d in a crontab line needs each percent escaped as \%, which is a genuinely obscure way to lose an evening.
Logging helps you investigate afterwards. It does not tell you that something went wrong. For that you need the job to report success and something else to notice the absence of that report. This is what heartbeat monitoring does: the script calls a URL when it finishes, and the monitor raises an alert when the call does not arrive inside the expected window.
0 2 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 https://example.com/heartbeat/abc123
The && matters. It means the heartbeat is only sent when the script exits successfully, so a failed backup does not report itself as a healthy one.
The reason this inversion is worth the ten minutes to set up is that the absence of a signal is invisible by default. A website going down generates complaints within minutes. A nightly export that has silently not run since March generates nothing at all until the day somebody needs the data, and by then you have lost months rather than hours. Some of these jobs also turn out not to need to exist: a nightly script whose entire purpose is emailing customers about overdue invoices is usually better handled by software that chases unpaid invoices on its own schedule and reports on what it collected, rather than by a cron entry nobody has looked at in a year. Cron heartbeat monitoring covers the ones that do need to exist, with a grace period so a job that usually takes four minutes and occasionally takes nine does not page anybody at 2am.
A checklist, in the order that finds it fastest
| Check | Command | What it rules out |
|---|---|---|
| Did cron fire? | grep CRON /var/log/syslog | tail | Splits "cron never ran" from "the command failed" |
| Is the daemon up? | systemctl status cron | Containers and rebuilt images, mostly |
| Right crontab? | crontab -l and sudo crontab -l | The job being installed under another user |
| Trailing newline? | crontab -e, end of file, press enter | The last line being ignored entirely |
| Is the script executable? | ls -l /path/to/script.sh | A missing execute bit |
| Does it run bare? | env -i /bin/sh -c '/abs/path/script.sh' | PATH, working directory and environment variables |
| What time zone? | timedatectl | The server running UTC while you think in local time |
| Does the expression mean what you read? | Paste it into an explainer | Weekday numbering and the OR rule on the two day fields |
| Where does output go? | Add >> /var/log/job.log 2>&1 | Errors being mailed into a void |
Common questions
Why does my script work manually but not in cron? The environment. Cron does not read your shell profile, uses a minimal PATH of roughly /usr/bin:/bin, and starts in the home directory rather than the script's directory. Anything installed by nvm, pyenv, rbenv or Homebrew will not be found, and relative file paths will point somewhere else. Reproduce it with env -i /bin/sh -c '/absolute/path/script.sh'.
How do I know if a cron job ran? Check the system log with grep CRON /var/log/syslog or journalctl -u cron, which records each invocation. That confirms cron started the command but says nothing about whether it succeeded. For that, log the output and exit status yourself, or have the job report in to a heartbeat monitor when it completes.
Does cron run missed jobs after a reboot? No. Standard cron has no catch up behaviour, so a run that was scheduled while the machine was off is simply lost. anacron exists for exactly this reason on machines that are not always on, and Kubernetes offers .spec.startingDeadlineSeconds to allow a late start within a window you define.
Why is my cron job running twice? Three usual causes. The entry exists in two places, such as both a user crontab and /etc/cron.d. The previous run had not finished, since cron happily starts a second copy of a job that overruns its interval, which needs a lock file or an advisory lock to prevent. Or the clock went back an hour for daylight saving and the scheduled time occurred twice.
What is the minimum interval a cron job can run at? One minute in Unix cron and Kubernetes, because neither has a seconds field. Quartz, Spring and Azure NCRONTAB do have one and can go down to one second. On Linux the usual workarounds for sub minute scheduling are a systemd timer with OnUnitActiveSec, or two crontab entries where the second sleeps 30 seconds before running.
Know your site is down before your customers do
Start monitoring your sites, APIs and services from six regions, with alerts by Slack, email, SMS and webhook and a branded status page. Transparent, flat pricing per monitor.