2026-10-26
Why is my cron job not running?
A Linux crontab job can miss its slot and leave no error in the application log. Check PATH, percent signs, the user field, the daemon, and MAILTO, then ping at the end so a job that never starts still pages you.
A crontab line that does not start writes nothing to the log you are tailing. The process that would have written the line was never created, and cron's own complaint, when it has one, goes to syslog or to mail. The command that works in your terminal is also running with a different environment from the one cron builds. This page is that environment: a user crontab, /etc/crontab, and /etc/cron.d, on Debian and Ubuntu's cron and on cronie (Fedora, RHEL, and derivatives).
A scheduled GitHub Actions workflow, an n8n workflow, and a Zapier Zap fail in their own ways, and each already has a page: GitHub Actions, n8n, and Zapier. What a clock change does on November 1 and March 8, 2026, is in the daylight-saving post. What follows is the classic crontab.
The environment cron actually uses
The crontab(5) manual for cronie, and Debian's crontab(5), agree on the variables cron sets. SHELL is /bin/sh unless the crontab sets it. HOME and LOGNAME come from the passwd entry of the crontab owner. HOME and SHELL can be overridden in the file. LOGNAME cannot. cronie also sets USER to that same name on Linux. None of this comes from .bashrc, .profile, or the PATH in your interactive shell. cron does not start a login shell. It hands the command string to SHELL.
On Debian and Ubuntu, /bin/sh is dash. `ls -l /bin/sh` shows the link on the host you have. A crontab line that uses source, arrays, or [[ ]] is bash syntax parsed by dash, and dash exits before the job does any work. Set SHELL=/bin/bash in that file when the line needs bash, or write the line as plain sh.
PATH is the other half of the same trap. Debian's cron package uses /usr/bin:/bin when the crontab does not set PATH. cronie uses /usr/bin:/bin:/usr/sbin:/sbin unless the daemon was started with -P. cron(8) documents -P as the switch that inherits PATH instead of setting it. /usr/local/bin, a home-directory bin, nvm, pyenv, and asdf are absent from both defaults. The PATH= line in /etc/crontab applies to that file. Debian's cron(8) says files in /etc/cron.d do not inherit environment settings from /etc/crontab. A user crontab does not inherit them either.
Both implementations change directory to HOME before the command. HOME is the passwd directory unless the crontab overrides it. ./nightly-job and data/out.csv are resolved there, not in the directory you were in when you edited the file, and not beside the script. cronie logs ERROR chdir failed when that directory is missing. An absolute path for the program, and for every file it opens, removes the guess.
See whether cron started the command
Debian's cron logs to the syslog facility cron, and the default is to log the start of every job. On Ubuntu those lines are in /var/log/syslog unless a local rule has split cron into its own file. cronie logs to /var/log/cron, which cron(8) names. The unit is cron on Debian and Ubuntu, and crond on cronie. A start line contains CMD and the command text, with the username. A missing line means cron did not start that command, with one Debian exception: /etc/default/cron can set EXTRA_OPTS="-L 0", which turns start lines off. Errors are still logged at that level. The file ships with EXTRA_OPTS empty, which leaves the daemon default, start lines on.
grep CRON /var/log/syslog
grep CRON /var/log/cron
journalctl -u cron --since "today"
journalctl -u crond --since "today"Redirect the job anyway. Mail and syslog are easy to miss, and a redirect you chose is a file you can open. The directory has to be writable by the user the crontab runs as. $HOME is. /var/log often is not, and a redirect that cannot open its file fails before the job runs. The error from that failure is itself mailed, or dropped, under the MAILTO rules below.
15 6 * * * /usr/local/bin/nightly-job >>"$HOME/nightly-job.log" 2>&1Reproduce it with env -i
Dump the environment cron actually built. This one-minute line is temporary. Delete it after it fires. /usr/bin/env is on both default paths, so the dump works even when your own tools are not.
* * * * * /usr/bin/env >>"$HOME/cron-env.txt" 2>&1Then run the job with a clean environment. env -i drops the PATH, functions, and version-manager variables your terminal added. Put the PATH from cron-env.txt on the line. The value below is Debian's compiled default. cronie's default is /usr/bin:/bin:/usr/sbin:/sbin when crond was not started with -P. If the job fails here and succeeds in your terminal, the crontab needs a PATH= line, or absolute paths to every binary. A missing command is a shell error, and that error is the output cron will try to mail.
env -i HOME="$HOME" LOGNAME="$(id -un)" USER="$(id -un)" SHELL=/bin/sh PATH=/usr/bin:/bin /bin/sh -c '/usr/local/bin/nightly-job'Percent signs
crontab(5) gives % a meaning. The command runs only up to the first unescaped percent. That percent becomes a newline, and everything after it is standard input. A date format inside the line does not format a date. cron cuts the command at the first %, and the rest of the format is stdin. Escape every percent with a backslash. Debian's crontab(5) states the same rule. A comment on its own line is left alone, because a line whose first non-space character is # is not a command. A # later on a command line is part of the command.
# The next line is the bug. It is commented so it will not install.
# 15 6 * * * /bin/date +%Y-%m-%d >>"$HOME/date.log" 2>&1
15 6 * * * /bin/date +\%Y-\%m-\%d >>"$HOME/date.log" 2>&1A missing final newline
cronie's crontab(5) requires each entry to end in a newline. If the last entry is terminated by the end of the file, cron considers the crontab at least partly broken and writes a warning to syslog. The last job is the one that disappears. Scripts, here-documents, and editors that do not end the buffer with a newline are how the file gets that way. crontab -e is the supported way to install a user table. Debian's cron(8) says the files in the spool should not be edited directly.
crontab -l | tail -c 1 | od -An -tx1The byte you want is 0a. On cronie, crontab -T checks a file's syntax before you install it.
Whose crontab, and the extra user field
crontab -e edits the table of the user you are. sudo crontab -e edits root's. crontab -l does not show root's lines, and sudo crontab -u deploy -l is how you read deploy's. A job in your table runs as you, so it cannot read files your account cannot read. A job you installed under sudo does not run as you.
/etc/crontab and the files in /etc/cron.d have a username between the five time fields and the command. cronie's manual shows the shape as minute, hour, day, month, weekday, then the user, then the command: * * * * * root touch /tmp/file. A user crontab stops at five time fields and then the command. Paste the six-field form into crontab -e and the username is executed as the program. Paste a five-field line into /etc/cron.d and the first word of the command is read as the user, so the real program never starts.
# user crontab. Five fields. Runs as the owner.
15 6 * * * /usr/local/bin/nightly-job
# /etc/cron.d/nightly-job. User field, then the command.
15 6 * * * deploy /usr/local/bin/nightly-jobOn Debian, a file in /etc/cron.d must be owned by root and must not be group-writable or world-writable. The name may contain only letters, digits, underscores, and hyphens. Debian's cron(8) says a dot in the name is enough to ignore the file, which is why backup.sh in that directory never runs, and why a leftover .dpkg-old file is skipped. cronie is narrower. It skips names that start with a dot or #, end in ~, or end in .rpmsave, .rpmorig, or .rpmnew. A dot in the middle does not, by itself, drop a cronie file. Check which daemon is installed before you rename something that was fine on the other one.
The execute bit, and files cron refuses
A command that is a path to a script needs the execute bit and a shebang, or it needs an interpreter in front: /bin/sh /usr/local/bin/nightly-job. ls -l shows the bit. cronie's crontab(5) adds a separate rule for the crontab file itself. It has to be a regular file, or a symlink to one, and it must not be executable or writable by anyone but the owner. A mode that breaks that rule is a table cron will not use. The application log stays empty because the script was never executed.
Scripts dropped into /etc/cron.hourly, /etc/cron.daily, /etc/cron.weekly, or /etc/cron.monthly are a third case. Debian runs them with run-parts. They must be executable, owned by root, not writable by group or other, and the file name follows the same letters, digits, underscore, and hyphen rule. A name that fails the rule is not executed. There is no job output to mail, because the script never started. Those directories are also where anacron comes in, below.
Is the daemon running?
No daemon means no starts and no CMD lines. Debian and Ubuntu's unit is cron. cronie's is crond. cron(8) gives the start command as systemctl start crond.service. A minimal image or a container often never installed the package, which looks identical to a wrong minute until you ask systemd.
systemctl is-active cron
systemctl is-active crond
ps -C cron -C crond -o pid,cmdOne of the two units should print active. not-found on both means the package is absent. inactive means it is installed and stopped.
Timezone and CRON_TZ
The five fields are compared with the daemon's clock. Debian's cron(8) uses /etc/timezone when that file is present. cronie's CRON_TZ sets the zone for one file. The times you type are in that zone. The log timestamp stays in the zone the daemon is running in, so a UTC job on a New York host is logged hours away from the numbers in the table. timedatectl is the clock to compare with the hour you wrote.
CRON_TZ=UTC
15 6 * * * /usr/local/bin/nightly-jobCRON_TZ does not turn daylight saving off. America/New_York still has a repeated hour on November 1, 2026, and a missing hour on March 8, 2026. The daylight-saving post walks those mornings, including what a UTC cron does and what cron(8) does with a clock step under three hours. A job you believe was skipped on an ordinary day is a zone mismatch long before it is a daylight-saving case. Put the same zone on the Nightshift monitor as the one this file uses.
Sleep, reboot, and anacron
cron examines the current minute. It does not walk back through minutes when the machine was suspended or powered off. A 06:15 line on a laptop that was asleep at 06:15 does not run at 07:00. uptime and who -b tell you whether the host was up for that minute. cronie's crontab(5) documents @reboot as once after reboot, and Debian's cron accepts the same nickname. @reboot is not a replay of the slot you missed.
anacron(8) is the scheduler that does not assume the machine is running continuously. Its period is in days. It is how Debian runs /etc/cron.daily, /etc/cron.weekly, and /etc/cron.monthly when anacron is installed. Debian's cron(8) says those tasks in /etc/crontab are disabled in that case, and the hourly task stays with cron. A five-field line in a user crontab is not an anacron job. It will not be caught up after a reboot.
MAILTO, and output that goes nowhere
When the command prints anything, including a shell error, cron mails that output. crontab(5) says a non-empty MAILTO is the recipient, an empty MAILTO (MAILTO="") sends no mail, and an unset MAILTO mails the crontab owner. An empty MAILTO and no redirect is the setup that looks like a job with no error and nothing in the logs. The shell failed, and the channel cron was going to use is turned off.
cronie's cron(8) covers the host with no mailer. If sendmail is not installed, that output goes to syslog instead of mail. Keep the redirect to a file the user can write, so the result does not depend on which of those two paths this host has. Set MAILTO to an address you actually read when you want the letter as well. Leave it empty once the log file is in place, so a healthy job that prints a banner does not send one every night.
A short pass on the host
- systemctl is-active cron and systemctl is-active crond. One of them is active. ps -C cron -C crond shows the process.
- grep CRON /var/log/syslog and grep CRON /var/log/cron, or journalctl -u cron and journalctl -u crond since today. A CMD line means cron started that command. On Debian, also read EXTRA_OPTS in /etc/default/cron.
- crontab -l for you, sudo crontab -l for root, sudo crontab -u deploy -l for someone else, and ls -l /etc/cron.d /etc/crontab. Five fields in a user table. A username before the command in /etc/cron.d.
- On Debian, the /etc/cron.d name has no dot, the file is owned by root, and it is not group-writable or world-writable. A script in cron.daily is executable and follows the same name rule.
- crontab -l | tail -c 1 | od -An -tx1 prints 0a. The script you name has a shebang and the execute bit, or the line starts with /bin/sh and an absolute path.
- The temporary env dump, then env -i with that PATH. The same command that works in your terminal fails here when PATH or the working directory was the cause.
- Every % in the command is written as \%. SHELL is set when the line is bash. timedatectl matches the hour you typed, or the file sets CRON_TZ.
- uptime covers the slot you expected. A user crontab does not catch up after sleep or reboot. anacron covers the daily, weekly, and monthly directories, in days.
- The command appends stdout and stderr to a file under $HOME. MAILTO is an address you read, or MAILTO="" once that file exists.
Ping at the end of the success path
The pass above is what you run once you already think the job missed. A deleted line, a stopped daemon, a slept laptop, and a file in /etc/cron.d that Debian's naming rule ignored all share a property: no process, so no line in nightly-job.log. Syslog has a CMD line only if the daemon tried, and only if someone reads it. The check that still fires lives outside the job. A monitor expects a ping after the work, and the missing ping is the alert.
Ping after the work, not before it. A request at the start only proves the line was reached. The ping API is specific: POST or GET /ping/<token>/start records the start, and it does not reset the deadline and does not clear a down monitor. The success POST is the heartbeat. /fail marks the monitor down on the error path, when you want the alert as soon as the command fails, instead of at the deadline. The dead-man's switch post is the same shape for GitHub Actions and Vercel cron.
tokens, cost_usd, and duration_ms are optional on every plan. A backup script can POST the bare URL. The snippet on the monitor page sends zeros for tokens and cost_usd. That request is still a success, and it still moves the deadline. Free records the numbers. Pro and Team are the plans that also alert when a loop spends too much while the pings are still arriving.
The monitor page copies the line below. NIGHTSHIFT_PING_URL has to be set in the crontab itself. cron will not pick the variable up from your shell. The token in the URL is the credential. Keep it in the user crontab. A file under /etc/cron.d that other accounts can read is a file those accounts can use to ping, or to fail, your monitor.
# crontab -e
0 * * * * /usr/local/bin/nightly-job && curl -fsS -m 10 -X POST "$NIGHTSHIFT_PING_URL" -H "content-type: application/json" -d '{"tokens":0,"cost_usd":0}' || curl -fsS -m 10 -X POST "$NIGHTSHIFT_PING_URL/fail"In the shell, && and || are left-associative and share a precedence, so job && curl-success || curl-fail runs the fail ping when the job fails and also when the job succeeds and the success curl fails. A wrapper keeps those paths apart. The log file still receives the job's output when MAILTO is empty. The crontab runs the wrapper by absolute path, and the wrapper needs the execute bit.
#!/bin/sh
set -eu
log="$HOME/nightly-job.log"
if /usr/local/bin/nightly-job >>"$log" 2>&1; then
curl -fsS -m 10 -X POST "$NIGHTSHIFT_PING_URL"
else
curl -fsS -m 10 -X POST "$NIGHTSHIFT_PING_URL/fail" || true
exit 1
fi# crontab -e
SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""
NIGHTSHIFT_PING_URL=https://nightshiftwatchdog.com/ping/YOUR_TOKEN
15 6 * * * /usr/local/bin/nightly-job-pingCreate the monitor with the same five fields and the same zone as the file. The schedules page says the deadline is the next slot plus the grace period you set. Nightshift checks once a minute, so a silence is noticed within about a minute after that deadline. Grace is how late a slow run may finish. The ping URL is on the monitor page after you sign up.