Skip to main content
DevOps Insightsguides & answers

Scripting · beginner

Shell Scripting for DevOps: Essential Bash Automation

Short answer

The part of shell scripting that matters in production is not loops and functions — it is what happens when a command fails. A script without set -euo pipefail keeps going after an error and exits 0, so whatever ran it believes it worked. Most of this page is about that, and about the cases where set -e still does not save you.

N
Neeraj Jha
Updated 9 min read159 views
On this page · 16 sections
A Bash script using set -euo pipefail, an ERR trap, a required argument and quoted expansions, each annotated.

Who this is for

You write scripts that other things run: CI jobs, cron entries, container entrypoints, deploy scripts. The difference from scripting at a prompt is that nobody is watching the output.

The header

bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

#!/usr/bin/env bash rather than #!/bin/bash, because on some systems bash is not in /bin, and rather than #!/bin/sh, because sh is not bash and most of what follows is not POSIX.

Then the three options, which are the whole point:

OptionWithout it
-eA failing command is ignored and the script continues
-uA typo'd variable expands to empty string, silently
-o pipefailA pipeline reports the last command's status, so false | true succeeds

-u is the one people leave out and then lose an afternoon to. rm -rf "$BUILD_DIR"/* with BUILD_DIR unset is rm -rf /*, which GNU rm's default --preserve-root does not catch — with -u it is an error on the line that referenced it.

Where set -e does not help

This is the part worth knowing, because set -e creates confidence that is only mostly earned. It does not trigger when:

bash
# ...the command is a condition. This is by design and usually what you want.
if grep -q pattern file; then ...; fi

# ...the command is on the left of && or ||
failing_command || echo "handled"

# ...the failure is anywhere but the end of a pipeline, unless pipefail is set
false | true        # exit status 0 without pipefail

# ...it is inside a function called in a condition
if my_function; then ...   # errexit is suspended inside my_function

So for anything that genuinely must not continue, check explicitly:

bash
if ! command -v kubectl >/dev/null 2>&1; then
  echo "kubectl is required but not installed" >&2
  exit 1
fi

And for required configuration, let the shell do it:

bash
: "${DATABASE_URL:?DATABASE_URL must be set}"
: "${ENVIRONMENT:=staging}"        # default if unset

The :? form exits with your message, naming the variable, which is a great deal more useful than a connection error forty lines later.

Cleaning up, whatever happens

bash
workdir="$(mktemp -d)"
cleanup() {
  local status=$?
  rm -rf "$workdir"
  exit "$status"
}
trap cleanup EXIT

trap ... EXIT runs on normal exit, on exit 1, and on an error under set -e. Capturing $? first and re-exiting with it is belt and braces rather than a necessity: once an EXIT trap finishes, bash exits with the status it was already exiting with, so a successful rm in the handler cannot turn a failure into success. What would is an explicit exit 0 inside the handler.

That five-line pattern replaces every rm -rf /tmp/something scattered through a script and the ones that get skipped when it exits early.

Quoting, which is most of the remaining bugs

bash
# Wrong. Breaks on any path containing a space.
cp $src $dst

# Right.
cp "$src" "$dst"

# Wrong. Re-splits each argument on $IFS and expands globs in it.
my_function $@

# Right. Preserves them as separate arguments.
my_function "$@"

The rule is simple enough to apply without thinking: quote every expansion unless you have a specific reason not to. Prefer [[ ]] over [ ] in bash — it does not word-split its operands, so an unquoted empty variable does not turn a test into a syntax error.

bash
[[ -f "$config" ]] || { echo "no config at $config" >&2; exit 1; }
[[ "$env" == "prod" ]] && echo "careful"

Functions

bash
deploy() {
  local environment="$1"
  local version="${2:-latest}"

  echo "Deploying ${version} to ${environment}" >&2
  # ...
}

local is not optional. Without it, every variable is global, and a loop counter named i inside a function silently destroys the i of whatever called it. Send progress messages to stderr with >&2 so a caller can capture the function's real output with $(...) without your log lines in it.

Exit codes

Your script's exit status is the only thing a scheduler, a CI runner, or Docker sees. Zero means success; anything else is a failure, and some values already mean something:

CodeMeaning
0Success
1General failure
126Found, but not executable
127Command not found
130Interrupted (Ctrl-C / SIGINT)

A container whose entrypoint script exits 0 stops, and Kubernetes restarts it — see Container exits immediately. The exit code explainer decodes anything above 128.

Cron

Cron is where working scripts go to fail, for one reason: it does not give you your shell environment. No PATH beyond a minimal default, no profile, no nvm, no virtualenv.

cron
# Absolute paths, redirect both streams, and a PATH you set yourself.
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * /opt/app/scripts/backup.sh >> /var/log/backup.log 2>&1

2>&1 is not optional either. Without it the error output goes to cron's mail spool, which on most servers goes nowhere, and a job that has been failing for six weeks looks exactly like one that has been working.

Use the cron builder rather than counting fields.

Windows line endings

Added 24 October 2026. A script saved on Windows ends every line with a carriage return as well as a newline, and that invisible character breaks it in two different ways. Run it directly, and the first line names an interpreter that does not exist:

text
bash: ./deploy.sh: /bin/bash^M: bad interpreter: No such file or directory

Run it as bash deploy.sh instead, and it starts, which is worse. Every line still ends in a carriage return, so cd /tmp asks for a directory called /tmp followed by that character. Reproduced on bash 3.2.57: the cd failed with cd: /tmp: No such file or directory, the script carried on in the directory it started in, and it exited 0.

Check for it, and strip it:

bash
file deploy.sh                    # "... with CRLF line terminators"
sed -i 's/\r$//' deploy.sh        # GNU sed; on macOS: sed -i '' 

1. **No `set -euo pipefail`.** The script continues after a failure and exits
   0. Whatever ran it records a success.
2. **Unquoted expansions.** Everything works until a path contains a space or a
   variable is empty.
3. **Parsing `ls`.** Use a glob (`for f in ./*.log`) or `find -print0` with
   `read -d ''`. Filenames can contain newlines.
4. **`cd` without checking.** `cd "$dir" || exit 1`. A failed `cd` followed by
   `rm -rf ./*` runs in whatever directory you were already in.
5. **Assuming GNU tools.** `sed -i` takes an argument on BSD and macOS and does
   not on GNU. If the script runs in both places, test in both.

## If this did not work

- **"command not found" only under cron or in CI.** `PATH`. Print `echo "$PATH"`
  from inside the failing context rather than from your terminal.
- **The script works when you run it, fails as an entrypoint.** The container
  image probably has no bash, only `sh`. Either install bash or write POSIX.
- **`set -e` did not stop it.** Check the list above — it is almost certainly a
  pipeline, a condition, or a function called in a condition.
- **It is subtly wrong and you cannot see why.** Run `shellcheck` on it.
  It catches quoting and expansion mistakes faster than reading does.

## Next steps

Add `set -euo pipefail` and a `trap ... EXIT` cleanup to the script you run
most often, then run `shellcheck` over it. On a script of any age, both will
find something.

## Related on this site

- [Container exit codes](/tools/exit-codes) — What your script's exit status means to whatever ran it.
- [Cron builder](/tools/cron-builder) — Build the schedule rather than guessing at the fields.
- [Container exits immediately](/problems/docker-container-exits-immediately) — When your entrypoint script is the reason.
s/\r$//' deploy.sh

Then stop it coming back, whatever each person's editor and Git settings are:

bash
printf '*.sh text eol=lf\n' >> .gitattributes
git add .gitattributes && git add --renormalize -- '*.sh'
git commit -m "Shell scripts use LF"

Reproduced with git 2.50.1: the repository then stores LF and a fresh clone runs. Your own copy keeps its carriage returns until the file is checked out again. Keep --renormalize limited to the scripts, as above: given ., it also stages any other file you have edited (reproduced on git 2.50.1). More on the error itself: bad interpreter: No such file or directory.

Common mistakes

  1. No set -euo pipefail. The script continues after a failure and exits 0. Whatever ran it records a success.
  2. Unquoted expansions. Everything works until a path contains a space or a variable is empty.
  3. Parsing ls. Use a glob (for f in ./*.log) or find -print0 with read -d ''. Filenames can contain newlines.
  4. cd without checking. cd "$dir" || exit 1. A failed cd followed by rm -rf ./* runs in whatever directory you were already in.
  5. Assuming GNU tools. sed -i takes an argument on BSD and macOS and does not on GNU. If the script runs in both places, test in both.

If this did not work

  • "command not found" only under cron or in CI. PATH. Print echo "$PATH" from inside the failing context rather than from your terminal.
  • The script works when you run it, fails as an entrypoint. The container image probably has no bash, only sh. Either install bash or write POSIX.
  • set -e did not stop it. Check the list above — it is almost certainly a pipeline, a condition, or a function called in a condition.
  • It is subtly wrong and you cannot see why. Run shellcheck on it. It catches quoting and expansion mistakes faster than reading does.

Next steps

Add set -euo pipefail and a trap ... EXIT cleanup to the script you run most often, then run shellcheck over it. On a script of any age, both will find something.

Tagged with

Enjoyed this article?

Get more DevOps insights delivered to your inbox.

Get new posts by email

Subscribe to get an email when a new blog post is published. Skip anytime.

No spam, unsubscribe anytime.

N

Written by

Neeraj Jha

Platform administrator and lead writer.

View all posts →

Discussion

0 comments

Sign in to join the conversation.

Be the first to comment

Start a conversation about this post

Share: