LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Writing Shell Scripts That Don't Break

bashshellscriptingdevops

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

Input Validation

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:

1
bats test_deploy.bats

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