Skip to content
← Guides

It works in your terminal. Under cron, it quietly doesn't.

You test the script, it works, you add it to crontab and move on. Days later you find out it never did its job, because cron ran it in a different shell with a different environment, and the error went somewhere nobody looks.

Short answer

Two things fix this for good. First, find the difference between your terminal and cron's environment (Step 1 below shows a one-line way to see it). Second, make the job prove it worked: give it a watch in Did It Run? and have the script check in only after the real work succeeds. If it fails under cron, or cron never starts it, no check-in arrives and you get a missed alert at the expected time plus your grace window. With the wrapper in Step 4, a failure is reported straight away with the last lines of the error in the alert. Start free (3 watches, no credit card).

Check-ins are by ping URL today, with /start and /fail variants. Email check-ins are coming soon.

Why a job that works by hand fails under cron

  • A different shell. The crontab(5) manual says cron sets SHELL to /bin/sh for every job unless the crontab overrides it (Debian crontab(5)). A command line that relies on bash features can behave differently when /bin/sh runs it.
  • A much smaller environment. The only variables the manual says cron sets up are SHELL, LOGNAME and HOME (taken from the crontab owner's /etc/passwd entry), plus anything you set in the crontab itself. What your terminal picks up from ~/.bashrc or ~/.profile, such as extra PATH entries, version-manager shims or exported API keys, isn't on that list. That's how an AI agent CLI or a Python script that works in your terminal ends up with "command not found" or a missing key at 3am.
  • A % sign in the command. cron turns an unescaped % in the command into a newline and sends what follows to the command's standard input. A date +%F in a crontab line breaks the job. More in one % sign can break a cron job every night.
  • A file in /etc/cron.daily that is silently skipped. On Debian and Ubuntu, those directories are run by run-parts, and files there must be executable, owned by root and not writable by group or others. File names may only contain letters, digits, underscores and hyphens, so "any file containing dots will be ignored" (Debian cron(8)). A script named backup.sh dropped into /etc/cron.daily never runs, and nothing tells you.
  • The error goes to mail. cron mails any output to the crontab's owner, or to the address in MAILTO; with MAILTO="" no mail is sent at all (Debian cron(8), crontab(5)). On many servers, nobody ever reads that local mailbox, so the "command not found" message sits there unread.

Step 1: see cron's environment, not yours

Add a temporary line to your crontab that dumps the environment cron gives a job, wait a minute, then compare it with your terminal:

bash
* * * * * env > /tmp/cron-env.txt 2>&1
bash
diff <(sort /tmp/cron-env.txt) <(env | sort)

Look at PATH, SHELL, HOME and any API keys or config variables your script reads. Remove the temporary line when you're done.

Step 2: capture the real error

Send the job's output to a log file instead of mail, so you can see what happened the last time it ran:

bash
0 6 * * * /home/me/bin/report.sh >> /home/me/logs/report.log 2>&1

cron also logs its own actions to the syslog facility cron (Debian cron(8)). On a Debian or Ubuntu machine using systemd, journalctl -u cron shows whether cron started the job at all.

Step 3: make the job independent of your terminal

  • Use absolute paths for every program and file, and don't assume which directory the job starts in.
  • Set what the job needs at the top of the crontab: environment settings like SHELL=/bin/bash or PATH=/usr/local/bin:/usr/bin:/bin go on their own lines. Comments aren't allowed on the same line as a setting or a command.
  • Keep secrets the script needs in a file it loads itself (for example set -a; . /home/me/.config/report.env; set +a at the top of a bash script), instead of relying on variables exported in your shell profile.
  • Make the script exit non-zero when something fails (in bash, set -euo pipefail), so the wrapper in Step 4 can tell a failure from a success.
  • Escape any % in the crontab line as \%, or move the command into the script.
  • In /etc/cron.daily and friends, name files without dots (backup, not backup.sh) and make them executable. On Debian, run-parts --test /etc/cron.daily lists the files that would run.

Step 4: add a heartbeat that only fires on success

Create a watch in Did It Run? with the job's schedule and timezone and a grace window that covers normal run time. Then run the job through a small wrapper that reports the start, the success, or the failure with its last lines of output:

bash
#!/usr/bin/env bash
# /home/me/bin/with-heartbeat: run a command and report the result to Did It Run?
PING="$1"; shift
curl -fsS -m 10 --retry 3 "$PING/start" > /dev/null
out=$("$@" 2>&1); code=$?
printf '%s\n' "$out" >> /home/me/logs/cron-jobs.log
if [ "$code" -eq 0 ]; then
  curl -fsS -m 10 --retry 3 "$PING" > /dev/null
else
  printf 'exit %s: %s' "$code" "$(printf '%s' "$out" | tail -c 180)" |
    curl -fsS -m 10 --retry 3 -X POST --data-binary @- "$PING/fail" > /dev/null
fi
exit "$code"
bash
SHELL=/bin/bash
0 6 * * * /home/me/bin/with-heartbeat https://diditrun.app/p/<your-token> /home/me/bin/report.sh

The wrapper calls curl by its name, so make sure curl is on cron's PATH (Step 1 shows it), or use its full path.

Now each case is covered:

  • The script fails under cron (missing command, missing key, bad path): the wrapper calls /fail and you get a failed alert with the end of the error message in it.
  • cron never starts the job (wrong crontab, skipped file, machine off): no check-in arrives, so the run is missed at the deadline.
  • The job starts but hangs: /start arrives with no finish, and the run is flagged missed at the deadline. See cron job or AI agent hung with no error.

If the job can run fine and still produce nothing useful, send an item count or the output with the success ping and set a sanity check on the watch. See scheduled job ran but the output was empty.

Honest limits

  • Did It Run? doesn't read your crontab, your logs or your mail. It only sees the check-ins your job sends, so the wrapper (or a ping at the end of the script) has to be there.
  • It can't tell you *why* a job never started. A missed alert says the run didn't arrive; Step 1 and Step 2 are how you find the cause.
  • The failure text in an alert is whatever your job sends to /fail, cut to the first 200 characters, so send the most useful part (the wrapper above sends the end of the output).

What you'll see

  • Missed: no check-in by the expected time plus grace.
  • Late: an optional early warning while the run is still inside its grace window.
  • Failed: the job called /fail, with the reason it sent.
  • Recovered: the first good run after a problem.

You get one alert when a watch changes state, not one for every run, plus a recovery notice. Free covers 3 watches with email alerts; Solo ($15/month) adds Slack, Discord and Telegram alerts and a daily roll-call digest across all your automations. Full setup steps are in the cron recipe.

FAQ

Why does my script work in the terminal but not in cron?

Usually because cron runs it differently: with /bin/sh as the shell and only a few environment variables (SHELL, LOGNAME and HOME, plus any set in the crontab), so PATH entries, version managers and API keys from your shell profile aren't there. Dump cron's environment with a temporary 'env > /tmp/cron-env.txt' entry and compare it with your terminal.

Where does cron put the error message?

cron mails a job's output to the crontab's owner, or to the address in MAILTO; with MAILTO="" nothing is sent. If nobody reads that mailbox, redirect the output to a log file with '>> /path/to/log 2>&1', and add a heartbeat so a failure reaches you as an alert.

Why doesn't my script in /etc/cron.daily run?

On Debian and Ubuntu, run-parts ignores files whose names contain dots, such as backup.sh, and files that aren't executable. Debian's cron manual also says files there must be owned by root and not writable by group or others. Rename it to something like 'backup', make it executable, and check with 'run-parts --test /etc/cron.daily'.

Will Did It Run? tell me why the job failed?

Only as much as the job tells it. If the job calls the /fail URL with a message, that message (first 200 characters) is in the alert. If the job never runs, you get a missed alert, and the cause is in cron's logs or the job's own log.

Can I check in by email instead of a URL?

Not yet. Email check-ins are coming soon. Today every check-in is an HTTP request to the watch's ping URL, with /start and /fail variants.

Is this on the free plan?

Yes. Missed, late, failed and empty detection is included on every plan, including Free (3 watches, email alerts).

Know the next time it doesn't run

Know when your AI agents and automations didn't run. Free for 3 watches.

Start free