Shell Script for Cron Job Best Practices: Exit Codes, Traps, Logging
Contents
A shell script for a cron job should fail loudly and leave evidence: name the shell it needs in the shebang, turn on set -eu (plus pipefail where the shell has it), check the commands that matter explicitly, exit with the job's real status, clean up in an EXIT trap, and write its own timestamped log. Cron sees only a run's exit status and output; recording what failed and cleaning up after it is the script's job. Behavior below comes from bash 5.3, dash 0.5.12 as Debian 13 and Ubuntu 24.04 build it, BusyBox 1.37.0, and POSIX.1-2024, checked October 24, 2026. The last section combines all of it into one template.
Choose the Shell in the Shebang#
Cron runs a crontab entry's command with /bin/sh unless the crontab sets SHELL (cron environment variables covers the rest). When that command is your script's path, the kernel reads the script's first line and starts the interpreter it names (execve(2)), so the shebang picks the shell, not cron. Two things defeat it: a script without execute permission fails with status 126, and an entry written as sh /srv/app/bin/nightly-report runs the file with sh and treats the shebang as a comment.
What #!/bin/sh gets you depends on the distribution:
| System | /bin/sh is |
set -o pipefail |
|---|---|---|
| Debian 13 | dash 0.5.12-12 | Yes, since Debian's 0.5.12-7 |
| Debian 12 | dash 0.5.12-2 | No |
| Ubuntu 24.04 | dash 0.5.12-6ubuntu5 | No |
| Ubuntu 26.04 | dash 0.5.12-12ubuntu3 | Yes |
| Fedora, RHEL 10 | bash, in POSIX mode | Yes |
| Alpine 3.24 | BusyBox ash 1.37.0 | Yes |
Sources: dash's /usr/bin/sh link on Debian and Ubuntu; Debian's changelog and versions; dash manuals for Debian 13, Ubuntu 24.04, and Ubuntu 26.04; bash packages for Fedora and CentOS Stream 10; Alpine's BusyBox config and ash.c.
POSIX.1-2024 added pipefail to set, but a shell that predates it stops the script at that line: dash 0.5.12 without Debian's patch fails with Illegal option -o pipefail (options.c). Bash invoked as sh enters POSIX mode (Bash manual) yet still accepts bash syntax, so a #!/bin/sh script using [[, arrays, or pipefail can pass every test on Fedora and fail on Ubuntu 24.04.
When you want pipefail everywhere, inherit_errexit, an ERR trap, or arrays, require bash: write #!/bin/bash, and on Alpine run apk add bash first. #!/usr/bin/env bash searches PATH, and cronie's and Debian's default PATH leaves out /usr/local/bin.
Use set -e, and Know Where It Doesn't Apply#
set -e makes the shell exit as soon as a command fails, with that command's status. POSIX and the Bash manual both list exceptions, and each one shows up in cron scripts:
- Conditions. A command tested by
if,elif,while, oruntil, or negated with!, can fail without stopping anything. &&and||lists, except the last command. Indump_db && upload_dump, a faileddump_dbskips the upload, and the script carries on with the next line.- Every command inside a function called from those places. The Bash manual says none of them "will be affected by the -e setting."
sync_orders || log "sync failed"runs every line ofsync_ordersafter its first step fails and returns the status of its last line. - Some command substitutions. An assignment takes the status of its last substitution, so
files=$(ls /srv/inbox)exits on failure (Bash manual), butecho "inbox: $(ls /srv/inbox)"doesn't. Inside the substitution, POSIX keeps-eand bash outside POSIX mode clears it (Bash manual), sofiles=$(cd /srv/inbox; ls)lists the wrong directory when thecdfails, unlessshopt -s inherit_errexit(bash 4.4 and later) is set. localandexport.local count=$(psql -tAc 'select count(*) from orders')succeeds whenpsqlfails, becauselocalreturns its own status, as does POSIXexport. Declare the variable, then assign it on its own line (SC2155).- Every command in a pipeline but the last, unless
pipefailis on, as below.
The function case surprises people most:
#!/bin/bash
set -e
sync_orders() {
curl --fail --silent --show-error --max-time 60 \
--output /srv/app/orders.json https://erp.example.com/api/orders
/srv/app/bin/import-orders /srv/app/orders.json # runs even after curl fails
}
if ! sync_orders; then
echo "order sync failed" >&2
exit 1
fiWhen the download fails, the import runs on whatever file is already there; if that succeeds, the function returns 0 and nothing reports the failure. So treat set -e as a backstop, and check each command whose failure must stop the run where it runs, as in curl ... || return 1. That check survives being moved into a function or a condition.
nounset and pipefail#
set -u makes expanding an unset variable an error that ends a non-interactive shell, so a typo such as $BACUP_DIR stops the run instead of turning into an empty string. Give optional values a default, ${RETENTION_DAYS:-14}, and read arguments as ${1:-}.
pipefail gives a pipeline the status of its rightmost failing command instead of its last command. Without it, /srv/app/bin/export-orders | gzip > /srv/exports/orders.csv.gz succeeds whenever gzip does. It has a catch: when a reader such as head exits early, the writer can be killed by SIGPIPE on its next write (pipe(7)), and the pipeline fails with 141. Where the shell has no pipefail, write to a file, check the command, and compress in a second step.
Exit With the Status the Job Earned#
Debian's cron logs non-zero exits only when its -L level includes 4 (cron(8)), systemd marks a service failed on any non-zero status that SuccessExitStatus= doesn't list (systemd.service(5)), and a monitoring wrapper turns it into a failure report. They see only the final status, so make it the job's:
- Without
exit, a script returns the status of the last command it ran (Bash manual). Withoutset -e, a script ending inrm -f "$tmp"reports success after any earlier failure, and one ending in[ -n "$VERBOSE" ] && echo donereports failure after a success. - Save
$?on the next line withrc=$?. Every command replaces it,echoand[included. exitwithout a number uses$?, and inside a trap action, the status from just before the trap (POSIX).- Keep your own codes between 1 and 125. Statuses run from 0 to 255, and the shell uses values above 125 specially.
| Status | Meaning |
|---|---|
| 0 | Success |
| 1 | General failure |
| 2 | Incorrect use of a bash builtin, such as an invalid option |
| 64-78 | sysexits.h: 64 EX_USAGE, 75 EX_TEMPFAIL, 78 EX_CONFIG |
| 124 | GNU timeout stopped the command at its limit |
| 126 | Found but couldn't be run, such as a script without execute permission |
| 127 | Command not found |
| 128+N | Ended by signal N: 130 for INT, 137 for KILL, 143 for TERM |
126 and 127 come from POSIX, which requires only "greater than 128" for signals; 128+N is bash's rule. sysexits.h defines EX_TEMPFAIL as a temporary failure where "the request should be reattempted later," which fits a run that skipped because the previous one still holds its lock; systemd can treat it as success with SuccessExitStatus=75.
A 0 means only that nothing reported failure, and a job can exit 0 without doing its work.
Clean Up With Traps#
trap 'commands' EXIT runs its commands when the shell exits: at the end of the script, at exit, or when set -e or set -u ends it. Pair it with mktemp, which creates a file or directory under an unpredictable name that only its owner can read (mktemp(1)):
workdir=$(mktemp -d /srv/reports/.build.XXXXXX)
trap 'rm -rf -- "$workdir"' EXITSignals are where shells differ. POSIX says the EXIT trap runs on a normal exit and may run when a signal kills the shell (trap). Bash 5.3, once an EXIT trap is set, runs it before dying of an untrapped INT or TERM (trap.c, sig.c). Dash leaves TERM at its default action and re-raises INT, so it dies without running it (trap.c, error.c). Trap both, and exit with 128 plus the signal number:
trap 'exit 130' INT
trap 'exit 143' TERMPOSIX requires exit to run the EXIT trap, so the cleanup runs in any shell, and $? there is 130 or 143, which tells it why. Three limits remain:
- SIGKILL can't be caught (signal(7)), so the OOM killer or
timeout -kleaves the temp directory behind. Keep temp files in one directory the job owns, where a leftover is easy to spot. - A trapped signal waits for the foreground command. If only the script receives TERM, the trap runs once the current command finishes (Bash manual). Cron job timeout shows the background-and-
waitpattern that avoids it. set -estays on inside the trap. When a cleanup command fails, bash ends the handler there and exits with that command's status (trap.c), skipping the rest. Start a cleanup function withrc=$?, thenset +e.
Bash also has an ERR trap, run whenever a command fails under the same rules as set -e, before the shell exits (Bash manual). With set -E, functions, command substitutions, and subshells inherit it, and $BASH_COMMAND holds the command that failed. Dash has no ERR trap, and POSIX defines none.
Log Every Run With a Timestamp and Run ID#
Redirect inside the script, so the crontab entry stays a bare path and a manual run logs the same way:
exec >>/var/log/app/nightly-report.log 2>&1
run_id="nightly-report-$(date -u +%Y%m%dT%H%M%SZ)-$$"
log() { printf '%s %s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$run_id" "$*"; }
log "starting"exec with only redirections applies them to the shell itself, so everything the script and its commands print after that line goes to the file. The 2>&1 matters: without it, error messages, which programs write to stderr, still go to cron, which mails them or, with no mailer, may discard them (cron "No MTA installed"). So does anything printed before the exec, such as an error opening the file. log stamps the script's own lines in UTC, which never repeats an hour at a daylight saving change; the commands' output lands unstamped between them. The run ID, the start time plus the shell's PID, ties one run's lines together: grep nightly-report-20261024T031500Z-48213 returns exactly that run.
For a summary line in the system log, use logger: logger -t nightly-report -p user.err "run $run_id failed with exit code $rc". -t sets the tag that journalctl -t nightly-report matches, and -p the priority, user.notice by default. Cron job logging covers what else each run should record and how to keep secrets out of the log.
Rotate Without Losing Lines#
Because each run opens the file afresh with >>, logrotate's default rotation is safe: it renames the file and creates a new one, a run in progress keeps writing to the renamed file, and the next run opens the new name, with no copytruncate or postrotate signal. copytruncate is for programs that can't reopen their log, and logrotate(8) (3.22.0 on Debian 13, 3.21.0 on Ubuntu 24.04) warns that it can lose lines written between its copy and truncate. If you use it anyway, append: >> opens the file with O_APPEND (POSIX), so each write goes to the current end (open(2)), while a > writer keeps its old offset and leaves the truncated file starting with null bytes. With compress, add delaycompress, meant for a program that keeps writing to the previous file for a while:
/var/log/app/nightly-report.log {
weekly
rotate 8
missingok
notifempty
compress
delaycompress
create 0640 deploy deploy
su deploy deploy
}Save it as /etc/logrotate.d/nightly-report. su rotates as the job's account, which the manual recommends for directories unprivileged users control.
Guard Against the Environment and Your Own Mistakes#
- Set
PATHandumaskat the top.umask 027keeps the log, lock, and output files the job creates unreadable by other accounts, whatever mask the cron daemon passed down. - Use absolute paths, and check
cd. Cron starts jobs in the account's home directory, socd /srv/app || exit 1before anything relative. - Quote every expansion. Unquoted,
$filesplits at spaces and expands*(field splitting). Before deleting,${var:?}stops the script if the variable is empty or unset (parameter expansion), sorm -rf -- "${staging_dir:?}"/*can't becomerm -rf /*.--ends option parsing, so a file named-nisn't read as an option. - Refuse to run as root unless the job needs it. A root run leaves root-owned log and lock files that the job's account can't open next time, and gives every bug full privileges.
- Lock against overlap with
flock -n, and take the lock before reporting anything to a monitor; preventing overlapping cron jobs explains both. - Limit runtime with GNU
timeout, so a hung run ends with status 124. - Run ShellCheck (0.11.0, checked October 24, 2026). It flags unquoted expansions (SC2086), an unchecked
cd(SC2164), and thelocalmasking above.
A Complete Template With Check-Ins#
This bash script combines everything above and reports each run to CronRadar. There is no CronRadar SDK for shell scripts or crontab and no auto-discovery, so each job reports with its own HTTP check-in, the dead man's switch pattern. For POSIX sh, change the first set line to set -eu and drop the shopt line and the ERR trap.
#!/bin/bash
# /srv/app/bin/nightly-report: runs from the deploy account's crontab
set -Eeuo pipefail
shopt -s inherit_errexit
PATH=/usr/local/bin:/usr/bin:/bin
export PATH
umask 027
job=nightly-report
log_file=/var/log/app/$job.log
lock_file=/var/lib/app/$job.lock
url="https://cron.life/api/v1/ping/$job"
if [ "$(id -u)" -eq 0 ]; then
echo "$job: refusing to run as root" >&2
exit 1
fi
exec >>"$log_file" 2>&1
run_id="$job-$(date -u +%Y%m%dT%H%M%SZ)-$$"
log() { printf '%s %s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$run_id" "$*"; }
trap 'log "failed with status $?: $BASH_COMMAND"' ERR
exec 9>"$lock_file"
if ! flock -n 9; then
log "skipped: the previous run still holds the lock"
exit 75
fi
# The application's API key, alone in a file only deploy can read
CRONRADAR_API_KEY=$(cat /etc/app/cronradar-key) || CRONRADAR_API_KEY=
report() {
event=$1
shift
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=15 3 * * *" \
--data-urlencode "gracePeriod=900" \
--data-urlencode "run_id=$run_id" \
"$@" "$url/$event" || true
}
workdir=
finish() {
rc=$?
set +e
if [ -n "$workdir" ]; then rm -rf -- "$workdir"; fi
case $rc in
0) report complete ;;
124) report fail --data-urlencode "message=$job hit its 30-minute timeout" ;;
130 | 143) report fail --data-urlencode "message=$job stopped by signal $((rc - 128))" ;;
*) report fail --data-urlencode "message=$job exited with code $rc" ;;
esac
if [ "$rc" -eq 0 ]; then
logger -t "$job" "run $run_id succeeded"
else
logger -t "$job" -p user.err "run $run_id failed with exit code $rc"
fi
log "finished with exit code $rc"
exit "$rc"
}
trap finish EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
log "starting"
report start
cd /srv/app || exit 1
workdir=$(mktemp -d /srv/reports/.build.XXXXXX)
timeout -k 1m 30m ./bin/build-report --out "$workdir"
mv -- "$workdir/report.pdf" /srv/reports/daily.pdfThe crontab entry is the bare path: 15 3 * * * /srv/app/bin/nightly-report. What the script guarantees, and what it can't:
- Monitoring can't fail the job. Each check-in gives up after five seconds and ignores its own errors,
|| truekeepsset -eand theERRtrap out of it, a missing key file only leaves an error in the log, and the script exits with the job's own status. - Every ending after the lock is reported. The
EXITtrap sendscompleteorfailwhether the run finished, hitset -e, calledexit, timed out, or received INT or TERM. Afailalerts immediately; its message gives the reason, never logs or secrets. - Skips stay quiet. The lock comes before
start, so a skipped run exits 75 without sending anything. - Some failures can't report. Refusing root and an unwritable log happen before the trap exists, and SIGKILL runs no trap. A run that sends nothing turns the monitor
criticalonce its expected time plus the grace period passes. A killed run'sstartis flagged as stuck only if the job's next scheduled time plus grace passes with no other check-in, and the next night'sstartresets that, so a single killed run shows up only as a log entry with no finish line. - The schedule is set once.
scheduleandgracePeriodapply only when a check-in creates the monitor; change them later in the monitor's settings. Sending them every time means a deleted monitor is recreated by the next run. CronRadar reads15 3 * * *in UTC, so send the UTC time if the server isn't on UTC.
Frequently Asked Questions#
Should I use set -e in cron scripts?#
Yes, with set -u and, where the shell supports it, pipefail, but as a backstop. It doesn't apply in conditions, && and || lists, functions called from either, or some command substitutions, so check critical commands explicitly with || exit 1.
Why does my cron script exit 0 even when a command fails?#
A script exits with the status of its last command, so an earlier failure is lost unless something stops the script there. Usual causes: no set -e, a failure early in a pipeline without pipefail, local var=$(command), and a function called from an if or ||. ShellCheck flags the local case as SC2155.
How do I log cron script output with timestamps?#
Put exec >>/var/log/app/job.log 2>&1 near the top of the script, and write its own messages through a function that prefixes date -u +%Y-%m-%dT%H:%M:%SZ and a run ID. For a summary line in the system journal, use logger -t job.
Does cron use bash or sh?#
Cron runs the crontab line with /bin/sh unless the crontab sets SHELL. That's dash on Debian and Ubuntu, bash in POSIX mode on Fedora and RHEL, and BusyBox ash on Alpine. A script started by its path runs under the interpreter in its shebang, as long as it's executable and not started as sh script.
How do I clean up temp files when a cron script fails?#
Create them with mktemp and remove them in an EXIT trap, which runs on normal exits, exit, and set -e failures. Add trap 'exit 130' INT and trap 'exit 143' TERM so signals reach the same cleanup in every shell. Nothing runs on SIGKILL.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.