Schedule and Debug Shell Scripts: cron, bash -x, and Portability
Edited by Dev Kilaru, Senior Data Analyst. How we make our content
Introduction
A script that you must remember to run is only half automated. On Linux and Unix the classic scheduler is cron, which runs commands at times you specify. Scripts that work perfectly in your terminal sometimes fail under cron because its environment is different, so this lesson covers those differences and how to find problems with tracing and inspection tools. It finishes with portability, since scripts written on one system often meet another, and with a checklist you can use for every script you write.
Prerequisites
- The backup and log report scripts from the previous lesson
- Access to a Linux system or WSL where cron is available
Step-by-Step Guide
Step 1: Find bugs with syntax checks and tracing
Start debugging with the cheapest tools. The command bash -n checks syntax without running anything, and bash -x prints every command after expansion with a leading plus sign, so you can see exactly which values were used. You can also turn tracing on for a section of a script with set -x and off again with set +x. Setting the PS4 variable adds the file name and line number to each trace line, which makes long scripts much easier to follow.
bash -n backup.sh # syntax check only
bash -x backup.sh src dest # trace every command
# trace only part of a script:
set -x
risky_command "$arg"
set +x
# more useful trace lines:
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}: 'Step 2: Inspect values while debugging
When a variable does not hold what you expect, print it. Surround the value with brackets and send the message to standard error, so hidden leading or trailing spaces and empty values are visible and the debug output stays out of data that the script prints. The declare -p command shows the value and type of variables, including every element of an array, which is the quickest way to check an array or an associative array.
declare -p name fruits ports # value and type of each variable
echo "DEBUG: src=[$src] dest=[$dest]" >&2 # brackets reveal stray spacesStep 3: Schedule a script with cron
The cron daemon reads a table of jobs, called a crontab, where each line has five time fields followed by the command. The fields are minute, hour, day of month, month, and day of week. An asterisk means every value, a slash sets a step, a dash makes a range, and a comma lists values. Edit your table with crontab -e and view it with crontab -l. A command's standard output is mailed to the user by default when mail is configured, so redirect it to a log file instead.
# minute hour day-of-month month day-of-week command
30 2 * * * /home/dev/bin/backup.sh /home/dev/data /home/dev/backups 7
0 */6 * * * /home/dev/bin/disk-check.sh 90
15 9 * * 1-5 /home/dev/bin/log-report.sh /var/log/nginx/access.log
# manage your jobs with:
# crontab -e edit
# crontab -l listStep 4: Handle the cron environment
Cron does not run your shell startup files, so a job sees a minimal environment: a short PATH, no custom variables, and your home directory as the working folder. This is the reason a script that works by hand fails under cron with command not found. Fix it by using absolute paths for scripts and files, setting PATH at the top of the crontab, and redirecting both output streams to a log file so you can read errors afterwards. Always test the exact command line from the crontab in a plain shell before relying on it.
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""
30 2 * * * /home/dev/bin/backup.sh /home/dev/data /home/dev/backups 7 >> /home/dev/logs/backup.log 2>&1Step 5: Prevent overlapping runs
If a job takes longer than the interval between runs, two copies may run at once and corrupt each other's work. The flock utility from util-linux takes a lock on a file and, with the -n option, exits immediately if another copy already holds it. You can wrap the command in the crontab, or take the lock inside the script. The flock command is common on Linux but not installed by default on macOS, where you can use a lock directory created with mkdir instead.
30 2 * * * flock -n /tmp/backup.lock /home/dev/bin/backup.sh /home/dev/data /home/dev/backups >> /home/dev/logs/backup.log 2>&1
# or inside a script:
exec 9> /tmp/myjob.lock
flock -n 9 || { echo "Already running"; exit 1; }Step 6: Know the alternatives to cron
On modern Linux servers, systemd timers are another way to schedule jobs and offer advantages such as logging through the journal, dependencies, and catching up on a run missed while the machine was off. You can inspect what is scheduled and read a service's output with a few commands. Cron remains the simplest choice for small user-level jobs, and it exists on almost every Unix system, so it is worth knowing well before moving on.
systemctl list-timers # show scheduled systemd timers
journalctl -u backup.service # read the logs of a serviceStep 7: Write portable scripts
Decide up front where a script must run. A script with a plain sh shebang should avoid Bash-only features such as arrays, the double-bracket test, here strings, and case conversion, because on Debian and Ubuntu sh is the smaller dash shell. If your script uses Bash features, say so in the shebang. Also expect differences between GNU tools on Linux and the BSD versions on macOS, for example in sed -i and date, and note that macOS ships Bash 3.2, which lacks associative arrays. When a script must run in both places, test on both or install GNU tools.
#!/bin/sh
# POSIX-friendly: avoid [[ ]], arrays, ${var^^}, <<<, and &>
if [ -f "$1" ]; then
echo "found"
fi
# GNU and BSD differences to remember:
# sed -i 's/a/b/' f (GNU) sed -i '' 's/a/b/' f (BSD/macOS)
# date -d "yesterday" (GNU) date -v-1d (BSD/macOS)
# touch -d "10 days ago" (GNU) touch -t 202611010000 f (BSD/macOS)Step 8: Use a checklist for every script
A short checklist catches most problems before they reach a server. Quote every expansion, validate the arguments, use local variables in functions, clean up with trap, send errors to standard error, return meaningful exit codes, and run ShellCheck before committing. Know when to stop, too. Shell is ideal for gluing commands together and for files and processes, but when a script needs complicated data structures, JSON handling, or many tests, a language such as Python is usually a better fit.
#!/usr/bin/env bash
set -euo pipefail
# 1. Quote every expansion: "$var" "${arr[@]}" "$(cmd)"
# 2. Validate arguments and print usage
# 3. Use local variables in functions
# 4. Use trap for cleanup
# 5. Send errors to stderr and return meaningful exit codes
# 6. Run shellcheck before you commitFAQs
- Why does my script work in the terminal but not in cron?
- Cron starts with a minimal environment, so the PATH is short, your profile is not loaded, and the working directory is your home folder. Use absolute paths, set PATH in the crontab, and redirect output to a log file to see the real error message.
- How do I test a destructive script safely?
- Add a dry-run mode that prints the commands it would run instead of running them, test on a copy of the data, and read the list produced by find before adding the delete option. Strict mode and ShellCheck help, but nothing replaces a rehearsal on throwaway data.
- Should I write automation in Bash or Python?
- Use Bash when the job mostly runs other programs, moves files, and processes text with pipes, and when it stays short. Use Python when you need structured data such as JSON, error handling beyond exit codes, reusable libraries, or tests. Many teams use both, with a small shell script that launches a Python program.
Summary
You learned to debug with bash -n, bash -x, and declare -p, schedule scripts with cron, handle cron's minimal environment, prevent overlapping runs with flock, write scripts that work across sh, Bash, Linux, and macOS, and review each script against a checklist. You now have a complete path from your first script to scheduled automation. A good next step is to automate a real task you do every week, and then share the script with a teammate for review.
Comments
Log in or create a free account to join the discussion.