How to write a safe shell script¶
You're writing a Bash script that will run unattended, from cron, a systemd timer or a configuration tool, and you want it to fail loudly instead of carrying on after an error, to cope with any file name, and to leave nothing behind when it stops. This guide builds such a script step by step, checks it with ShellCheck, and tests what happens when it succeeds and when it fails.
The example, archive-dir, packs a directory into a dated .tar.gz file in a destination directory.
The archive appears in the destination only when it's complete: a failed run leaves neither a partial archive nor a temporary file.
Before you begin¶
- Bash, which every distribution covered here installs by default (version 5.2 or later).
sudo, to install the script in/usr/local/binand to install ShellCheck.
Steps¶
-
Start the script with a Bash shebang and strict mode:
set -e(errexit) makes the script exit as soon as a command fails, instead of running the next line on top of a failure.set -u(nounset) makes using an unset variable an error, so a typo such as$dest_dristops the script instead of expanding to nothing (and turningrm -rf "$dest_dri/"intorm -rf /).set -o pipefailmakes a pipeline fail if any command in it fails, not just the last one. Without it,tar -c … | gzip > filereports success even whentarfailed.
Use
#!/bin/bash, not#!/bin/sh, when the script uses Bash features such as[[ ]]and arrays. On Debian and Ubuntu,/bin/shisdash, which doesn't support[[ ]], arrays and other Bash features. -
Know where
set -edoesn't help, and write those places explicitly.errexitis a safety net, not error handling. It's ignored, or behaves surprisingly, in these cases:Construct What happens Write instead A command in if,while,until, or before&&or\|\|A failure is treated as "false", never as an error, including inside functions called there Test the result yourself: if ! cmd; then die "…"; filocal out=$(cmd)localsucceeds, hiding the failure ofcmdlocal outon one line,out=$(cmd)on the next(( count++ ))whencountis 0The arithmetic result is 0, so the command "fails" and the script exits count=$(( count + 1 ))cmd \| head -n 1underpipefailcmdmay be killed bySIGPIPEwhenheadexits, so the pipeline fails (status 141)Capture first: out=$(cmd), then process"$out"grepthat finds nothingExit status 1 stops the script, though "no match" may be fine grep … \|\| true, or test it in anifAn optional argument $1set -ustops the script when it's missingGive a default: ${1:-}or${1:-default} -
Quote every expansion. Unquoted,
$fileis split at spaces, tabs and newlines and then expanded as a glob, so a file calledQ3 summary.txtbecomes two arguments. Write"$file","$@","${files[@]}"and"$(command)". Put--before file name arguments, so a name starting with-isn't read as an option:rm -- "$file". Inside[[ ]]and on the right-hand side of a plain assignment, word splitting doesn't happen, so quotes are optional there, with one exception. In[[ ]], an unquoted right-hand side of==or!=is a glob pattern, and of=~a regular expression: quote it when you mean a literal string.[[ $name == *.txt ]]matches any name ending in.txt,[[ $name == "*.txt" ]]only the name*.txt. -
Create temporary files with
mktemp, and remove them with atrap. Fixed names like/tmp/myscript.tmpcollide when two copies run at once and can be pre-created by another user.mktempcreates a new file (or, with-d, a directory) with a random name, readable only by you, and prints its name. A trap onEXITruns whenever the script ends: normally, throughset -e, or throughexit. Bash also runs it when a signal such asSIGTERMends the script, but other shells, such as dash, don't. Traps onINTandTERMmake the signal case explicit and portable: they turn Ctrl+C andkillinto an ordinaryexitwith a defined status (130 and 143, the statuses a shell reports for those signals), which runs theEXITtrap in any shell:Set the trap straight after
mktemp, before anything that can fail. To make a result appear atomically, create the temporary file in the destination directory (mktemp "$dest/.name.XXXXXX") andmvit into place at the end: a rename within one filesystem is atomic, so readers see either the old state or the complete new file, never half a file. -
Check arguments and preconditions at the start, and report errors on standard error with a non-zero exit status:
die() { printf '%s: %s\n' "${0##*/}" "$*" >&2; exit 1; } [[ -d $src ]] || die "not a directory: $src"Use exit status 2 for wrong usage and 1 for other failures, as many standard commands do. Callers such as systemd and cron only see the exit status, so it has to be right. When
set -estops the script, though, the script exits with the failing command's own status, which can be 2 as well: GNUtarexits with 2 when it can't read a file, as in Verify step 5. Where a caller must tell the cases apart, handle that command yourself:tar … || die "tar failed". -
Stop two runs from overlapping. cron starts a job at every scheduled time, even if the previous run is still going, and two copies writing to the same place can collide (a systemd timer doesn't start its service again while it's still running). Take a lock at the start:
exec 9< "$dest"opens the destination directory for reading on file descriptor 9 for the rest of the script, andflock -n 9(from util-linux) takes an exclusive lock on it, or fails at once if another process holds one. The kernel releases the lock when the script ends, however it ends, so there's no lock file to clean up. Locking the destination allows one run per destination at a time; to allow only one run of the script in total, lock a fixed file such as the script itself (exec 9< "$0"). -
Put it together. Create
/usr/local/bin/archive-dir:#!/bin/bash # archive-dir: pack SOURCE_DIR into DEST_DIR/<name>-<timestamp>.tar.gz. # The archive appears in DEST_DIR only when it is complete. set -euo pipefail prog=${0##*/} usage() { printf 'Usage: %s SOURCE_DIR DEST_DIR\n' "$prog" >&2; exit 2; } die() { printf '%s: %s\n' "$prog" "$*" >&2; exit 1; } [[ $# -eq 2 ]] || usage src=${1%/} dest=${2%/} [[ -d $src ]] || die "not a directory: $src" [[ -d $dest && -w $dest ]] || die "not a writable directory: $dest" # Allow one run per destination at a time: lock DEST_DIR until the script ends. exec 9< "$dest" flock -n 9 || die "another run is using $dest" name="$(basename -- "$src")-$(date +%Y%m%d-%H%M%S).tar.gz" # Write to a temporary file next to the final one, so the last step is an atomic rename. tmp=$(mktemp -- "$dest/.$name.XXXXXX") trap 'rm -f -- "$tmp"' EXIT trap 'exit 130' INT trap 'exit 143' TERM tar -C "$(dirname -- "$src")" -czf "$tmp" -- "$(basename -- "$src")" tar -tzf "$tmp" > /dev/null # fail now if the archive can't be read back chmod 0644 -- "$tmp" mv -- "$tmp" "$dest/$name" printf '%s\n' "$dest/$name"Make it executable:
mktempcreates files with mode 0600; thechmodgives the finished archive normal permissions.${0##*/}is the script's name without its directory, and${1%/}removes a trailing slash from the argument. -
Install ShellCheck and run it on the script:
ShellCheck is in EPEL, not in the distribution's own repositories:
On RHEL itself, enable EPEL as described in the EPEL documentation, which also needs the CodeReady Builder repository.
No output and exit status 0 mean ShellCheck found nothing to report. To see what it catches, check a version with an unquoted variable:
$ printf '#!/bin/bash\nsrc=$1\ntar -czf out.tar.gz $src\n' > /tmp/unquoted.sh $ shellcheck /tmp/unquoted.sh In /tmp/unquoted.sh line 3: tar -czf out.tar.gz $src ^--^ SC2086 (info): Double quote to prevent globbing and word splitting. Did you mean: tar -czf out.tar.gz "$src"Each finding has a code such as
SC2086, described on the ShellCheck wiki. Fix the finding rather than silencing it; where a finding is genuinely wrong for your script, disable that one code on the line above with a# shellcheck disable=SC2086comment, and say why.
Verify¶
Test the script on the path that should succeed, then on paths that should fail.
-
Create a source directory with a file name containing spaces, and a destination:
-
Run the script and list the archive's contents:
$ archive-dir ~/reports ~/archives /home/labuser/archives/reports-20261107-141502.tar.gz $ tar -tzf ~/archives/reports-*.tar.gz reports/ reports/Q3 summary.txt $ ls -lA ~/archives total 4 -rw-r--r-- 1 labuser labuser 170 Nov 7 14:15 reports-20261107-141502.tar.gzThe file name with a space survived intact, and only the finished archive is in the destination.
-
Check the usage error. The script exits with status 2:
-
Check that a second run is refused while another holds the lock.
flockused as a command takes the same lock, then runs the script while holding it:$ flock ~/archives archive-dir ~/reports ~/archives archive-dir: another run is using /home/labuser/archivesThe script stopped with status 1 before creating anything.
-
Make the archive step itself fail, and check that the script stops and cleans up. A file the script can't read makes
tarskip it:tarfinishes writing the archive but exits with a non-zero status:$ archive-dir ~/reports ~/archives tar: reports/secret.txt: Cannot open: Permission denied tar: Exiting with failure status due to previous errors$ ls -lA ~/archives total 4 -rw-r--r-- 1 labuser labuser 170 Nov 7 14:15 reports-20261107-141502.tar.gzset -estopped the script at the failingtar, beforemv, withtar's exit status 2, and theEXITtrap removed the temporary file: there is still only the archive from the successful run. -
Check that an interrupted run cleans up too. Start the script on a large source in the background, stop it with
SIGTERM, and look at the exit status and the destination:$ chmod 600 ~/reports/secret.txt $ head -c 100M /dev/urandom > ~/reports/big.bin $ archive-dir ~/reports ~/archives & sleep 1; kill -TERM $!; wait $! || echo "exit status: $?" exit status: 143 $ ls -lA ~/archives total 4 -rw-r--r-- 1 labuser labuser 170 Nov 7 14:15 reports-20261107-141502.tar.gzAn interactive shell also prints job-control lines such as
[1] 2345, not shown here. TheTERMtrap ended the script withexit 143, and thatexitran theEXITtrap, which removed the temporary file. Bash runs a trap only after the command in the foreground finishes, so the script stops once the runningtarhas ended, not at the moment the signal arrives.
Remove the test data when you're done:
Troubleshooting¶
The script stops without an error message.
A command failed silently (many commands, such as grep or test, report "false" only through their exit status).
Run the script with tracing to see the last command that ran: bash -x /usr/local/bin/archive-dir ….
To report the failing line automatically, change the strict-mode line to set -Eeuo pipefail and add trap 'printf "%s: line %d: exit %d\n" "$prog" "$LINENO" "$?" >&2' ERR after it.
Without -E (errtrace), functions, command substitutions and commands in ( … ) don't inherit the ERR trap, so failures inside them aren't reported.
unbound variable for a variable that may legitimately be unset.
That's set -u working as intended.
Give the variable a default where it's used, for example "${EDITOR:-vi}", or test it with [[ -n ${VAR:-} ]].
[[: not found, Syntax error: "(" unexpected or similar.
The script is running under sh rather than Bash: it was started as sh script, or its shebang is #!/bin/sh.
Run it as ./script or bash script, and keep the #!/bin/bash line.
The script works in your shell but not from cron or a timer. Scheduled jobs start with a minimal environment and a different working directory. Use absolute paths for files, set any variables the script needs inside it, and don't rely on aliases or functions from your shell profile.
Related¶
- How-to: How to schedule a job with cron
- How-to: How to schedule a job with a systemd timer
- How-to: How to run a script as a service
- Explanation: Idempotency and configuration management
- Explanation: cron and systemd timers
- Reference: crontab syntax