0%
Bash Scripting: Writing Robust and Maintainable Scripts

Bash Scripting: Writing Robust and Maintainable Scripts

A complete guide to Bash scripting: variables, quoting, set -euo pipefail, functions, getopts, trap, arrays, and shellcheck for reliable scripts.

I

InSkillCoach

· min

Bash Scripting: Writing Robust and Maintainable Scripts

Anyone can write a Bash script that works once, on their own machine, on a good day. Writing a script that survives spaces in file names, commands that fail silently, and interruptions in the middle of processing is another matter. In this guide, we go through the techniques that separate a throwaway script from a production script: strict mode, rigorous quoting, functions, argument handling, cleanup with trap, and validation with shellcheck. It all wraps up with a complete, commented backup script.

1. Shebang and Execution

The first line matters

The shebang tells the system which interpreter to use. Two forms coexist:

#!/bin/bash
# Direct path: simple, works on nearly every distribution

#!/usr/bin/env bash
# Looks up bash in the PATH: preferable for portability (macOS, Nix, etc.)

Don’t use #!/bin/sh if your script contains Bash-specific constructs ([[ ]], arrays, local): on Debian and Ubuntu, /bin/sh points to dash, which doesn’t understand them.

Making the script executable

# Grant execute permission
chmod +x mon_script.sh

# Run using the relative path
./mon_script.sh

# Run without execute permission (by going through the interpreter)
bash mon_script.sh

# Check the syntax without running
bash -n mon_script.sh

# Run in trace mode for debugging (prints each command)
bash -x mon_script.sh

2. Strict Mode: set -euo pipefail

This is the first line to write after the shebang of any serious script:

#!/usr/bin/env bash
set -euo pipefail

Details of each option:

OptionEffectWithout it
set -eStops the script at the first command that failsThe script keeps going after an error and makes the damage worse
set -uErrors out if an undefined variable is usedA typo silently yields an empty string
set -o pipefailA pipeline fails if any of its commands failscommande_cassee | grep x succeeds if grep succeeds

A concrete example of the danger without set -u:

# Potential CATASTROPHE: if REPERTOIRE is not defined,
# this line becomes "rm -rf /" with certain values
rm -rf "${REPERTOIRE}/cache"

# With set -u, the script stops immediately with:
# line N: REPERTOIRE: unbound variable

When a command is allowed to fail, say so explicitly:

# Allow a specific command to fail despite set -e
grep "motif" fichier.log || true

# Or capture the exit code
if ! systemctl is-active --quiet nginx; then
    echo "nginx est arrêté"
fi

3. Variables and Quoting

Declaring and using variables

# Assignment: no spaces around the equals sign
nom="serveur-web-01"

# Always wrap variables in double quotes
echo "Machine : ${nom}"

# Command substitution: prefer $() over backticks
date_jour=$(date +%Y-%m-%d)

# Default value if the variable is empty or unset
port="${PORT:-8080}"

# Explicit error if the variable is unset
fichier="${1:?Usage : $0 <fichier>}"

# Read-only variables for constants
readonly VERSION="2.1.0"

Quoting: the number one source of bugs

An unquoted variable is subject to word splitting and glob expansion. This is the cause of most Bash bugs.

fichier="rapport final.pdf"

# WRONG: Bash sees two arguments, "rapport" and "final.pdf"
cp $fichier /backup/

# CORRECT: a single argument, whatever the content
cp "$fichier" /backup/

# WRONG: if the directory contains *.txt, the glob is expanded
message="Fichiers *.txt trouvés"
echo $message

# CORRECT
echo "$message"

A simple rule: always quote your variables ("$var"), unless you know exactly why you’re not doing so.

4. Tests with [[ ]]

The double square brackets [[ ]] are specific to Bash and safer than [ ]: no word splitting inside, readable logical operators, and built-in pattern matching.

# String tests
if [[ -z "$variable" ]]; then echo "vide"; fi
if [[ -n "$variable" ]]; then echo "non vide"; fi
if [[ "$reponse" == "oui" ]]; then echo "confirmé"; fi

# Pattern matching (glob)
if [[ "$fichier" == *.log ]]; then echo "fichier de log"; fi

# Regular expression
if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
    echo "ressemble à une adresse IPv4"
fi

# File tests
if [[ -f "/etc/nginx/nginx.conf" ]]; then echo "fichier existe"; fi
if [[ -d "/var/www" ]]; then echo "répertoire existe"; fi
if [[ -r "$fichier" && -w "$fichier" ]]; then echo "lecture et écriture"; fi

# Numeric comparisons (or use (( )) for arithmetic)
if [[ "$nombre" -gt 100 ]]; then echo "supérieur à 100"; fi
if (( nombre > 100 )); then echo "idem, syntaxe arithmétique"; fi

5. Loops

# Loop over an explicit list
for service in nginx postgresql redis; do
    systemctl status "$service" --no-pager
done

# Loop over files: the glob correctly handles spaces
for fichier in /var/log/*.log; do
    echo "Traitement de $fichier"
done

# C-style numeric loop
for (( i = 1; i <= 5; i++ )); do
    echo "Tentative $i"
done

# While loop with a counter
tentatives=0
while (( tentatives < 3 )); do
    (( tentatives++ )) || true
    echo "Essai numéro $tentatives"
done

# Until loop: wait for a service to respond
until curl -sf http://localhost:8080/health > /dev/null; do
    echo "En attente du service..."
    sleep 2
done

Never iterate over the output of ls: use globs or find with -print0.

6. Functions

# Definition: local variables are mandatory to avoid side effects
log() {
    local niveau="$1"
    local message="$2"
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${niveau}] ${message}" >&2
}

# Function with a return value via stdout
espace_libre_mo() {
    local point_montage="$1"
    df --output=avail -m "$point_montage" | tail -1 | tr -d ' '
}

# Usage
log "INFO" "Démarrage du traitement"
libre=$(espace_libre_mo /backup)

if (( libre < 1024 )); then
    log "ERREUR" "Moins de 1 Go disponible sur /backup"
    exit 1
fi

Two golden rules: declare all function variables with local, and write diagnostic messages to stderr (>&2) so they don’t pollute the values returned on stdout.

7. Arguments and getopts

Special variables

echo "Nom du script : $0"
echo "Premier argument : $1"
echo "Nombre d'arguments : $#"
echo "Tous les arguments : $*"

# "$@" preserves individual arguments (always prefer this form)
for arg in "$@"; do
    echo "Argument : $arg"
done

Parsing options with getopts

#!/usr/bin/env bash
set -euo pipefail

usage() {
    echo "Usage : $0 [-v] [-o fichier_sortie] -s source" >&2
    exit 1
}

verbeux=false
sortie="/tmp/resultat.txt"
source_dir=""

while getopts ":vo:s:" option; do
    case "$option" in
        v) verbeux=true ;;
        o) sortie="$OPTARG" ;;
        s) source_dir="$OPTARG" ;;
        :) echo "L'option -$OPTARG requiert un argument" >&2; usage ;;
        \?) echo "Option inconnue : -$OPTARG" >&2; usage ;;
    esac
done
shift $((OPTIND - 1))

# Validate required arguments
[[ -n "$source_dir" ]] || usage

"$verbeux" && echo "Source : $source_dir, sortie : $sortie"

8. trap and Cleanup

A script can be interrupted at any time (Ctrl+C, kill, an error with set -e). trap guarantees that cleanup happens no matter what.

#!/usr/bin/env bash
set -euo pipefail

# Create a temporary working directory
travail=$(mktemp -d)

# The cleanup function runs when the script exits,
# whether the exit is normal, caused by an error, or caused by an interruption
nettoyage() {
    local code=$?
    rm -rf "$travail"
    echo "Nettoyage effectué (code de sortie : $code)" >&2
}
trap nettoyage EXIT

# Print the offending line on error: invaluable for debugging
trap 'echo "Erreur à la ligne $LINENO" >&2' ERR

# The rest of the script can use $travail safely
cp /etc/hosts "$travail/"

The EXIT trap covers almost every case: normal exit, explicit exit, an error with set -e, and SIGINT/SIGTERM signals (Bash runs the EXIT trap after receiving the signal).

9. Arrays

# Indexed array
serveurs=("web-01" "web-02" "db-01")

# Add an element
serveurs+=("cache-01")

# Number of elements
echo "Nombre de serveurs : ${#serveurs[@]}"

# Iterate: always "${array[@]}" with quotes
for serveur in "${serveurs[@]}"; do
    echo "Vérification de $serveur"
done

# Associative array (Bash 4 and later)
declare -A ports
ports[http]=80
ports[https]=443
ports[ssh]=22

for service in "${!ports[@]}"; do
    echo "$service écoute sur le port ${ports[$service]}"
done

10. Reading Files

The canonical way to read a file line by line:

# IFS= preserves leading/trailing whitespace
# -r prevents backslash interpretation
while IFS= read -r ligne; do
    echo "Lu : $ligne"
done < /etc/hostname

# Read a CSV file, splitting the fields
while IFS=, read -r nom ip role; do
    echo "Serveur $nom ($ip) : rôle $role"
done < serveurs.csv

# Load an entire file into an array
mapfile -t lignes < /etc/hosts
echo "Le fichier contient ${#lignes[@]} lignes"

Avoid the classic cat fichier | while read pitfall: the pipe creates a subshell, and variables modified in the loop are lost when it exits.

11. shellcheck: Your Automated Reviewer

shellcheck analyzes your scripts and detects classic mistakes: unquoted variables, dubious comparisons, non-portable constructs.

# Installation
sudo apt install shellcheck        # Debian/Ubuntu
sudo dnf install ShellCheck        # Fedora/Rocky

# Analyze a script
shellcheck mon_script.sh

# Ignore a specific rule on one line (with a justification in a comment)
# shellcheck disable=SC2086  # intentional word splitting of the options
commande $options

Integrate shellcheck into your CI: a script that doesn’t pass shellcheck shouldn’t be merged.

12. Complete Example: Backup Script

This script brings together all the techniques covered so far.

#!/usr/bin/env bash
#
# backup.sh — Compressed backup of a directory with rotation.
# Usage: backup.sh [-k number_of_copies] -s /source/directory -d /destination/directory
#
set -euo pipefail

readonly SCRIPT_NAME="${0##*/}"

# --- Functions -------------------------------------------------------------

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
}

usage() {
    echo "Usage : $SCRIPT_NAME [-k copies] -s source -d destination" >&2
    exit 1
}

nettoyage() {
    # Remove the partial archive if the script is interrupted
    if [[ -n "${archive_temp:-}" && -f "$archive_temp" ]]; then
        rm -f "$archive_temp"
        log "Archive partielle supprimée : $archive_temp"
    fi
}
trap nettoyage EXIT

# --- Argument parsing ------------------------------------------------------

copies=7
source_dir=""
dest_dir=""

while getopts ":k:s:d:" option; do
    case "$option" in
        k) copies="$OPTARG" ;;
        s) source_dir="$OPTARG" ;;
        d) dest_dir="$OPTARG" ;;
        *) usage ;;
    esac
done

[[ -n "$source_dir" && -n "$dest_dir" ]] || usage
[[ -d "$source_dir" ]] || { log "ERREUR : source introuvable : $source_dir"; exit 1; }
mkdir -p "$dest_dir"

# --- Backup ----------------------------------------------------------------

horodatage=$(date +%Y%m%d-%H%M%S)
archive="${dest_dir}/backup-${horodatage}.tar.gz"
archive_temp="${archive}.part"

log "Sauvegarde de $source_dir vers $archive"

# Write to a temporary file, then rename: the final archive
# only exists if it is complete
tar -czf "$archive_temp" -C "$(dirname "$source_dir")" "$(basename "$source_dir")"
mv "$archive_temp" "$archive"
archive_temp=""

log "Archive créée : $(du -h "$archive" | cut -f1)"

# --- Rotation: keep the N most recent archives ------------------------------

mapfile -t anciennes < <(ls -1t "${dest_dir}"/backup-*.tar.gz 2>/dev/null | tail -n +"$((copies + 1))")

for ancienne in "${anciennes[@]}"; do
    rm -f "$ancienne"
    log "Ancienne archive supprimée : $ancienne"
done

log "Terminé : $(ls -1 "${dest_dir}"/backup-*.tar.gz | wc -l) archive(s) conservée(s)"

Best Practices

  • Start every script with set -euo pipefail and justify each exception with || true.
  • Quote your variables systematically: "$var", "${array[@]}", "$@".
  • Declare function variables with local and constants with readonly.
  • Write logs to stderr to keep stdout available for data.
  • Use mktemp for temporary files and a trap EXIT to clean them up.
  • Write to a temporary file, then rename it with mv: an atomic operation avoids half-written files.
  • Run every script through shellcheck before deploying it.
  • If a script grows past a few hundred lines or handles complex data structures, consider Python: Bash excels at orchestrating commands, not at business logic.

Conclusion

The robustness of a Bash script comes not from its length but from its discipline: strict mode that turns silent errors into clean stops, quoting that neutralizes tricky file names, trap that guarantees cleanup, and shellcheck that reviews every line for you. These habits cost a few seconds when writing and save hours of debugging in production.

Reuse the backup script from section 12 as a skeleton for your own tools: the structure of logging, arguments, trap, and atomic operations adapts to nearly every administration script.

InSkillCoach

About InSkillCoach

Training and technology expert

Coach specializing in advanced technologies and AI, backed by GNeurone Inc.

Certifications:

  • AWS Certified Solutions Architect – Professional
  • Google Cloud certifications
  • Microsoft Certified: DevOps Engineer Expert
  • Certified Kubernetes Administrator (CKA)
  • CompTIA Security+
967
333

Comments

Comments are powered by GitHub Discussions

Sign in with GitHub to join the discussion

Link copied!