HowTo Bash

Bash scripts: base structure and best practices

How to organize a clean shell script: variables, checks, logs, error handling, and permissions.

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:

  1. Shebang + shell options
  2. Constants/global variables
  3. Utility functions (log, die, usage)
  4. Business functions
  5. main
  6. 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 umask if 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 "$@"

Back to the Linux HowTo section