Shell scripts are everywhere: CI pipelines, deployment automation, developer tooling. Yet most are fragile, failing unexpectedly when conditions change slightly. Here’s how to write scripts that hold up.
Start With Strict Mode
Always begin scripts with:
1
2
|
#!/usr/bin/env bash
set -euo pipefail
|
What this does:
set -e: Exit immediately if a command fails
set -u: Treat unset variables as errors
set -o pipefail: Pipeline fails if any command fails, not just the last
The Difference This Makes
Without strict mode:
1
2
3
|
#!/bin/bash
rm -rf $DIRECTROY/contents # Typo in variable name
# Silently deletes /contents because $DIRECTROY is empty
|
With strict mode:
1
2
3
4
|
#!/usr/bin/env bash
set -euo pipefail
rm -rf $DIRECTROY/contents
# Error: DIRECTROY: unbound variable
|
Quote Your Variables
Unquoted variables cause word splitting and glob expansion:
1
2
3
4
5
6
7
8
9
10
11
|
# Bad
file=$1
rm $file # Breaks if filename has spaces or wildcards
# Good
file="$1"
rm "$file" # Works correctly
# Also good for arrays
files=("file one.txt" "file two.txt")
rm "${files[@]}" # Properly handles spaces
|
When to Quote
Always quote, except:
- Inside
[[ ]] (but quoting doesn’t hurt)
- Assignments:
var=$other_var is safe
- When you explicitly want word splitting (rare)
Use ShellCheck
ShellCheck catches bugs automatically:
1
2
3
4
5
6
|
# Install
apt install shellcheck # Debian/Ubuntu
brew install shellcheck # macOS
# Run
shellcheck myscript.sh
|
Example output:
In myscript.sh line 3:
rm -rf $DIR/*
^---^ SC2086: Double quote to prevent globbing and word splitting.
Add it to your CI pipeline:
1
2
|
- name: Lint shell scripts
run: shellcheck **/*.sh
|
Handle Errors Gracefully
Custom Error Handling
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
#!/usr/bin/env bash
set -euo pipefail
cleanup() {
echo "Cleaning up temporary files..."
rm -rf "$TEMP_DIR"
}
error_handler() {
echo "Error on line $1" >&2
cleanup
exit 1
}
trap 'error_handler $LINENO' ERR
trap cleanup EXIT
TEMP_DIR=$(mktemp -d)
# Script continues...
|
Check Command Success Explicitly
Sometimes you need to handle failure without exiting:
1
2
3
4
5
6
7
8
9
10
11
12
|
if ! command -v docker &> /dev/null; then
echo "Docker is not installed" >&2
exit 1
fi
# Or capture the exit code
if output=$(some_command 2>&1); then
echo "Success: $output"
else
exit_code=$?
echo "Failed with code $exit_code: $output" >&2
fi
|
Use Functions
Functions make scripts readable and testable:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
#!/usr/bin/env bash
set -euo pipefail
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
log_error() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2
}
check_dependencies() {
local deps=("docker" "curl" "jq")
for dep in "${deps[@]}"; do
if ! command -v "$dep" &> /dev/null; then
log_error "Missing dependency: $dep"
return 1
fi
done
}
main() {
log "Starting deployment"
check_dependencies
# Rest of script...
log "Deployment complete"
}
main "$@"
|
Safe Temporary Files
Never hardcode temp paths:
1
2
3
4
5
6
7
8
9
|
# Bad
temp_file="/tmp/myapp_temp" # Race conditions, predictable names
# Good
temp_file=$(mktemp)
temp_dir=$(mktemp -d)
# Clean up on exit
trap 'rm -rf "$temp_file" "$temp_dir"' EXIT
|
Portable Scripting
If your script needs to run on different systems:
Use POSIX Shell
1
2
|
#!/bin/sh
# POSIX sh instead of bash
|
Avoid Bashisms
| Bash |
POSIX |
[[ ]] |
[ ] |
$(( )) |
$(( )) (this one’s fine) |
{1..10} |
seq 1 10 |
source file |
. file |
function foo() |
foo() |
Check for Command Availability
1
2
3
4
5
6
7
8
9
10
11
|
# Prefer printf over echo for portability
printf '%s\n' "Hello, World"
# Check for GNU vs BSD tools
if stat --version &> /dev/null; then
# GNU stat
mod_time=$(stat -c %Y "$file")
else
# BSD stat (macOS)
mod_time=$(stat -f %m "$file")
fi
|
Never trust input:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
validate_input() {
local input="$1"
# Check if empty
if [[ -z "$input" ]]; then
log_error "Input cannot be empty"
return 1
fi
# Check against pattern
if [[ ! "$input" =~ ^[a-zA-Z0-9_-]+$ ]]; then
log_error "Input contains invalid characters"
return 1
fi
# Check path doesn't escape
if [[ "$input" == *".."* ]]; then
log_error "Path traversal not allowed"
return 1
fi
}
|
Testing Shell Scripts
Use Bats
Bats is a testing framework for Bash:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
# test_deploy.bats
#!/usr/bin/env bats
setup() {
source ./deploy.sh
}
@test "check_dependencies succeeds with all deps" {
run check_dependencies
[ "$status" -eq 0 ]
}
@test "validate_input rejects empty string" {
run validate_input ""
[ "$status" -eq 1 ]
[[ "$output" == *"cannot be empty"* ]]
}
|
Run tests:
Script Template
Here’s a template incorporating these practices:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
log() { echo "[INFO] $*"; }
log_error() { echo "[ERROR] $*" >&2; }
log_warn() { echo "[WARN] $*" >&2; }
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS] <argument>
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
Arguments:
argument Description of argument
EOF
}
cleanup() {
# Cleanup code here
:
}
trap cleanup EXIT
main() {
local verbose=false
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-v|--verbose)
verbose=true
shift
;;
-*)
log_error "Unknown option: $1"
usage
exit 1
;;
*)
break
;;
esac
done
if [[ $# -lt 1 ]]; then
log_error "Missing required argument"
usage
exit 1
fi
local argument="$1"
log "Starting $SCRIPT_NAME"
# Main logic here
log "Completed successfully"
}
main "$@"
|
Conclusion
Shell scripts don’t have to be fragile. With strict mode, proper quoting, ShellCheck, and good error handling, you can write scripts that work reliably across environments and fail gracefully when something goes wrong. The investment in learning these practices pays off every time your script doesn’t mysteriously break in production.
Comments