DevKitLab Logo DevKitLab
Cron / crontab / Debugging / DevOps

Why Didn't My Cron Job Run? A Field Guide to Silent Cron Failures

"It didn't run" is three failures in one phrase — it never fired, it died on its first line, or it ran fine somewhere you can't see. This guide tells them apart, starting at the cron log.

The schedule looks right. You read it back field by field, maybe pasted it into a reader to be sure, and it says exactly what you meant. But the thing it was supposed to do never happened — no file, no email, no row in the table. So the job “didn’t run.”

That phrase is where the debugging goes wrong, because it names a symptom and quietly asserts a cause. “It didn’t run” gets treated as one problem with one fix, when it’s really three very different failures wearing the same sentence:

  1. It never fired. Cron never invoked your command at all — the line isn’t where you think it is, the daemon isn’t running, or this machine has no cron.
  2. It fired, but the command died immediately. Cron ran the line on schedule, but the command hit an error on its first step and exited — usually because cron’s environment is nothing like the shell you tested in.
  3. It ran fine, and you can’t see the proof. The command did exactly what it should. Its output went somewhere you’re not looking, so from where you’re standing it looks like nothing happened.

These have almost nothing in common. Suspecting the schedule when the real problem is a missing PATH, or rewriting the command when cron never fired it, is how an hour disappears. Two subtler variants sit on top of the three — a job that fires most days but skips one, and a job that fires at a time you didn’t expect — and we’ll reach both at the end. But the first move isn’t to theorize about any of it. It’s to find out which case you’re holding. One place tells you.

One scope note before the steps. This guide is mostly about a classic Linux crontab — the Cronie/Vixie-style cron most distributions ship — with macOS, systemd, container, and hosted cases flagged as they come up. The reasoning transfers everywhere; the specifics don’t. On GitHub Actions, Cloudflare, or a Kubernetes CronJob, you confirm a run fired in that platform’s own execution history, not a system log — so treat each platform’s docs as the authority.

Start at the log — it splits the problem in half

Before you change anything, ask cron what it did. The cron daemon usually logs a line each time it invokes a job — typically (user) CMD (the exact command) — so the log also confirms it was reading the line you think it was. (Usually, not always: Cronie lets a crontab line start with - to suppress its syslog entry, and log backends vary — one more reason a missing entry isn’t proof on its own.) That single fact cuts the cases in two. Where the log lives depends on the system:

grep CRON /var/log/syslog        # Debian / Ubuntu
journalctl -u cron               # Debian / Ubuntu (systemd)
journalctl -u crond              # RHEL / Fedora / Alma (systemd)
cat /var/log/cron                # RHEL / CentOS

Look for a line at the scheduled minute naming your command. What you see decides where to go next:

  • There’s a log entry for your command. Cron fired it. The schedule is fine, and you can stop suspecting it. Your problem is downstream — the command died (next section) or ran and hid its output (the one after). This is the common case, and it eliminates half the guesses in one command.
  • There’s no entry at the time it should have run. Most often that means cron never invoked the line — but confirm you’re reading the right log first (see the caveat below), then skip ahead to It never fired.

Two things to keep straight. First, a matching CMD (…) entry proves cron invoked the job — and only that. It’s an invocation, not an exit code, so it moves you from “did it fire?” to “what happened after?”, which is exactly the split you want. The absence of an entry is weaker evidence: across distributions, log configs, container images, and permissions, cron may simply not be writing where you’re looking. So before you conclude “it never fired,” confirm you’re querying the daemon and log backend this host actually uses. Second, on a stripped-down box with no syslog daemon these lines live only in the systemd journal, so reach for journalctl rather than a log file. (macOS routes cron through the unified system log; log show --predicate 'process == "cron"' --last 1h is the rough equivalent, though on a Mac the likelier answer is that cron isn’t the right tool at all — more below.)

When you can’t trust the log at all — a locked-down host, an unfamiliar distro, a container — sidestep it with a probe that writes to a file you own, and watch whether it grows:

* * * * *  date >> /tmp/cron-probe.log 2>&1

A new line every minute means the daemon is alive and reading your crontab; a file that never appears, or stops growing, means it isn’t. It answers “is cron running my crontab at all?” without depending on the system log. Remove it once you have your answer.

It fired, but the command died: cron is not your shell

This is the most common reason a correct line produces nothing, and it rests on one idea worth holding onto: the command that works when you type it does not run in the same environment when cron runs it. Cron doesn’t start your shell. It runs your line as /bin/sh -c '<command>' — a minimal, non-interactive process with a bare set of variables — so none of the setup your interactive shell does for you happens here.

A handful of consequences account for most of the failures:

The PATH is tiny. Cron typically runs with a PATH of little more than /usr/bin:/bin. So a line that calls node, python3, docker, aws, psql, or your own script by bare name works at your prompt — where PATH is rich — and fails under cron with “command not found,” which you never see, because cron mails the error somewhere you’re not looking (next section). Anything installed under /usr/local/bin, a language version manager, or a project’s node_modules/.bin is invisible.

Your dotfiles are never read. Cron’s shell is non-login and non-interactive, so ~/.bashrc, ~/.bash_profile, and ~/.profile are not sourced. Everything those files set up is gone: the PATH additions from nvm, pyenv, rbenv, asdf, or Homebrew; exported secrets and config; an activated virtualenv or Conda environment. The command that “just works” in your terminal often works because of a line in a dotfile you forgot you had.

The working directory is $HOME, and the shell is /bin/sh. Cron runs from your home directory, so any relative path (./data, logs/out.txt, config.yml) resolves from the wrong place. And unless you set it, the shell is /bin/sh — which on Debian and Ubuntu is dash, not bash. Bash-only syntax ([[ … ]], arrays, source) then fails with a cryptic error.

It may not even be your account. A system crontab runs as root or a named service user, so $HOME — and with it ~/.ssh keys, known_hosts, ~/.aws, a kubeconfig, a gcloud login — points at that user’s home, not yours. A git, ssh, rsync, or cloud CLI that authenticates fine from your terminal fails under cron because the credentials it reaches for live in a home directory it isn’t using.

The fix is to stop depending on anything interactive. Use absolute paths for both the interpreter and the files, set the environment explicitly at the top of the crontab, and pin the shell:

SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin

0 3 * * *  cd /srv/app && /usr/local/bin/node scripts/nightly.js

For anything beyond one command, put it in a script: an absolute shebang (#!/bin/bash, not #!/usr/bin/env bashenv still leans on PATH to find bash, the very thing that’s unreliable here), set -euo pipefail at the top, and absolute paths inside — then schedule the script. Your crontab line stays trivial and all the fragile setup lives in one testable file. And you can approximate cron’s stripped environment before you deploy, which turns “works for me” into a real test:

env -i HOME="$HOME" PATH=/usr/bin:/bin /bin/sh -c 'cd "$HOME" && exec /srv/app/scripts/nightly.sh'

The cd "$HOME" matters: cron starts each job from the owner’s home directory, so omitting it lets a script with a relative path pass your test yet fail under cron (or the reverse). Use the home of the user that owns the job — often root or a service account, not your login — since that’s whose $HOME and dotfiles cron uses. It isn’t cron exactly (cron also sets LOGNAME, USER, SHELL, and any crontab-level variables), but it reproduces what usually breaks. If it fails at your prompt, it would have failed under cron too.

Even with the environment right, the script itself may refuse to run. A stripped environment isn’t the only way a line dies on its first step. Three more make a command fail the instant cron reaches it, each invisible until you capture the error (next section):

  • The script isn’t executable. Schedule /srv/app/job.sh without chmod +x and cron gets permission denied. Mark it executable, or invoke the interpreter explicitly: /bin/bash /srv/app/job.sh.
  • Windows line endings. A script saved with CRLF line endings turns the shebang into #!/bin/bash\r, and cron reports bad interpreter: /bin/bash^M: no such file or directory. It’s the classic “edited on Windows, deployed to Linux” failure. dos2unix job.sh fixes it.
  • Nothing to prompt at. Cron has no terminal, so anything that stops to ask — ssh confirming an unknown host key, a passphrase, sudo wanting a password, a CLI’s “are you sure?” — hangs or fails outright. Give it a non-interactive form: ssh -o BatchMode=yes so a missing credential fails fast instead of hanging, a dedicated least-privilege automation key with a real known_hosts entry, and a scoped sudoers rule instead of a password. A passphrase-less key can work, but as a deliberate trade-off, not the default.

It ran fine — you just can’t see it

Sometimes cron fired the line and the command succeeded, and it still looks like nothing happened, because you’re judging “did it run” by an effect you never actually wired up. The classic version is a command whose only output is text on stdout. Cron captures that output and, by tradition, mails it to the job’s owner. On a server with no mail transfer agent configured — as many now are — that mail has nowhere to go and is silently dropped. Your job printed “Done,” and the “Done” evaporated.

So don’t rely on cron’s mail. Send output somewhere you control, and capture errors with it:

0 * * * *  /srv/app/hourly.sh >> /var/log/hourly.log 2>&1

The order matters: >> file 2>&1 points stdout at the file and then points stderr at the same place. Write 2>&1 >> file and stderr still goes to the old destination. With both streams in a log you can read, “it didn’t run” usually turns into a specific error you can fix in a minute.

One trap lives right in the command string and produces exactly this “ran but did nothing useful” symptom: an unescaped % in a crontab command is not a literal percent sign. Cron translates % to a newline, and everything after the first % becomes standard input to the command rather than part of it. So this line does not do what it looks like:

0 0 * * *  pg_dump mydb > /backup/db-$(date +%F).sql

Cron cuts the command at the %, so it runs pg_dump mydb > /backup/db-$(date + — a broken command feeding F).sql in as input. The date never expands and the backup is empty or missing. Escape every % you mean literally with a backslash:

0 0 * * *  pg_dump mydb > /backup/db-$(date +\%F).sql

It never fired: the crontab isn’t what you think

If the log showed nothing at the scheduled time, cron never got to your line. The line exists — but not where a running cron daemon is reading it.

One thing you probably don’t need first: a restart. After a normal crontab -e, Cronie notices the change on its own by watching the spool’s modification time, so the reflexive systemctl restart cron is usually habit, not a fix. It isn’t an absolute rule — edge cases like a crontab reached through a symlink can defeat the mtime check — but if a plain edit isn’t taking effect, look at the causes below before restarting.

You edited a file, not the crontab. User crontabs are managed with crontab -e; cron reads them from its own spool directory, not from a file you created somewhere and saved. If you wrote your line into a random crontab.txt and never installed it, nothing schedules it. Confirm what cron actually has with crontab -l, and install a file with crontab path/to/file.

System crontabs have an extra field, and it’s easy to get wrong. A user crontab (crontab -e) is five time fields and then the command. But the system files — /etc/crontab and anything in /etc/cron.d/ — insert a user field between the schedule and the command:

# /etc/cron.d/backup  — note the "root" field before the command
0 3 * * *  root  /srv/app/backup.sh

Drop a normal five-field line into /etc/cron.d/ and cron reads the first word of your command as the username — the job fails to run and you get a logged error about an unknown user, not the result you wanted. The mistake runs the other way too, with a different symptom: pasting that same root field into crontab -e does not select a user. It becomes the first word of the command, so the shell usually fails trying to execute root (command not found).

A few quieter installation traps:

  • No trailing newline. Some cron implementations ignore the final line of a crontab if the file doesn’t end in a newline. crontab -e normally handles this; a file you drop into /etc/cron.d/ by hand may not.
  • A filename cron skips. This one is a run-parts behavior, not a universal cron rule: on Debian/Ubuntu and other systems that drive /etc/cron.daily, /etc/cron.hourly, and friends through run-parts, a file whose name contains a dot is ignored by default, so backup.sh there never runs while backup does. Systems that don’t use run-parts here won’t have the restriction.
  • The daemon isn’t running — or won’t be after a reboot. A minimal container or a freshly provisioned box may not have cron started at all. Check that it’s running now with systemctl status cron (or crond); but since status only reflects the current state, confirm it also starts on boot with systemctl is-enabled cron, and turn both on with systemctl enable --now cron if not.
  • You weren’t allowed to install the crontab. /etc/cron.allow and /etc/cron.deny govern who may use the crontab command, not whether an installed job runs. So these rules matter only if the user couldn’t install or replace the crontab in the first place — they don’t stop an already-installed user crontab from firing. If crontab -e errored with a permissions message earlier, this is why your line was never saved.

There’s no cron here at all — or it’s the wrong scheduler

Sometimes the schedule never fired because the thing you’re picturing simply isn’t present. This is increasingly the real answer.

Containers don’t run cron for free. A Docker base image has no cron daemon running. Adding a crontab to the image does nothing unless you also install and start cron in that container — and even then you inherit a second copy of the environment problem, because the container’s PATH and installed tools differ from the host’s. For scheduled work, running the job from the host or the orchestrator is usually cleaner than nursing a cron daemon inside a container.

On macOS, cron is the wrong default. cron still exists on a Mac, but Apple recommends launchd instead, and there’s a practical reason to switch: if the Mac is asleep at the scheduled minute, cron does not run the missed job when the machine wakes — it’s simply skipped. A launchd agent with StartCalendarInterval does run a job that was missed while the machine was asleep, catching it up on wake. Note the boundary: that catch-up covers sleep, not shutdown — a job missed while the Mac was fully powered off is not run later. So a nightly cron on a laptop that’s closed overnight may never fire at all, and launchd fixes the asleep case but not the powered-off one.

Modern Linux often uses timers, not cron. Many distributions schedule with systemd timers instead of, or alongside, cron. If a job is defined as a timer, it won’t appear in any crontab. List them with systemctl list-timers, and note that a timer with Persistent=true will catch up a run missed while the machine was off — something plain cron never does.

Hosted and serverless schedulers only run what’s deployed to them. A GitHub Actions schedule:, a Cloudflare Workers cron trigger, or a Kubernetes CronJob runs on that platform’s scheduler, not on any machine’s crontab. So the line has to actually be deployed there — and each has its own way of quietly not running: a Kubernetes CronJob can be suspend: true or skip runs under concurrencyPolicy and startingDeadlineSeconds; GitHub Actions runs schedules on a best-effort basis and can delay or drop them under load. And Windows has no cron at all — that world uses Task Scheduler or schtasks.

It fires most days, then skips one

A subtler case: the job runs most of the time, then misses. Two mechanisms cause it.

Runs overlap and pile up. If one execution takes longer than the gap to the next, cron starts the next one anyway — it never waits for the previous to finish. A job that usually takes 20 seconds but occasionally runs for three minutes, scheduled every minute, ends up with several copies fighting over the same file or lock. On Linux, guard against it with flock (a util-linux tool), refusing to start when the previous run is still going:

* * * * *  /usr/bin/flock -n /var/lock/myapp/sync.lock /srv/app/sync.sh

flock -n grabs the lock or exits immediately. So if a previous run still holds the lock, the new scheduled invocation exits at once instead of overlapping the one still in progress — the slow run keeps going untouched; it’s the next trigger that’s skipped. Put the lock file somewhere the job’s own user can write and that isn’t world-writable — a dedicated directory like /var/lock/myapp/ rather than shared /tmp, whose permissions and cleanup make it a poor fit for a production service.

The window passed while the machine was off. Plain cron has no memory. If the box was powered down or asleep at 3:00 when the nightly job was due, cron does not run it late — that occurrence is simply gone. This is the same catch-up gap that anacron was built to close for daily, weekly, and monthly jobs, and that systemd timers close with Persistent=true. On an always-on server it rarely bites; on anything that sleeps, it’s a frequent cause of “it ran yesterday but not today.”

When it fired — just at the wrong time

There’s a last case that the log will point you to: an entry appears, but at a wall-clock minute you didn’t expect. The entry means the line fired — so this isn’t the environment or the install. It’s the schedule itself: the expression, or the timezone it runs in.

The expression traps are covered end to end in How to Read a Cron Expression — a step like */35 that doesn’t divide its range evenly, the day-of-month/day-of-week OR rule, and the fact that a cron expression carries no timezone of its own, so the server’s zone and daylight-saving shifts decide when it really fires. The fastest way to see what a line should do is to drop it into the cron expression reader: it prints the schedule in plain English, breaks out each field, and lists the next runs in the timezone you choose, so a shifted or skipped run becomes visible.

Once you’ve narrowed it to a zone offset — the job fired at the right instant but the wrong wall clock — a timezone converter lines up the server’s zone against yours, and UTC, GMT, ISO 8601, and Unix time untangles the layers underneath.

A diagnostic checklist

When a cron job “didn’t run,” work it in this order:

  1. Read the cron log first. grep CRON /var/log/syslog or journalctl -u cron/crond. An entry at the right minute proves it fired — the problem is downstream. No entry usually means it never fired, once you’ve confirmed you’re reading the log this host actually writes to.
  2. If it fired but did nothing: check the environment and the script. Absolute paths, an explicit PATH and SHELL, no reliance on dotfiles, the right working directory and user — then confirm the script is executable, has Unix line endings, and never waits for input. Reproduce it with env -i.
  3. If it fired but you saw no output: redirect it. Append >> /path/log 2>&1 and stop trusting cron’s mail. Escape any literal % as \%.
  4. If it never fired: check the install. crontab -l for user jobs, the extra user field for /etc/cron.d/ jobs, a trailing newline, and whether the daemon is even running.
  5. If there’s no cron here: use the right scheduler. A container, a Mac, a systemd box, or a hosted platform each schedules its own way — and a machine that sleeps needs one that catches up.
  6. If it fired at the wrong time: it’s the schedule, not the setup. Head back to the reader and the reading guide for the step, day-field, and timezone traps.

Do that and “it didn’t run” stops being a mystery and becomes a short, ordered narrowing — from did it fire at all down to the one specific reason it produced nothing.