What it means

Cron starts each job with /bin/sh -c "<your command>". The shell searches the command in PATH, which cron sets to /usr/bin:/bin and nothing more. It does not read .bashrc, .profile or /etc/environment. On Debian and Ubuntu, /bin/sh is dash:

/bin/sh: 1: node: not found

On RHEL and Fedora, /bin/sh is bash, and the same failure reads /bin/sh: line 1: node: command not found. Either way, you only see it if the output reaches you: without a mail server cron logs No MTA installed instead.

Common causes

1. The command is outside /usr/bin and /bin. /usr/local/bin (aws, docker-compose, binaries you installed by hand), /usr/sbin, ~/.local/bin (pip install --user), ~/.composer/vendor/bin. Find where it lives:

command -v aws

2. A version manager. nvm, pyenv, rbenv and asdf add their directories to PATH from your shell profile, which cron never reads.

3. A bash builtin under dash. source does not exist in dash: /bin/sh: 1: source: not found. Same for [[, which fails with [[: not found.

4. Windows line endings. A crontab saved with CRLF gives a command ending in \r. The error prints as : not found, with the name hidden:

crontab -l | cat -A | grep '\^M'

5. Wrong user. The job runs under a user that lacks the tool, or whose home holds a different version.

How to fix

  1. Test the line with cron's environment before reinstalling it: env -i HOME="$HOME" PATH=/usr/bin:/bin /bin/sh -c 'aws s3 ls'
  2. Use absolute paths, from command -v: /usr/local/bin/aws s3 sync /backups s3://bucket.
  3. Or set PATH once at the top of the crontab: PATH=/usr/local/bin:/usr/bin:/bin. Cron does not expand $PATH there, so write the full list.
  4. For bash syntax, write . instead of source, or add SHELL=/bin/bash at the top of the crontab. Better, move the logic into a script with a #!/bin/bash line.
  5. Convert line endings with dos2unix and reinstall with crontab file.

Is it just you?

If the job fires at times you did not expect, paste the expression into the cron expression generator to see its next runs.

Get alerted next time

  • A command that is not found never reaches the ping chained after it with &&, so a healthcheck alerts once the grace period passes.
  • Use the healthcheck with the job's own cron expression and timezone, so every expected run is checked.
  • Call the URL with /fail appended from a wrapper script to alert at once, without waiting for the grace period.