shell-scripting
Shell Scripting & Bash Best Practices
Comprehensive guide to writing robust, portable, and maintainable shell scripts. Covers Bash idioms, POSIX compliance, error handling, security, and real-world patterns.
Bash Script Template
Every script should start with a solid foundation.
#!/usr/bin/env bash
#
# script-name.sh - Brief description of what the script does
#
# Usage: script-name.sh [OPTIONS] <arguments>
#
# Author: Your Name
# Date: 2024-01-01
set -euo pipefail
IFS=$'\n\t'
# --- Constants ----------------------------------------------------------------
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly VERSION="1.0.0"
# --- Cleanup Trap -------------------------------------------------------------
cleanup() {
local exit_code=$?
# Remove temp files, release locks, etc.
if [[ -n "${TMPDIR_CUSTOM:-}" && -d "$TMPDIR_CUSTOM" ]]; then
rm -rf "$TMPDIR_CUSTOM"
fi
exit "$exit_code"
}
trap cleanup EXIT
trap 'echo "Interrupted."; exit 130' INT
trap 'echo "Terminated."; exit 143' TERM
# --- Main Logic ---------------------------------------------------------------
main() {
parse_args "$@"
validate_dependencies
# ... your logic here ...
}
main "$@"
What the options mean
set -e-- Exit immediately on any command failure.set -u-- Treat unset variables as an error.set -o pipefail-- A pipeline fails if any command in it fails, not just the last.IFS=$'\n\t'-- Safer word splitting; avoids problems with spaces in filenames.
Variable Handling
Quoting Rules
Always double-quote variables unless you explicitly need word splitting or globbing.
# CORRECT -- variables are quoted
name="world"
echo "Hello, $name"
cp "$source" "$destination"
# WRONG -- unquoted variables break on spaces
cp $source $destination # Breaks if paths have spaces
# When you DO want globbing (intentionally)
for f in *.txt; do
echo "Processing: $f"
done
Variable Expansion and Defaults
# Default value if unset or empty
db_host="${DB_HOST:-localhost}"
db_port="${DB_PORT:-5432}"
# Assign default if unset or empty
: "${LOG_LEVEL:=info}"
# Error if variable is unset
: "${API_KEY:?ERROR: API_KEY must be set}"
# Substring extraction
filename="report-2024-01-15.csv"
echo "${filename:0:6}" # "report"
echo "${filename: -3}" # "csv" (note the space before -)
# String length
echo "${#filename}" # 22
# Variable indirection
var_name="HOME"
echo "${!var_name}" # prints value of $HOME
Removal and Replacement
filepath="/home/user/documents/report.tar.gz"
# Remove shortest match from front
echo "${filepath#*/}" # "home/user/documents/report.tar.gz"
# Remove longest match from front
echo "${filepath##*/}" # "report.tar.gz" (basename)
# Remove shortest match from end
echo "${filepath%.*}" # "/home/user/documents/report.tar"
# Remove longest match from end
echo "${filepath%%.*}" # "/home/user/documents/report"
# Pattern substitution
echo "${filepath/user/admin}" # "/home/admin/documents/report.tar.gz"
# Replace all occurrences
msg="foo-bar-baz"
echo "${msg//-/_}" # "foo_bar_baz"
# Case conversion (Bash 4+)
text="Hello World"
echo "${text,,}" # "hello world" (lowercase)
echo "${text^^}" # "HELLO WORLD" (uppercase)
echo "${text~}" # "hELLO WORLD" (toggle first char)
Conditionals and Test Operators
if/elif/else
if [[ -f "$config_file" ]]; then
source "$config_file"
elif [[ -f /etc/default/myapp ]]; then
source /etc/default/myapp
else
echo "No configuration found, using defaults."
fi
Test Operators
# File tests
[[ -e "$path" ]] # Exists (file, directory, symlink, etc.)
[[ -f "$path" ]] # Regular file
[[ -d "$path" ]] # Directory
[[ -L "$path" ]] # Symlink
[[ -r "$path" ]] # Readable
[[ -w "$path" ]] # Writable
[[ -x "$path" ]] # Executable
[[ -s "$path" ]] # Non-empty file
[[ "$a" -nt "$b" ]] # a is newer than b
[[ "$a" -ot "$b" ]] # a is older than b
# String tests
[[ -z "$str" ]] # Empty string
[[ -n "$str" ]] # Non-empty string
[[ "$a" == "$b" ]] # String equality
[[ "$a" != "$b" ]] # String inequality
[[ "$a" == *.txt ]] # Glob pattern match
[[ "$a" =~ ^[0-9]+$ ]] # Regex match
# Numeric comparisons
[[ "$x" -eq "$y" ]] # Equal
[[ "$x" -ne "$y" ]] # Not equal
[[ "$x" -lt "$y" ]] # Less than
[[ "$x" -gt "$y" ]] # Greater than
[[ "$x" -le "$y" ]] # Less than or equal
[[ "$x" -ge "$y" ]] # Greater than or equal
# Logical operators inside [[ ]]
[[ -f "$f" && -r "$f" ]] # AND
[[ -f "$f" || -d "$f" ]] # OR
[[ ! -e "$path" ]] # NOT
Arithmetic
# Arithmetic evaluation
(( count++ ))
(( total = price * quantity ))
if (( age >= 18 )); then
echo "Adult"
fi
# Ternary-style
(( result = (a > b) ? a : b ))
Loops
for loops
# Iterate over a list
for fruit in apple banana cherry; do
echo "Fruit: $fruit"
done
# C-style for loop
for (( i = 0; i < 10; i++ )); do
echo "Iteration $i"
done
# Iterate over files safely
for file in /var/log/*.log; do
[[ -f "$file" ]] || continue # Guard against no matches
echo "Log: $file"
done
# Iterate over command output (line by line)
while IFS= read -r line; do
echo "Line: $line"
done < <(find /tmp -maxdepth 1 -name "*.tmp" -type f)
# Iterate over array
declare -a servers=("web01" "web02" "db01")
for server in "${servers[@]}"; do
echo "Pinging $server..."
done
while and until
# while loop
counter=0
while (( counter < 5 )); do
echo "Count: $counter"
(( counter++ ))
done
# Read file line by line
while IFS= read -r line; do
echo ">> $line"
done < "$input_file"
# Read with a custom delimiter (e.g., colon-separated)
while IFS=: read -r user _ uid gid _ home shell; do
echo "User: $user, Home: $home, Shell: $shell"
done < /etc/passwd
# until loop (runs until condition becomes true)
until ping -c1 -W1 "$host" &>/dev/null; do
echo "Waiting for $host to come online..."
sleep 5
done
echo "$host is reachable."
Loop Control
for i in {1..100}; do
(( i % 2 == 0 )) && continue # Skip even numbers
(( i > 20 )) && break # Stop after 20
echo "$i"
done
Functions and Return Values
# Function definition
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
printf '[%s] [%-5s] %s\n' "$timestamp" "$level" "$message"
}
# Using local variables (always use local in functions)
calculate_sum() {
local -i a="$1"
local -i b="$2"
local -i result
result=$(( a + b ))
echo "$result" # Return value via stdout
}
sum=$(calculate_sum 10 20)
echo "Sum: $sum" # "Sum: 30"
# Return codes for success/failure signaling
is_valid_ip() {
local ip="$1"
local regex='^([0-9]{1,3}\.){3}[0-9]{1,3}$'
if [[ "$ip" =~ $regex ]]; then
return 0 # success
else
return 1 # failure
fi
}
if is_valid_ip "192.168.1.1"; then
echo "Valid IP"
fi
# Function with nameref (Bash 4.3+)
get_result() {
local -n ref="$1"
ref="computed value"
}
get_result my_var
echo "$my_var" # "computed value"
Command-Line Argument Parsing
Manual Parsing (Flexible, handles long options)
usage() {
cat <<USAGE
Usage: ${SCRIPT_NAME} [OPTIONS] <input-file>
Options:
-o, --output FILE Output file (default: stdout)
-v, --verbose Enable verbose output
-n, --dry-run Show what would be done
-h, --help Show this help message
--version Show version
Examples:
${SCRIPT_NAME} -v --output result.txt data.csv
${SCRIPT_NAME} --dry-run input.log
USAGE
exit "${1:-0}"
}
# Defaults
output=""
verbose=false
dry_run=false
input_file=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output)
[[ -n "${2:-}" ]] || { echo "Error: --output requires a value"; usage 1; }
output="$2"
shift 2
;;
-v|--verbose)
verbose=true
shift
;;
-n|--dry-run)
dry_run=true
shift
;;
-h|--help)
usage 0
;;
--version)
echo "${SCRIPT_NAME} v${VERSION}"
exit 0
;;
--)
shift
break
;;
-*)
echo "Error: Unknown option: $1" >&2
usage 1
;;
*)
input_file="$1"
shift
;;
esac
done
# Remaining positional arguments after --
[[ -n "$input_file" ]] || { echo "Error: input file required" >&2; usage 1; }
getopts (POSIX compatible, short options only)
verbose=0
output=""
while getopts ":vo:h" opt; do
case "$opt" in
v) verbose=1 ;;
o) output="$OPTARG" ;;
h) usage 0 ;;
:) echo "Error: -${OPTARG} requires an argument" >&2; exit 1 ;;
*) echo "Error: Unknown option -${OPTARG}" >&2; exit 1 ;;
esac
done
shift $((OPTIND - 1))
Input/Output Redirection and Pipes
# Standard redirections
command > file.txt # Redirect stdout (overwrite)
command >> file.txt # Redirect stdout (append)
command 2> errors.log # Redirect stderr
command &> all.log # Redirect both stdout and stderr
command > out.log 2>&1 # Same as above (POSIX compatible)
command 2>/dev/null # Discard stderr
# Redirect both independently
command > stdout.log 2> stderr.log
# Here document
cat <<EOF > /etc/myapp.conf
# Configuration generated on $(date)
server_name=${SERVER_NAME}
port=${PORT:-8080}
EOF
# Here document without variable expansion (note the quotes)
cat <<'EOF' > script_template.sh
#!/bin/bash
echo "This $variable is literal, not expanded"
EOF
# Here string
grep "error" <<< "$log_contents"
# Process substitution
diff <(sort file1.txt) <(sort file2.txt)
# Pipeline with error checking
set -o pipefail
cat access.log | grep "500" | awk '{print $1}' | sort -u > failed_ips.txt
# tee -- write to file and stdout
command | tee output.log # Display and save
command | tee -a output.log # Display and append
command 2>&1 | tee debug.log # Capture everything
# File descriptor manipulation
exec 3> custom_output.log # Open fd 3 for writing
echo "Custom log entry" >&3
exec 3>&- # Close fd 3
Process Management
# Run in background
long_running_task &
pid=$!
echo "Started background task with PID: $pid"
# Wait for specific process
wait "$pid"
echo "Task exited with status: $?"
# Wait for all background jobs
job1 &
job2 &
job3 &
wait # Wait for all
# Parallel execution with controlled concurrency
max_jobs=4
for file in /data/*.csv; do
while (( $(jobs -r | wc -l) >= max_jobs )); do
sleep 0.5
done
process_file "$file" &
done
wait
# Trap signals
shutdown() {
echo "Shutting down gracefully..."
# Kill child processes
kill -- -$$ 2>/dev/null || true
exit 0
}
trap shutdown SIGINT SIGTERM
# PID file for singleton enforcement
acquire_lock() {
local pidfile="$1"
if [[ -f "$pidfile" ]]; then
local old_pid
old_pid="$(cat "$pidfile")"
if kill -0 "$old_pid" 2>/dev/null; then
echo "Error: Already running (PID $old_pid)" >&2
return 1
fi
echo "Removing stale PID file" >&2
fi
echo $$ > "$pidfile"
}
release_lock() {
local pidfile="$1"
rm -f "$pidfile"
}
# Timeout a command
timeout 30 long_running_command || {
echo "Command timed out after 30 seconds"
exit 1
}
String Manipulation with Parameter Expansion
No need for sed or awk for simple string operations.
str=" Hello, World! "
# Trim leading/trailing whitespace (Bash trick)
trimmed="${str#"${str%%[![:space:]]*}"}"
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
# Check if string contains substring
if [[ "$str" == *"World"* ]]; then
echo "Contains 'World'"
fi
# Split string into array
IFS=',' read -ra parts <<< "one,two,three,four"
for part in "${parts[@]}"; do
echo "Part: $part"
done
# Join array into string
join_by() {
local IFS="$1"
shift
echo "$*"
}
result=$(join_by ',' "${parts[@]}")
echo "$result" # "one,two,three,four"
# Repeat a character
printf '=%.0s' {1..60}
echo
# Uppercase / lowercase first character
name="john"
echo "${name^}" # "John"
name="JOHN"
echo "${name,}" # "jOHN"
Array Handling
# Indexed arrays
declare -a fruits=("apple" "banana" "cherry")
fruits+=("date") # Append
echo "${fruits[0]}" # First element
echo "${fruits[@]}" # All elements
echo "${#fruits[@]}" # Length
echo "${!fruits[@]}" # All indices
# Slice
echo "${fruits[@]:1:2}" # "banana cherry"
# Remove element (leaves gap)
unset 'fruits[1]'
# Iterate
for fruit in "${fruits[@]}"; do
echo "$fruit"
done
# Associative arrays (Bash 4+)
declare -A config
config[host]="localhost"
config[port]="8080"
config[debug]="true"
# Check if key exists
if [[ -v config[host] ]]; then
echo "Host: ${config[host]}"
fi
# Iterate keys and values
for key in "${!config[@]}"; do
echo "$key = ${config[$key]}"
done
# Array from command output
mapfile -t lines < <(ls -1 /tmp)
echo "Found ${#lines[@]} items in /tmp"
# Array filtering
declare -a evens=()
for n in {1..20}; do
(( n % 2 == 0 )) && evens+=("$n")
done
echo "Evens: ${evens[*]}"
Error Handling Patterns
# Custom error handler
err_handler() {
local line_no="$1"
local command="$2"
local exit_code="$3"
echo "ERROR: Command '${command}' failed at line ${line_no} with exit code ${exit_code}" >&2
}
trap 'err_handler ${LINENO} "${BASH_COMMAND}" $?' ERR
# die function for fatal errors
die() {
echo "FATAL: $*" >&2
exit 1
}
# Retry with exponential backoff
retry() {
local max_attempts="${1:-3}"
local delay="${2:-1}"
shift 2
local attempt=1
until "$@"; do
if (( attempt >= max_attempts )); then
echo "Command failed after $max_attempts attempts: $*" >&2
return 1
fi
echo "Attempt $attempt failed. Retrying in ${delay}s..." >&2
sleep "$delay"
(( attempt++ ))
(( delay *= 2 ))
done
}
# Usage: retry 5 2 curl -sf https://example.com/health
# Require commands to exist
require_cmd() {
for cmd in "$@"; do
command -v "$cmd" >/dev/null 2>&1 || die "Required command not found: $cmd"
done
}
require_cmd git curl jq
# Assert function
assert() {
local description="$1"
shift
if ! "$@"; then
die "Assertion failed: $description"
fi
}
assert "Config file exists" test -f /etc/myapp.conf
File Operations
# Safe temporary files
tmpfile="$(mktemp)"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpfile" "$tmpdir"' EXIT
# Find files with various criteria
find /var/log -name "*.log" -mtime +30 -type f -delete # Delete logs older than 30 days
find . -name "*.sh" -exec chmod +x {} + # Make all .sh files executable
find . -type f -size +100M # Find files over 100MB
# Portable file reading
while IFS= read -r line || [[ -n "$line" ]]; do
echo "$line"
done < "$file"
# Note: || [[ -n "$line" ]] handles files without trailing newline
# Atomic file write (write to temp, then move)
atomic_write() {
local target="$1"
local tmp
tmp="$(mktemp "${target}.XXXXXX")"
if cat > "$tmp" && mv -f "$tmp" "$target"; then
return 0
else
rm -f "$tmp"
return 1
fi
}
echo "new content" | atomic_write /etc/myapp.conf
# Check and create directory
ensure_dir() {
local dir="$1"
if [[ ! -d "$dir" ]]; then
mkdir -p "$dir" || die "Cannot create directory: $dir"
fi
}
# Compare files
if cmp -s file1.txt file2.txt; then
echo "Files are identical"
else
echo "Files differ"
fi
# Basename and dirname without external commands
path="/home/user/docs/report.pdf"
echo "${path##*/}" # "report.pdf" (basename)
echo "${path%/*}" # "/home/user/docs" (dirname)
Portable Scripting (POSIX Compliance)
# Use #!/bin/sh for POSIX scripts, #!/usr/bin/env bash for Bash scripts
# POSIX-compatible alternatives:
# Instead of [[ ]], use [ ] with proper quoting
if [ -f "$file" ] && [ -r "$file" ]; then
echo "File exists and is readable"
fi
# Instead of (( )), use [ ] with -eq, -lt, etc.
if [ "$count" -gt 10 ]; then
echo "Count exceeds 10"
fi
# Instead of $() for arithmetic, use expr or $(( ))
total=$((a + b))
# Instead of arrays (not POSIX), use positional parameters or IFS splitting
# Instead of local (not strictly POSIX), most shells support it anyway
# Instead of Bash-specific string manipulation, use cut, sed, or tr
# Bash: echo "${var,,}"
# POSIX: echo "$var" | tr '[:upper:]' '[:lower:]'
# Use printf instead of echo -e (echo behavior varies across shells)
printf 'Line 1\nLine 2\n'
# Check your scripts with shellcheck
# shellcheck disable=SC2034 -- Inline suppression
# Run: shellcheck -s bash script.sh
Common Patterns
Lockfile Pattern
LOCKFILE="/var/run/${SCRIPT_NAME}.lock"
acquire_lock() {
if ( set -o noclobber; echo $$ > "$LOCKFILE" ) 2>/dev/null; then
trap 'rm -f "$LOCKFILE"' EXIT
return 0
fi
local lock_pid
lock_pid="$(cat "$LOCKFILE" 2>/dev/null)"
if [[ -n "$lock_pid" ]] && kill -0 "$lock_pid" 2>/dev/null; then
echo "Script is already running (PID $lock_pid)" >&2
return 1
fi
echo "Removing stale lock file" >&2
rm -f "$LOCKFILE"
acquire_lock
}
Configuration File Parsing
# Parse a simple key=value config file
declare -A CONFIG
parse_config() {
local config_file="$1"
[[ -f "$config_file" ]] || die "Config file not found: $config_file"
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip blank lines and comments
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
# Extract key and value
local key="${line%%=*}"
local value="${line#*=}"
# Trim whitespace
key="${key#"${key%%[![:space:]]*}"}"
key="${key%"${key##*[![:space:]]}"}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
# Remove surrounding quotes from value
value="${value#\"}"
value="${value%\"}"
CONFIG["$key"]="$value"
done < "$config_file"
}
parse_config /etc/myapp.conf
echo "DB host: ${CONFIG[db_host]:-localhost}"
Logging Framework
LOG_LEVEL="${LOG_LEVEL:-INFO}"
LOG_FILE="${LOG_FILE:-/var/log/myapp.log}"
declare -A LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3 [FATAL]=4)
log() {
local level="$1"
shift
local message="$*"
local current_level="${LOG_LEVELS[${LOG_LEVEL}]:-1}"
local msg_level="${LOG_LEVELS[${level}]:-1}"
(( msg_level < current_level )) && return
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
local entry
entry="$(printf '[%s] [%-5s] [%s:%s] %s' "$timestamp" "$level" "${FUNCNAME[1]:-main}" "${BASH_LINENO[0]}" "$message")"
echo "$entry" >> "$LOG_FILE"
if [[ "$level" == "ERROR" || "$level" == "FATAL" ]]; then
echo "$entry" >&2
fi
}
log INFO "Application started"
log DEBUG "Verbose debugging info"
log ERROR "Something went wrong"
Here Documents and Here Strings
# Here document with variable expansion
generate_html() {
local title="$1"
local body="$2"
cat <<-EOF
<!DOCTYPE html>
<html>
<head><title>${title}</title></head>
<body>${body}</body>
</html>
EOF
}
# Here document passed to a command's stdin
mysql -u root <<SQL
CREATE DATABASE IF NOT EXISTS myapp;
GRANT ALL ON myapp.* TO 'appuser'@'localhost';
SQL
# Here string (Bash extension)
while IFS=, read -r name age city; do
echo "Name: $name, Age: $age, City: $city"
done <<< "Alice,30,NYC
Bob,25,LA
Charlie,35,Chicago"
# Indent-stripped here doc (use <<- with tabs)
if true; then
cat <<-'USAGE'
Usage: command [options]
-h Show help
-v Verbose mode
USAGE
fi
Security Best Practices
# NEVER use eval with user input
# BAD: eval "$user_input"
# BAD: eval "echo $untrusted"
# GOOD: Use arrays and direct execution
# Quote EVERYTHING
rm "$file" # GOOD
rm $file # BAD -- breaks on spaces, globs could expand
# Validate inputs
validate_filename() {
local name="$1"
if [[ "$name" =~ [^a-zA-Z0-9._-] ]]; then
die "Invalid filename: $name (contains special characters)"
fi
if [[ "$name" == ..* || "$name" == */* ]]; then
die "Invalid filename: $name (path traversal attempt)"
fi
}
# Use -- to end option parsing (prevents option injection)
rm -- "$file"
grep -- "$pattern" "$file"
# Restrict PATH
export PATH="/usr/local/bin:/usr/bin:/bin"
# Use secure temp files
tmpfile="$(mktemp)" || die "Failed to create temp file"
chmod 600 "$tmpfile"
# Avoid writing secrets to the command line (visible in ps)
# BAD: mysql -p"$password" ...
# GOOD: Use environment variables or config files
export MYSQL_PWD="$password"
mysql -u root mydb
# Do not store secrets in shell variables that get exported
# If you must, unset them after use
unset MYSQL_PWD
# Prevent glob expansion when not needed
set -f # Disable globbing
# ... process user input ...
set +f # Re-enable globbing
# Drop privileges when running as root
if [[ "$(id -u)" -eq 0 ]]; then
exec su -s /bin/bash nobody -- "$0" "$@"
fi
Useful One-Liners and Idioms
# Check if running as root
(( EUID == 0 )) || die "Must run as root"
# Check if a command exists
command -v docker >/dev/null 2>&1 || die "Docker is not installed"
# Portable way to get the script's directory
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Default variable using :- vs -
# ${var:-default} uses default if var is unset OR empty
# ${var-default} uses default only if var is unset
# Read password without echoing
read -rsp "Enter password: " password
echo
# Confirm before proceeding
confirm() {
read -rp "${1:-Are you sure?} [y/N] " response
[[ "$response" =~ ^[Yy]$ ]]
}
confirm "Delete all files?" || exit 0
# Progress indicator
spin() {
local -a frames=('|' '/' '-' '\')
while true; do
for frame in "${frames[@]}"; do
printf '\r%s %s' "$frame" "$1"
sleep 0.2
done
done
}
spin "Working..." &
spinner_pid=$!
# ... do work ...
kill "$spinner_pid" 2>/dev/null
printf '\rDone. \n'
# Measure execution time
start_time="$(date +%s)"
# ... do work ...
end_time="$(date +%s)"
echo "Elapsed: $(( end_time - start_time )) seconds"
# Generate random string
random_string=$(head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 16)
# Check if stdin is a terminal
if [[ -t 0 ]]; then
echo "Interactive mode"
else
echo "Reading from pipe or file"
fi
# Coalesce empty values
result="${value1:-${value2:-${value3:-fallback}}}"
Script Debugging
# Enable debug tracing
set -x # Print each command before execution
set +x # Disable tracing
# Custom trace prompt for better readability
export PS4='+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:+${FUNCNAME[0]}():} '
# Debug only a section
debug_section() {
set -x
# ... commands to debug ...
set +x
}
# Conditional debugging via environment variable
if [[ "${DEBUG:-}" == "true" ]]; then
set -x
fi
# Debug function that respects verbosity
debug() {
[[ "${VERBOSE:-false}" == "true" ]] && echo "DEBUG: $*" >&2
}
# Trace function calls
trace_calls() {
echo "TRACE: ${FUNCNAME[1]} called from ${FUNCNAME[2]:-main} (line ${BASH_LINENO[1]})" >&2
}
# Dump all variables (useful for debugging)
dump_vars() {
echo "=== Variable Dump ===" >&2
declare -p 2>/dev/null | grep -v ' -[aAirx]' >&2
echo "=== End Dump ===" >&2
}
# Run script in debug mode from the command line:
# bash -x script.sh
# bash -xv script.sh (also shows the script lines being read)
Complete Example: Backup Script
#!/usr/bin/env bash
#
# backup.sh - Incremental backup script with rotation
#
set -euo pipefail
IFS=$'\n\t'
readonly SCRIPT_NAME="$(basename "$0")"
readonly VERSION="1.0.0"
readonly DEFAULT_RETENTION=7
# --- Logging ------------------------------------------------------------------
log() { printf '[%s] [%-5s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" "${*:2}"; }
info() { log INFO "$@"; }
warn() { log WARN "$@"; }
error() { log ERROR "$@" >&2; }
die() { error "$@"; exit 1; }
# --- Cleanup ------------------------------------------------------------------
cleanup() {
local ec=$?
[[ -n "${tmpdir:-}" ]] && rm -rf "$tmpdir"
(( ec != 0 )) && error "Backup failed with exit code $ec"
exit "$ec"
}
trap cleanup EXIT
# --- Usage --------------------------------------------------------------------
usage() {
cat <<HELP
Usage: ${SCRIPT_NAME} [OPTIONS] <source-directory>
Creates a compressed, timestamped backup of the given directory.
Options:
-d, --dest DIR Destination directory (default: /backups)
-r, --retention DAYS Delete backups older than DAYS (default: ${DEFAULT_RETENTION})
-n, --dry-run Show what would be done
-v, --verbose Verbose output
-h, --help Show this help
--version Show version
Examples:
${SCRIPT_NAME} /etc
${SCRIPT_NAME} -d /mnt/nas/backups -r 30 /var/www
HELP
exit "${1:-0}"
}
# --- Parse Arguments ----------------------------------------------------------
dest="/backups"
retention="$DEFAULT_RETENTION"
dry_run=false
verbose=false
source_dir=""
while [[ $# -gt 0 ]]; do
case "$1" in
-d|--dest) dest="${2:?--dest requires a value}"; shift 2 ;;
-r|--retention) retention="${2:?--retention requires a value}"; shift 2 ;;
-n|--dry-run) dry_run=true; shift ;;
-v|--verbose) verbose=true; shift ;;
-h|--help) usage 0 ;;
--version) echo "${SCRIPT_NAME} v${VERSION}"; exit 0 ;;
--) shift; break ;;
-*) die "Unknown option: $1" ;;
*) source_dir="$1"; shift ;;
esac
done
[[ -n "$source_dir" ]] || { error "Source directory required"; usage 1; }
[[ -d "$source_dir" ]] || die "Source is not a directory: $source_dir"
command -v tar >/dev/null || die "tar is required but not found"
# --- Main Logic ---------------------------------------------------------------
main() {
local timestamp
timestamp="$(date '+%Y%m%d-%H%M%S')"
local archive_name
archive_name="backup-$(basename "$source_dir")-${timestamp}.tar.gz"
local archive_path="${dest}/${archive_name}"
info "Backing up: $source_dir -> $archive_path"
if "$dry_run"; then
info "[DRY RUN] Would create: $archive_path"
info "[DRY RUN] Would remove backups older than $retention days"
return 0
fi
mkdir -p "$dest"
tmpdir="$(mktemp -d)"
local tmp_archive="${tmpdir}/${archive_name}"
tar -czf "$tmp_archive" -C "$(dirname "$source_dir")" "$(basename "$source_dir")"
mv "$tmp_archive" "$archive_path"
chmod 600 "$archive_path"
local size
size="$(du -sh "$archive_path" | cut -f1)"
info "Backup complete: $archive_path ($size)"
# Rotate old backups
local deleted=0
while IFS= read -r old_backup; do
rm -f "$old_backup"
(( deleted++ ))
"$verbose" && info "Deleted old backup: $old_backup"
done < <(find "$dest" -name "backup-$(basename "$source_dir")-*.tar.gz" -mtime "+${retention}" -type f)
(( deleted > 0 )) && info "Removed $deleted old backup(s)"
info "Done."
}
main
References
More from 1mangesh1/dev-skills-collection
curl-http
HTTP request construction and API testing with curl and HTTPie. Use when user asks to "test API", "make HTTP request", "curl POST", "send request", "test endpoint", "debug API", "upload file", "check response time", "set auth header", "basic auth with curl", "send JSON", "test webhook", "check status code", "follow redirects", "rate limit testing", "measure API latency", "stress test endpoint", "mock API response", or any HTTP calls from the command line.
28database-indexing
Database indexing internals, index type selection, query plan analysis, and write-overhead tradeoffs across PostgreSQL, MySQL, and MongoDB. Use when user asks to "optimize queries", "create indexes", "fix slow queries", "read EXPLAIN output", "reduce query time", "index strategy", "database performance", "composite index", "covering index", "partial index", "index bloat", "unused indexes", or needs help diagnosing and resolving database performance problems.
13testing-strategies
Testing strategies, patterns, and methodologies across the full testing spectrum. Use when asked about unit tests, integration tests, e2e tests, test pyramid, mocking, test doubles, TDD, property-based testing, snapshot testing, test coverage, mutation testing, contract testing, performance testing, test data management, CI/CD testing, flaky tests, test anti-patterns, test organization, test isolation, test fixtures, test parameterization, or any testing strategy, approach, or methodology.
10secret-scanner
This skill should be used when the user asks to "scan for secrets", "find API keys", "detect credentials", "check for hardcoded passwords", "find leaked tokens", "scan for sensitive keys", "check git history for secrets", "audit repository for credentials", or mentions secret detection, credential scanning, API key exposure, token leakage, password detection, or security key auditing.
10authentication-patterns
Comprehensive authentication and authorization implementation guide. Use when the user asks about JWT tokens, OAuth 2.0 flows, session management, API key auth, SSO setup, SAML, OIDC, password hashing, multi-factor authentication, CSRF protection, token storage, CORS headers, rate limiting auth endpoints, bearer tokens, refresh tokens, OAuth scopes, identity providers, user permissions, role-based access control, attribute-based access control, or any authentication and authorization architecture, implementation, or security patterns.
6ssh-config
SSH key management, config file setup, tunnels, port forwarding, and jump hosts. Use when user asks to "setup SSH keys", "configure SSH", "create SSH tunnel", "add SSH host", "jump host", "port forwarding", "bastion host", "SSH agent", "ssh-copy-id", "SSH proxy", "SSH hardening", "SSH multiplexing", "SSH certificates", "multiple GitHub keys", "ProxyJump", "SSH debug", "SSH escape", "SSH SOCKS proxy", "rsync over SSH", or manage SSH connections.
6