cron Cheatsheet

Environment and PATH

Use this cron reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

Cron's PATH is not your PATH

This is the number one reason a command that works in your shell fails under cron. Cron does not read .bashrc, .bash_profile, .profile, /etc/profile, or anything else. It sets a minimal environment and runs the command.

VariableValue under cron
PATH/usr/bin:/bin, the compiled-in default in Vixie cron and cronie
SHELL/bin/sh, documented as /usr/bin/sh on recent Debian
HOMEfrom /etc/passwd
LOGNAMEfrom /etc/passwd, cannot be overridden
USERsame as LOGNAME on BSD-derived crons
MAILTOunset, so mail goes to the crontab owner
everything elsenot set

So none of these exist under cron: $PATH entries from /usr/local/bin, nvm, pyenv, rbenv, cargo, go, Homebrew's /opt/homebrew/bin, $AWS_PROFILE, $DISPLAY, $SSH_AUTH_SOCK, $LANG, or any variable you set in a dotfile.

# Fails: node is in ~/.nvm/versions/..., which cron's PATH does not include
0 3 * * * node /srv/app/task.js

# Works: absolute path
0 3 * * * /home/deploy/.nvm/versions/node/v22.11.0/bin/node /srv/app/task.js

Prove it in thirty seconds

Before debugging anything else, capture the actual environment your job sees.

* * * * * /usr/bin/env > /tmp/cron-env.txt 2>&1

Wait one minute, then compare.

diff <(sort /tmp/cron-env.txt) <(env | sort)

The diff is the complete list of things your job cannot rely on. Do the same for the working directory and the user:

* * * * * { id; pwd; echo "PATH=$PATH"; } >> /tmp/cron-probe.log 2>&1

The working directory is $HOME, not the directory the script lives in.

The three ways to fix PATH

# 1. Set PATH at the top of the crontab. Explicit, and it applies to every job.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 3 * * * backup.sh

# 2. Absolute paths everywhere. Verbose, but immune to PATH changes.
0 3 * * * /usr/local/bin/backup.sh

# 3. Set PATH inside the script, which keeps the crontab line short.
#!/bin/bash
export PATH=/usr/local/bin:/usr/bin:/bin

Note that PATH=$PATH:/opt/bin in a crontab does not work. Crontab environment lines are literal, with no variable substitution and no ~ expansion, so that sets PATH to the string $PATH:/opt/bin. Debian's crontab(5) documents this explicitly, along with the fact that C=$A $B does not reference A and B either. Write the full path list.

/etc/crontab and files in /etc/cron.d are not exempt, they just usually ship a PATH line already. Debian's is:

PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

SHELL, and why bash -lc is a trap

Cron runs the command with /bin/sh -c, and /bin/sh is dash on Debian and Ubuntu, not bash. Anything bash-specific breaks: [[ ]], arrays, source, local in some forms, brace expansion, $'...', and pipefail.

SHELL=/bin/bash
0 3 * * * [[ -f /tmp/flag ]] && /usr/local/bin/job.sh

The popular shortcut is to source a login shell:

0 3 * * * /bin/bash -lc 'nvm use 22 && node /srv/app/task.js'

That does work, and it is fragile in a specific way. It makes the job's behavior depend on the contents of a dotfile, so an unrelated edit to .bash_profile changes what the scheduled job does, and an interactive-only line such as a prompt or a tput call can hang or fail. It also means the job no longer runs reproducibly under a different user. Prefer setting the two or three variables the job actually needs, in the script itself.

For a version manager specifically, resolve the interpreter path once and hard code it, or use the manager's own shim directory:

0 3 * * * /home/deploy/.pyenv/shims/python /srv/app/task.py

Every path in the job must be absolute

The working directory is $HOME, so relative paths resolve somewhere you did not intend, and redirections create files there.

# Broken: relative input, relative output, relative script
0 3 * * * ./backup.sh data/ >> log.txt 2>&1

# Correct
0 3 * * * cd /srv/app && /srv/app/backup.sh /srv/app/data/ \
            >> /var/log/backup.log 2>&1

The cd matters for anything that reads a config file relative to the current directory, which includes docker compose, npm, make, terraform, and most language toolchains.

A script template that survives cron

Write the schedule as one crontab line calling one script, and put the environment in the script where it is version controlled and testable.

#!/bin/bash
set -euo pipefail

export PATH=/usr/local/bin:/usr/bin:/bin
export HOME=/home/deploy
export TZ=UTC
export LANG=C.UTF-8

cd /srv/app

exec >> /var/log/app-nightly.log 2>&1
echo "=== $(date -Is) starting ==="

./manage.py cleanup --older-than 30d

echo "=== $(date -Is) done ==="
0 3 * * * /srv/app/bin/nightly.sh

Then test it the way cron will run it, with an empty environment:

env -i /bin/sh -c /srv/app/bin/nightly.sh

That single command catches nearly every environment bug before it becomes a missed backup, because it reproduces exactly what cron does: no profile, no inherited variables, and /bin/sh as the shell.