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.
InSkillCoach
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:
| Option | Effect | Without it |
|---|---|---|
set -e | Stops the script at the first command that fails | The script keeps going after an error and makes the damage worse |
set -u | Errors out if an undefined variable is used | A typo silently yields an empty string |
set -o pipefail | A pipeline fails if any of its commands fails | commande_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 pipefailand justify each exception with|| true. - Quote your variables systematically:
"$var","${array[@]}","$@". - Declare function variables with
localand constants withreadonly. - Write logs to stderr to keep stdout available for data.
- Use
mktempfor temporary files and atrap EXITto 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.
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+
Comments
Comments are powered by GitHub Discussions
Sign in with GitHub to join the discussion