Mastering Bash Error Handling: set -euo pipefail and trap for Production‑Ready Scripts
Why I Care About Errors in Bash
When I started writing automation scripts for CI/CD pipelines, I quickly discovered that a simple exit 1 hidden deep in a pipeline could leave temporary files scattered and make debugging a nightmare. Over the years I’ve refined a small set of conventions that turn a “just another Bash script” into a robust, self‑healing piece of tooling. The two pillars of that approach are the strict error‑handling flags set -euo pipefail and the trap builtin for cleanup. In this article I’ll walk you through a real‑world scenario, show you the exact code I rely on, and explain the reasoning behind each choice.
A Common Pain Point
Imagine you’re deploying a Docker image to a remote host. The script does three things: builds the image, tags it, and pushes it. If any step fails, you want the script to stop immediately, report the failure, and delete any temporary build artifacts. Without explicit error handling, a failing docker push would leave the partially built image on disk, and the script would continue to the next step—only to fail again. That’s exactly the kind of situation where a little extra discipline pays off.
The Core Flags: set -euo pipefail
These four flags make Bash treat errors, undefined variables, and pipeline failures as fatal conditions, which is exactly what you want in a production script.
Here’s the minimal flag set I adopt at the top of every script I ship:
#!/usr/bin/env bash
# Exit on any error, undefined variable, or pipeline failure.
# -e : abort on non‑zero exit status
# -u : treat unset variables as an error
# -o pipefail : propagate failures from the last command in a pipeline
# -o nounset is the same as -u (kept for clarity)
set -euo pipefail
# Optional: allow for a clean exit on script interruption
trap 'echo "Script aborted. Cleaning up..." >&2; exit 130' INT TERM
Let’s break it down:
-e– If any command returns a non‑zero exit code, Bash stops executing the script. This prevents a downstream command from receiving a broken input from an earlier step.-u(ornounset) – Using an undefined variable is now an error. This catches typos like$IMAGE_NAMEvs$IMAGE_NAMEearly rather than later.-o pipefail– In a pipeline, Bash treats the last command’s exit status as the pipeline’s overall status. Without it, a failing command inside a pipe could be masked by a later successful command, leading to silent bugs.
These flags are cheap to enable and they give you a predictable failure mode that’s easy to test.
Cleanup with trap
Even with set -euo pipefail, a script can still be interrupted by a signal (Ctrl‑C, kill, etc.). A trap ensures we can perform a cleanup routine before exiting. Below is a complete deployment script that demonstrates both error handling and cleanup.
#!/usr/bin/env bash
# Exit on any error, undefined variable, or pipeline failure.
set -euo pipefail
# Temporary directory for intermediate files.
TEMP_DIR=$(mktemp -d /tmp/deploy-XXXXXX)
# Register cleanup handlers.
trap 'rm -rf "$TEMP_DIR"; echo "Cleaned up $TEMP_DIR" >&2' EXIT
trap 'echo "Interrupted. Cleaning up..." >&2; rm -rf "$TEMP_DIR"; exit 130' INT TERM
# Example deployment steps.
IMAGE_NAME="myapp:latest"
BUILD_ARGS=(--tag "$IMAGE_NAME" --build-arg "VERSION=$VERSION")
echo "Building Docker image..."
docker build "${BUILD_ARGS[@]}" .
echo "Tagging for registry..."
docker tag "$IMAGE_NAME" "registry.example.com/$IMAGE_NAME"
echo "Pushing to registry..."
docker push "registry.example.com/$IMAGE_NAME"
echo "Deployment succeeded."
Key observations:
- Temp directory creation – We isolate any intermediate files so they can be removed automatically.
- Two traps – One for
EXITruns after normal or error exit, the other forINT/TERMto catch user interruptions. Both clean up the temp directory. - Fail‑fast behavior – If any of the Docker commands fails, Bash aborts because of
-e. TheEXITtrap still fires, removing the temporary directory.
The script is now production‑ready: it leaves no temporary artifacts, it fails fast, and it gives you a clean error path.
When to Relax the Rules
Not every script needs the strictest settings. For one‑off exploratory commands, you might want to keep the shell interactive. In those cases, you can temporarily disable the flags with set +e or set +u. However, for any code that you commit to a repository and run in CI, the strict mode is a safety net you should keep.
Putting It All Together: A Real‑World Example
Let’s look at a concrete scenario: building a multi‑stage Docker image, running a test suite, and pushing the result only if tests pass. The script below uses the same error‑handling pattern and adds a conditional step that only proceeds on success.
#!/usr/bin/env bash
# Strict error handling
set -euo pipefail
# Colors for output (optional but helpful)
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
# Configuration
REPO="myorg/myapp"
TAG="${TAG:-latest}"
IMAGE="$REPO:$TAG"
TEMP_DIR=$(mktemp -d /tmp/build-XXXXXX)
# Cleanup on exit
trap 'rm -rf "$TEMP_DIR"; echo -e "${RED}Cleaned up $TEMP_DIR${NC}" >&2' EXIT
trap 'echo -e "${RED}Interrupted. Cleaning up...${NC}" >&2; rm -rf "$TEMP_DIR"; exit 130' INT TERM
# Step 1: Build the image with a cache‑busting arg
BUILD_ID=$(date +%s)
echo "Building $IMAGE (build id: $BUILD_ID)..."
docker build \
--tag "$IMAGE" \
--build-arg BUILD_ID=$BUILD_ID \
--file Dockerfile.prod .
# Step 2: Run tests inside the container (simplified)
echo "Running test suite..."
if ! docker run --rm "$IMAGE" npm run test; then
echo -e "${RED}Tests failed. Aborting.${NC}" >&2
exit 1
fi
# Step 3: Push to registry (only on success)
echo "Pushing $IMAGE to registry..."
docker push "$IMAGE"
echo -e "${GREEN}Deployment completed successfully.${NC}"
Notice how the script:
- Creates a temporary directory for any intermediate files (we didn’t need one here, but the pattern is there).
- Uses
set -euo pipefailto guarantee that a failing Docker command aborts the script before the push step. - Adds a manual
if ! ...; then exit 1for the test step because we want to give a custom error message and skip the push. Even with-e, we can still explicitly exit with a friendly message.
The result is a script that is easy to reason about, leaves no stray files, and fails loudly when something goes wrong.
Common Pitfalls
Even with these safeguards, developers sometimes run into subtle issues:
- Ignoring pipeline failures. Without
pipefail, a command likecat file | grep pattern || truewould still cause the script to exit ifcatfails. Addingpipefailchanges that behavior, so be aware of how you intend to handle errors inside pipelines. - Using functions that bypass error checking. Bash functions do not trigger
-eif they contain a failing command unless you setset -o errtrace. If you need functions to abort the script on error, enableerrtraceas well. - Over‑cleaning. A trap that runs on
EXITwill also fire after a successful run, which is fine for cleanup but can be noisy. Use>/dev/nullor conditional logic if you only want cleanup on failure.
These edge cases are rare, but they illustrate why understanding the “why” behind each flag matters.
Wrap‑Up
Writing Bash scripts that are robust enough for production doesn’t require exotic features—just a disciplined approach to error handling and resource cleanup. By adding set -euo pipefail and a well‑placed trap, you get:
- Immediate failure detection so you don’t chase phantom bugs later.
- Automatic cleanup that protects the filesystem from stray temporary files.
- Predictable exit codes that CI systems can reliably interpret.
These patterns have saved me countless hours when a pipeline broke in the middle of night. Try them in your next script, and you’ll see how much smoother your automation becomes. Happy scripting!