A Bash script can work even if improvised, but when it grows it becomes fragile. A clear structure saves time on bugs, changes, and maintenance.
1. Why structure a script well
- Easier to read and modify
- Fewer errors with spaces, empty variables, and weird paths
- Simpler debugging
- Reuse functions in other scripts
2. Recommended base template
A good starting point for modern Bash scripts:
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_FILE="/tmp/${SCRIPT_NAME%.sh}.log"
usage() {
cat <<'EOF'
Usage: script.sh [options]
Options:
-h, --help Show this help
-v, --verbose More verbose output
EOF
}
log() {
printf '[%s] %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"
}
die() {
log "ERROR: $*"
exit 1
}
main() {
log "Starting script: $SCRIPT_NAME"
# main logic here
}
main "$@"
set -euo pipefail helps catch errors early, but use it knowingly (especially with pipelines, optional commands, and tests).
3. Variables and constants
Practical rules
- Use clear names:
backup_dir,source_file - Quote expansions almost always:
"$var" - Use uppercase for constants:
DEFAULT_PORT=22 - Use Bash arrays, not concatenated strings
DEFAULT_PORT=22
backup_dir="/var/backups/app"
files=("config.yml" "db.sql" "notes.txt")
for f in "${files[@]}"; do
printf 'File: %s\n' "$f"
done
Avoid
for f in $(ls ...): it breaks easily with spaces and special characters in filenames.
4. Functions and code organization
Split code into small functions with a clear responsibility.
check_requirements() {
command -v rsync >/dev/null 2>&1 || die "rsync not found"
}
run_backup() {
local src="$1"
local dst="$2"
rsync -av --delete "$src/" "$dst/"
}
main() {
check_requirements
run_backup "/etc" "/backup/etc"
}
Recommended order in the file:
- Shebang + shell options
- Constants/global variables
- Utility functions (
log,die,usage) - Business functions
main- Final call:
main "$@"
5. Arguments, options, and help
Handle arguments explicitly. Simple example with case:
VERBOSE=0
TARGET_DIR=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-v|--verbose)
VERBOSE=1
shift
;;
-t|--target)
[[ $# -lt 2 ]] && die "Missing value for $1"
TARGET_DIR="$2"
shift 2
;;
*)
die "Unknown argument: $1"
;;
esac
done
[[ -z "$TARGET_DIR" ]] && die "You must specify --target"
For more complex scripts you can use
getopts (short options) or a dedicated parser, but the while + case pattern is often enough.
6. Error handling (real best practices)
Check dependencies first
command -v curl >/dev/null 2>&1 || die "curl not installed"
command -v jq >/dev/null 2>&1 || die "jq not installed"
Do not ignore exit codes
if ! cp "$src" "$dst"; then
die "Copy failed: $src -> $dst"
fi
Safe default values
: "${TMPDIR:=/tmp}"
: "${BACKUP_KEEP_DAYS:=7}"
Avoid dangerous side effects
Before destructive operations (
rm, mv, sync), print the target and validate it is not empty:
[[ -n "${target_dir:-}" ]] || die "target_dir empty"
[[ "$target_dir" != "/" ]] || die "Refused: target_dir is /"
7. Simple but useful logging
Use dedicated functions for log levels.
log_info() { printf '[INFO] %s\n' "$*"; }
log_warn() { printf '[WARN] %s\n' "$*" >&2; }
log_error() { printf '[ERROR] %s\n' "$*" >&2; }
log_info "Backup started"
log_warn "Low disk space"
log_error "Connection failed"
If you also want to save to file:
exec > >(tee -a "$LOG_FILE")
exec 2>&1
This redirects stdout/stderr to
tee. Useful for maintenance scripts, less suitable if you want “clean” output for parsing.
8. Traps and cleanup
When you create temp files or lockfiles, use trap to clean up even on errors/interruption.
TMP_FILE="$(mktemp)"
cleanup() {
rm -f "$TMP_FILE"
}
trap cleanup EXIT
trap 'echo "Interrupted"; exit 130' INT TERM
9. Permissions and security
- Make executable only if needed:
chmod +x script.sh - Check if root is required at the beginning
- Do not store passwords in plain text
- Use
umaskif you create sensitive files
require_root() {
[[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Run as root"
}
If you use external input (arguments, files, command output), treat it as untrusted: always quote and validate format.
10. Debugging, linting, and tests
Quick debug
bash -x ./script.sh --target /tmp/test
bash -n ./script.sh
-x: trace executed commands-n: check syntax only
ShellCheck (strongly recommended)
shellcheck script.sh
It flags common errors, missing quotes, bad variable use, and fragile patterns.
Mini final checklist
[ ] Correct shebang (#!/usr/bin/env bash)
[ ] set -euo pipefail (if appropriate)
[ ] Variables quoted
[ ] Arguments validated
[ ] Error handling with die()/exit code
[ ] Trap cleanup if using temp files
[ ] bash -n and shellcheck run
Reusable final template
#!/usr/bin/env bash
set -euo pipefail
usage() { echo "Usage: $0 --target DIR"; }
die() { echo "ERROR: $*" >&2; exit 1; }
TARGET=""
while [[ $# -gt 0 ]]; do
case "$1" in
--target) TARGET="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "Unknown argument: $1" ;;
esac
done
[[ -n "$TARGET" ]] || die "Specify --target"
[[ -d "$TARGET" ]] || die "Directory not found: $TARGET"
main() {
echo "Operating on: $TARGET"
}
main "$@"