Skip to content

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/bin and to install ShellCheck.

Steps

  1. Start the script with a Bash shebang and strict mode:

    #!/bin/bash
    set -euo pipefail
    
    • 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_dri stops the script instead of expanding to nothing (and turning rm -rf "$dest_dri/" into rm -rf /).
    • set -o pipefail makes a pipeline fail if any command in it fails, not just the last one. Without it, tar -c … | gzip > file reports success even when tar failed.

    Use #!/bin/bash, not #!/bin/sh, when the script uses Bash features such as [[ ]] and arrays. On Debian and Ubuntu, /bin/sh is dash, which doesn't support [[ ]], arrays and other Bash features.

  2. Know where set -e doesn't help, and write those places explicitly. errexit is 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 "…"; fi
    local out=$(cmd) local succeeds, hiding the failure of cmd local out on one line, out=$(cmd) on the next
    (( count++ )) when count is 0 The arithmetic result is 0, so the command "fails" and the script exits count=$(( count + 1 ))
    cmd \| head -n 1 under pipefail cmd may be killed by SIGPIPE when head exits, so the pipeline fails (status 141) Capture first: out=$(cmd), then process "$out"
    grep that finds nothing Exit status 1 stops the script, though "no match" may be fine grep … \|\| true, or test it in an if
    An optional argument $1 set -u stops the script when it's missing Give a default: ${1:-} or ${1:-default}
  3. Quote every expansion. Unquoted, $file is split at spaces, tabs and newlines and then expanded as a glob, so a file called Q3 summary.txt becomes 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.

  4. Create temporary files with mktemp, and remove them with a trap. Fixed names like /tmp/myscript.tmp collide when two copies run at once and can be pre-created by another user. mktemp creates a new file (or, with -d, a directory) with a random name, readable only by you, and prints its name. A trap on EXIT runs whenever the script ends: normally, through set -e, or through exit. Bash also runs it when a signal such as SIGTERM ends the script, but other shells, such as dash, don't. Traps on INT and TERM make the signal case explicit and portable: they turn Ctrl+C and kill into an ordinary exit with a defined status (130 and 143, the statuses a shell reports for those signals), which runs the EXIT trap in any shell:

    tmp=$(mktemp)
    trap 'rm -f -- "$tmp"' EXIT
    trap 'exit 130' INT
    trap 'exit 143' TERM
    

    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") and mv it 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.

  5. 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 -e stops the script, though, the script exits with the failing command's own status, which can be 2 as well: GNU tar exits 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".

  6. 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"
    flock -n 9 || die "another run is using $dest"
    

    exec 9< "$dest" opens the destination directory for reading on file descriptor 9 for the rest of the script, and flock -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").

  7. 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:

    sudo chmod 0755 /usr/local/bin/archive-dir
    

    mktemp creates files with mode 0600; the chmod gives the finished archive normal permissions. ${0##*/} is the script's name without its directory, and ${1%/} removes a trailing slash from the argument.

  8. Install ShellCheck and run it on the script:

    sudo apt-get update
    sudo apt-get install -y shellcheck
    

    ShellCheck is in EPEL, not in the distribution's own repositories:

    sudo dnf install -y epel-release
    sudo dnf install -y ShellCheck
    

    On RHEL itself, enable EPEL as described in the EPEL documentation, which also needs the CodeReady Builder repository.

    sudo pacman -Syu --noconfirm shellcheck
    

    dev-util/shellcheck builds from Haskell source and is only keyworded for testing (~amd64). dev-util/shellcheck-bin installs the upstream binary:

    sudo emerge --getbinpkg dev-util/shellcheck-bin
    
    $ shellcheck /usr/local/bin/archive-dir
    $ echo $?
    0
    

    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=SC2086 comment, and say why.

Verify

Test the script on the path that should succeed, then on paths that should fail.

  1. Create a source directory with a file name containing spaces, and a destination:

    mkdir -p ~/reports ~/archives
    echo "revenue: 42" > ~/reports/"Q3 summary.txt"
    
  2. 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.gz
    

    The file name with a space survived intact, and only the finished archive is in the destination.

  3. Check the usage error. The script exits with status 2:

    $ archive-dir ~/reports
    Usage: archive-dir SOURCE_DIR DEST_DIR
    
  4. Check that a second run is refused while another holds the lock. flock used 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/archives
    

    The script stopped with status 1 before creating anything.

  5. Make the archive step itself fail, and check that the script stops and cleans up. A file the script can't read makes tar skip it: tar finishes writing the archive but exits with a non-zero status:

    echo "private" > ~/reports/secret.txt
    chmod 000 ~/reports/secret.txt
    
    $ 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.gz
    

    set -e stopped the script at the failing tar, before mv, with tar's exit status 2, and the EXIT trap removed the temporary file: there is still only the archive from the successful run.

  6. 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.gz
    

    An interactive shell also prints job-control lines such as [1] 2345, not shown here. The TERM trap ended the script with exit 143, and that exit ran the EXIT trap, which removed the temporary file. Bash runs a trap only after the command in the foreground finishes, so the script stops once the running tar has ended, not at the moment the signal arrives.

Remove the test data when you're done:

rm -rf ~/reports ~/archives /tmp/unquoted.sh

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.