# Shell Script for Cron Job Best Practices: Exit Codes, Traps, Logging

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/cron-shell-script-best-practices

> Write cron shell scripts that fail loudly: pick the shell, use set -e and pipefail correctly, keep exit codes, trap signals, and log every run.

**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](https://cronradar.com/blog/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)](https://man7.org/linux/man-pages/man2/execve.2.html)), 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](https://sources.debian.org/src/dash/0.5.12-12/debian/dash.links/) and [Ubuntu](https://git.launchpad.net/ubuntu/+source/dash/tree/debian/dash.links?h=applied/ubuntu/noble); Debian's [changelog](https://sources.debian.org/src/dash/0.5.12-12/debian/changelog/) and [versions](https://sources.debian.org/src/dash/); dash manuals for [Debian 13](https://manpages.debian.org/trixie/dash/dash.1.en.html), [Ubuntu 24.04](https://manpages.ubuntu.com/manpages/noble/man1/dash.1.html), and [Ubuntu 26.04](https://manpages.ubuntu.com/manpages/resolute/man1/dash.1.html); bash packages for [Fedora](https://src.fedoraproject.org/rpms/bash/blob/rawhide/f/bash.spec) and [CentOS Stream 10](https://gitlab.com/redhat/centos-stream/rpms/bash/-/blob/c10s/bash.spec); Alpine's [BusyBox config](https://gitlab.alpinelinux.org/alpine/aports/-/blob/3.24-stable/main/busybox/busyboxconfig) and [ash.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/shell/ash.c#L360-L362).

POSIX.1-2024 added `pipefail` to [`set`](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#tag_19_26), 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](https://git.kernel.org/pub/scm/utils/dash/dash.git/tree/src/options.c?h=v0.5.12#n269)). Bash invoked as `sh` enters POSIX mode ([Bash manual](https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html)) 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](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#tag_19_26) and the [Bash manual](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html) both list exceptions, and each one shows up in cron scripts:

- **Conditions.** A command tested by `if`, `elif`, `while`, or `until`, or negated with `!`, can fail without stopping anything.
- **`&&` and `||` lists, except the last command.** In `dump_db && upload_dump`, a failed `dump_db` skips 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 of `sync_orders` after 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](https://www.gnu.org/software/bash/manual/html_node/Simple-Command-Expansion.html)), but `echo "inbox: $(ls /srv/inbox)"` doesn't. Inside the substitution, POSIX keeps `-e` and bash outside POSIX mode clears it ([Bash manual](https://www.gnu.org/software/bash/manual/html_node/Command-Execution-Environment.html)), so `files=$(cd /srv/inbox; ls)` lists the wrong directory when the `cd` fails, unless `shopt -s inherit_errexit` ([bash 4.4](https://cgit.git.savannah.gnu.org/cgit/bash.git/tree/NEWS?h=bash-5.3#n856) and later) is set.
- **`local` and `export`.** `local count=$(psql -tAc 'select count(*) from orders')` succeeds when `psql` fails, because [`local`](https://www.gnu.org/software/bash/manual/html_node/Bash-Builtins.html) returns its own status, as does POSIX [`export`](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#tag_19_23). Declare the variable, then assign it on its own line ([SC2155](https://www.shellcheck.net/wiki/SC2155)).
- **Every command in a pipeline but the last,** unless `pipefail` is on, as below.

The function case surprises people most:

```bash
#!/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
fi
```

When 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)](https://man7.org/linux/man-pages/man7/pipe.7.html)), 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)](https://manpages.debian.org/trixie/cron/cron.8.en.html)), systemd marks a service failed on any non-zero status that `SuccessExitStatus=` doesn't list ([systemd.service(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html)), 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](https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html)). Without `set -e`, a script ending in `rm -f "$tmp"` reports success after any earlier failure, and one ending in `[ -n "$VERBOSE" ] && echo done` reports failure after a success.
- **Save `$?` on the next line** with `rc=$?`. Every command replaces it, `echo` and `[` included.
- **`exit` without a number uses `$?`,** and inside a trap action, the status from just before the trap ([POSIX](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#tag_19_22)).
- **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](https://man7.org/linux/man-pages/man3/sysexits.h.3head.html): 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](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#tag_19_08_02), 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](https://cronradar.com/blog/scheduled-job-silent-success).

## 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)](https://man7.org/linux/man-pages/man1/mktemp.1.html)):

```bash
workdir=$(mktemp -d /srv/reports/.build.XXXXXX)
trap 'rm -rf -- "$workdir"' EXIT
```

Signals are where shells differ. POSIX says the `EXIT` trap runs on a normal exit and may run when a signal kills the shell ([trap](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#tag_19_29)). Bash 5.3, once an `EXIT` trap is set, runs it before dying of an untrapped INT or TERM ([trap.c](https://cgit.git.savannah.gnu.org/cgit/bash.git/tree/trap.c?h=bash-5.3#n838), [sig.c](https://cgit.git.savannah.gnu.org/cgit/bash.git/tree/sig.c?h=bash-5.3#n641)). Dash leaves TERM at its default action and re-raises INT, so it dies without running it ([trap.c](https://sources.debian.org/src/dash/0.5.12-12/src/trap.c/#L177), [error.c](https://sources.debian.org/src/dash/0.5.12-12/src/error.c/#L105)). Trap both, and exit with 128 plus the signal number:

```bash
trap 'exit 130' INT
trap 'exit 143' TERM
```

POSIX 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)](https://man7.org/linux/man-pages/man7/signal.7.html)), so the OOM killer or `timeout -k` leaves 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](https://www.gnu.org/software/bash/manual/html_node/Signals.html)). [Cron job timeout](https://cronradar.com/blog/cron-job-timeout) shows the background-and-`wait` pattern that avoids it.
- **`set -e` stays on inside the trap.** When a cleanup command fails, bash ends the handler there and exits with that command's status ([trap.c](https://cgit.git.savannah.gnu.org/cgit/bash.git/tree/trap.c?h=bash-5.3#n1072)), skipping the rest. Start a cleanup function with `rc=$?`, then `set +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](https://www.gnu.org/software/bash/manual/html_node/Bourne-Shell-Builtins.html)). 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:

```bash
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"](https://cronradar.com/blog/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](https://man7.org/linux/man-pages/man1/logger.1.html): `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](https://cronradar.com/blog/scheduled-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)](https://manpages.debian.org/trixie/logrotate/logrotate.8.en.html) (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](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#tag_19_07_03)), so each write goes to the current end ([open(2)](https://man7.org/linux/man-pages/man2/open.2.html)), 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:

```text
/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 `PATH` and `umask` at the top.** `umask 027` keeps 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, so `cd /srv/app || exit 1` before anything relative.
- **Quote every expansion.** Unquoted, `$file` splits at spaces and expands `*` ([field splitting](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#tag_19_06_05)). Before deleting, `${var:?}` stops the script if the variable is empty or unset ([parameter expansion](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#tag_19_06_02)), so `rm -rf -- "${staging_dir:?}"/*` can't become `rm -rf /*`. `--` ends option parsing, so a file named `-n` isn'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](https://cronradar.com/blog/prevent-overlapping-cron-jobs) explains both.
- **Limit runtime** with GNU `timeout`, so a hung run ends with status 124.
- **Run [ShellCheck](https://www.shellcheck.net/)** (0.11.0, checked October 24, 2026). It flags unquoted expansions ([SC2086](https://www.shellcheck.net/wiki/SC2086)), an unchecked `cd` ([SC2164](https://www.shellcheck.net/wiki/SC2164)), and the `local` masking 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](https://cronradar.com/blog/cron-dead-mans-switch) pattern. For POSIX `sh`, change the first `set` line to `set -eu` and drop the `shopt` line and the `ERR` trap.

```bash
#!/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.pdf
```

The 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, `|| true` keeps `set -e` and the `ERR` trap 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 `EXIT` trap sends `complete` or `fail` whether the run finished, hit `set -e`, called `exit`, timed out, or received INT or TERM. A `fail` alerts 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 `critical` once its expected time plus the grace period passes. A killed run's `start` is flagged as stuck only if the job's next scheduled time plus grace passes with no other check-in, and the next night's `start` resets that, so a single killed run shows up only as a log entry with no finish line.
- **The schedule is set once.** `schedule` and `gracePeriod` apply 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 reads `15 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.
