Production-Quality Scripts and Templates
Summary: in this tutorial, you will learn learn to handle temporary files safely, prevent concurrent execution with lockfiles, and build production-ready script templates.
Production-Quality Scripts and Templates
Writing scripts that work on your machine is one thing — writing scripts that run reliably in production is another. This tutorial covers the patterns and practices that separate quick-and-dirty scripts from production-quality automation: safe temporary files, lockfiles, cleanup handlers, and a battle-tested script template.
Temporary Files and Cleanup
mktemp — Secure Temporary Files
# Create a secure temporary file
tmp_file=$(mktemp)
echo "Temp file: $tmp_file"
# Output: /tmp/tmp.Xa8b2c3d
# Create temporary file with custom pattern
tmp_file=$(mktemp /tmp/myapp.XXXXXX)
echo "Temp file: $tmp_file"
# Output: /tmp/myapp.b4e7f9
# Create temporary directory
tmp_dir=$(mktemp -d)
echo "Temp directory: $tmp_dir"
# Output: /tmp/tmp.d8h3k9s
# Always clean up temporary files with trap
cleanup() {
rm -rf "$tmp_file" "$tmp_dir"
}
trap cleanup EXIT
Why mktemp?
- Security: Creates files with random names, preventing race conditions
- Permissions: Files created with mode 0600 (owner read/write only)
- Uniqueness: Guaranteed unique filename
Bad practice (DON'T DO THIS):
# INSECURE: Predictable name, race condition vulnerability
tmp_file="/tmp/myapp_$$"
# An attacker could create this file first as a symlink to /etc/passwd
# When your script writes to it, you corrupt /etc/passwd!
Comprehensive Cleanup with trap
#!/usr/bin/env bash
set -euo pipefail
# Track all resources that need cleanup
TEMP_FILES=()
TEMP_DIRS=()
BACKGROUND_PIDS=()
# Cleanup function
cleanup() {
local exit_code=$?
# Kill background processes
for pid in "${BACKGROUND_PIDS[@]:-}"; do
if kill -0 "$pid" 2>/dev/null; then
echo "Stopping background process $pid..."
kill "$pid" 2>/dev/null || true
fi
done
# Remove temporary files
for file in "${TEMP_FILES[@]:-}"; do
[[ -f "$file" ]] && rm -f "$file"
done
# Remove temporary directories
for dir in "${TEMP_DIRS[@]:-}"; do
[[ -d "$dir" ]] && rm -rf "$dir"
done
exit $exit_code
}
trap cleanup EXIT INT TERM
# Usage:
tmp_file=$(mktemp)
TEMP_FILES+=("$tmp_file")
tmp_dir=$(mktemp -d)
TEMP_DIRS+=("$tmp_dir")
# Start background process
some_command &
BACKGROUND_PIDS+=($!)
# Cleanup happens automatically on:
# - Normal exit
# - Error (with set -e)
# - Ctrl+C (INT)
# - kill (TERM)
Lockfiles — Prevent Concurrent Execution
Prevent multiple instances of a script from running simultaneously:
#!/usr/bin/env bash
set -euo pipefail
LOCKFILE="/var/run/$(basename "$0").lock"
# Method 1: Simple directory-based lock (atomic operation)
acquire_lock() {
if ! mkdir "$LOCKFILE" 2>/dev/null; then
if [[ -f "$LOCKFILE/pid" ]]; then
local pid=$(cat "$LOCKFILE/pid")
if kill -0 "$pid" 2>/dev/null; then
echo "Another instance is running (PID: $pid)" >&2
exit 1
else
echo "Removing stale lock from dead process $pid"
rm -rf "$LOCKFILE"
mkdir "$LOCKFILE"
fi
else
echo "Another instance is running" >&2
exit 1
fi
fi
# Store our PID
echo $$ > "$LOCKFILE/pid"
trap "rm -rf '$LOCKFILE'" EXIT
}
acquire_lock
# Your script logic here...
echo "Running (PID: $$)"
sleep 30
# Method 2: Using flock (more robust on Linux)
LOCKFD=200
eval "exec $LOCKFD>$LOCKFILE"
if ! flock -n $LOCKFD; then
echo "Another instance is already running" >&2
exit 1
fi
trap "rm -f '$LOCKFILE'" EXIT
# Script runs with lock held
Production-Quality Script Template
Here's a comprehensive template incorporating all best practices:
#!/usr/bin/env bash
#
# script_name.sh — Brief one-line description
#
# DESCRIPTION:
# Detailed description of what this script does, its purpose,
# and any important notes about its behavior.
#
# USAGE:
# ./script_name.sh [OPTIONS] ARGUMENTS
#
# OPTIONS:
# -v, --verbose Enable verbose output
# -d, --dry-run Show what would be done without actually doing it
# -c, --config FILE Configuration file
# -h, --help Show this help message
# --version Show version information
#
# EXAMPLES:
# ./script_name.sh --verbose input.txt
# ./script_name.sh --config=app.conf --dry-run data/
#
# AUTHOR: Your Name <your.email@example.com>
# VERSION: 1.0.0
# DATE: 2026-02-11
set -euo pipefail
IFS=$'\n\t'
# ==================== Configuration ====================
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly SCRIPT_VERSION="1.0.0"
readonly LOCKFILE="/var/run/${SCRIPT_NAME}.lock"
# ==================== Color Output ====================
# Only use colors if outputting to a terminal
if [[ -t 1 ]]; then
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[0;33m'
readonly BLUE='\033[0;34m'
readonly BOLD='\033[1m'
readonly NC='\033[0m' # No Color
else
readonly RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC=''
fi
# ==================== Logging Functions ====================
log() { echo -e "${BLUE}[INFO]${NC} $(date '+%H:%M:%S') $*"; }
success() { echo -e "${GREEN}[OK]${NC} $(date '+%H:%M:%S') $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%H:%M:%S') $*" >&2; }
error() { echo -e "${RED}[ERROR]${NC} $(date '+%H:%M:%S') $*" >&2; }
die() { error "$*"; exit 1; }
# ==================== Error Handler ====================
on_error() {
error "Script failed at line $1: $2"
}
trap 'on_error $LINENO "$BASH_COMMAND"' ERR
# ==================== Cleanup ====================
TEMP_FILES=()
TEMP_DIRS=()
cleanup() {
for file in "${TEMP_FILES[@]:-}"; do
[[ -f "$file" ]] && rm -f "$file"
done
for dir in "${TEMP_DIRS[@]:-}"; do
[[ -d "$dir" ]] && rm -rf "$dir"
done
[[ -d "$LOCKFILE" ]] && rm -rf "$LOCKFILE"
}
trap cleanup EXIT INT TERM
# ==================== Functions ====================
usage() {
cat << EOF
${BOLD}NAME${NC}
$SCRIPT_NAME — Brief description
${BOLD}SYNOPSIS${NC}
$SCRIPT_NAME [OPTIONS] ARGUMENTS
${BOLD}DESCRIPTION${NC}
Detailed description of what this script does.
${BOLD}OPTIONS${NC}
-v, --verbose Enable verbose output
-d, --dry-run Show what would be done without doing it
-c, --config FILE Configuration file
-h, --help Show this help message
--version Show version information
${BOLD}EXAMPLES${NC}
$SCRIPT_NAME --verbose input.txt
$SCRIPT_NAME --config=app.conf --dry-run data/
${BOLD}EXIT CODES${NC}
0 Success
1 General error
2 Missing arguments
3 File not found
4 Permission denied
${BOLD}AUTHOR${NC}
Your Name <your.email@example.com>
${BOLD}VERSION${NC}
$SCRIPT_VERSION
EOF
}
check_dependencies() {
local deps=("curl" "jq" "awk")
local missing=()
for dep in "${deps[@]}"; do
if ! command -v "$dep" >/dev/null 2>&1; then
missing+=("$dep")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
error "Missing required dependencies: ${missing[*]}"
die "Install with: apt install ${missing[*]}"
fi
}
acquire_lock() {
if ! mkdir "$LOCKFILE" 2>/dev/null; then
die "Another instance is already running"
fi
echo $$ > "$LOCKFILE/pid"
}
# ==================== Argument Parsing ====================
VERBOSE=false
DRY_RUN=false
CONFIG=""
ARGS=()
while [[ $# -gt 0 ]]; do
case $1 in
-v|--verbose)
VERBOSE=true
shift
;;
-d|--dry-run)
DRY_RUN=true
shift
;;
-c|--config)
CONFIG="$2"
shift 2
;;
--config=*)
CONFIG="${1#*=}"
shift
;;
-h|--help)
usage
exit 0
;;
--version)
echo "$SCRIPT_NAME version $SCRIPT_VERSION"
exit 0
;;
--)
shift
ARGS+=("$@")
break
;;
-*)
die "Unknown option: $1. Use --help for usage information."
;;
*)
ARGS+=("$1")
shift
;;
esac
done
# ==================== Validation ====================
[[ ${#ARGS[@]} -eq 0 ]] && die "No arguments provided. Use --help for usage."
# ==================== Main Function ====================
main() {
acquire_lock
check_dependencies
log "Starting $SCRIPT_NAME v$SCRIPT_VERSION"
$VERBOSE && log "Verbose mode enabled"
$DRY_RUN && warn "Dry-run mode — no changes will be made"
[[ -n "$CONFIG" ]] && log "Using config: $CONFIG"
for arg in "${ARGS[@]}"; do
log "Processing: $arg"
if $DRY_RUN; then
log "Would process: $arg"
else
# Actual processing logic here
:
fi
done
success "Completed successfully!"
}
# ==================== Entry Point ====================
main
Exercises
Task: This script has bugs that strict mode would catch. Identify them and fix with strict mode:
#!/bin/bash
TARGET_DIR="/backup"
rm -rf $TARGER_DIR/*
echo "Cleaned $TARGET_DIR"
count = 5
echo "Processed $count files"
Show Solution
#!/usr/bin/env bash
set -euo pipefail
# Bugs fixed:
# 1. Typo: $TARGER_DIR → $TARGET_DIR (set -u catches this)
# 2. Space around = in variable assignment (syntax error)
# 3. Added strict mode to catch future issues
# 4. Quoted variable to prevent word splitting
readonly TARGET_DIR="/backup"
if [[ ! -d "$TARGET_DIR" ]]; then
echo "Error: Directory does not exist: $TARGET_DIR" >&2
exit 1
fi
rm -rf "${TARGET_DIR:?}"/*
echo "Cleaned $TARGET_DIR"
count=5
echo "Processed $count files"
Task: Write a script that:
- Downloads a file from a URL with retry logic (3 attempts, 5-second delay)
- Verifies the download with SHA256 checksum
- Logs all attempts and results
- Uses strict mode and proper error handling
- Cleans up partial downloads on failure
Show Solution
#!/usr/bin/env bash
set -euo pipefail
LOGFILE="/tmp/downloader.log"
TEMP_FILES=()
log() { echo "[$(date '+%H:%M:%S')] INFO: $*" | tee -a "$LOGFILE"; }
error() { echo "[$(date '+%H:%M:%S')] ERROR: $*" | tee -a "$LOGFILE" >&2; }
cleanup() {
for file in "${TEMP_FILES[@]:-}"; do
[[ -f "$file" ]] && rm -f "$file"
done
}
trap cleanup EXIT
download_with_retry() {
local url="$1"
local output="$2"
local expected_sha="$3"
local max_attempts=3
local delay=5
local attempt=1
local temp_file
temp_file=$(mktemp)
TEMP_FILES+=("$temp_file")
while [[ $attempt -le $max_attempts ]]; do
log "Attempt $attempt/$max_attempts: Downloading $url"
if curl -fsSL -o "$temp_file" "$url" 2>/dev/null; then
log "Download complete, verifying checksum..."
local actual_sha
actual_sha=$(sha256sum "$temp_file" | awk '{print $1}')
if [[ "$actual_sha" == "$expected_sha" ]]; then
mv "$temp_file" "$output"
log "SUCCESS: File verified and saved to $output"
return 0
else
error "Checksum mismatch! Expected: $expected_sha, Got: $actual_sha"
fi
else
error "Download failed on attempt $attempt"
fi
if [[ $attempt -lt $max_attempts ]]; then
log "Retrying in ${delay}s..."
sleep "$delay"
fi
((attempt++))
done
error "All $max_attempts attempts failed"
return 1
}
# Usage
if [[ $# -ne 3 ]]; then
echo "Usage: $0 URL OUTPUT_FILE EXPECTED_SHA256" >&2
exit 1
fi
download_with_retry "$1" "$2" "$3"
Task: Create a production-quality backup script that:
- Uses strict mode and comprehensive error handling
- Parses arguments with getopts (-s source, -d destination, -v verbose, -c compress)
- Creates timestamped backups
- Uses lockfile to prevent concurrent runs
- Logs all operations
- Cleans up on errors
- Validates all inputs
Show Solution
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
readonly SCRIPT_NAME="$(basename "$0")"
readonly LOCKFILE="/var/run/${SCRIPT_NAME}.lock"
readonly LOGFILE="/var/log/backup.log"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO: $*" | tee -a "$LOGFILE"; }
error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" | tee -a "$LOGFILE" >&2; }
die() { error "$*"; exit 1; }
cleanup() {
[[ -d "$LOCKFILE" ]] && rm -rf "$LOCKFILE"
}
trap cleanup EXIT INT TERM
on_error() {
error "Script failed at line $1: $2"
}
trap 'on_error $LINENO "$BASH_COMMAND"' ERR
acquire_lock() {
if ! mkdir "$LOCKFILE" 2>/dev/null; then
die "Another backup is already running"
fi
echo $$ > "$LOCKFILE/pid"
}
usage() {
cat << EOF
Usage: $SCRIPT_NAME -s SOURCE -d DESTINATION [OPTIONS]
Create timestamped backups.
REQUIRED:
-s SOURCE Source directory to backup
-d DESTINATION Destination directory for backups
OPTIONS:
-v Verbose output
-c Compress backup with gzip
-h Show this help
EXAMPLE:
$SCRIPT_NAME -s /home/alice -d /backup -v -c
EOF
}
SOURCE=""
DESTINATION=""
VERBOSE=false
COMPRESS=false
while getopts ":s:d:vch" opt; do
case $opt in
s) SOURCE="$OPTARG" ;;
d) DESTINATION="$OPTARG" ;;
v) VERBOSE=true ;;
c) COMPRESS=true ;;
h) usage; exit 0 ;;
:) die "-$OPTARG requires an argument" ;;
\?) die "Unknown option: -$OPTARG" ;;
esac
done
[[ -z "$SOURCE" ]] && die "Source is required. Use -h for help."
[[ -z "$DESTINATION" ]] && die "Destination is required. Use -h for help."
[[ -d "$SOURCE" ]] || die "Source does not exist: $SOURCE"
[[ -d "$DESTINATION" ]] || mkdir -p "$DESTINATION" || die "Cannot create destination: $DESTINATION"
[[ -r "$SOURCE" ]] || die "Cannot read source: $SOURCE"
[[ -w "$DESTINATION" ]] || die "Cannot write to destination: $DESTINATION"
acquire_lock
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="backup_$(basename "$SOURCE")_${TIMESTAMP}"
if $COMPRESS; then
BACKUP_FILE="$DESTINATION/${BACKUP_NAME}.tar.gz"
else
BACKUP_FILE="$DESTINATION/${BACKUP_NAME}.tar"
fi
log "Starting backup"
log "Source: $SOURCE"
log "Destination: $BACKUP_FILE"
$VERBOSE && log "Verbose mode enabled"
if $COMPRESS; then
log "Creating compressed backup..."
if tar czf "$BACKUP_FILE" -C "$(dirname "$SOURCE")" "$(basename "$SOURCE")" 2>&1 | tee -a "$LOGFILE"; then
log "Backup created successfully"
else
die "Backup failed"
fi
else
log "Creating backup..."
if tar cf "$BACKUP_FILE" -C "$(dirname "$SOURCE")" "$(basename "$SOURCE")" 2>&1 | tee -a "$LOGFILE"; then
log "Backup created successfully"
else
die "Backup failed"
fi
fi
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
log "Backup size: $SIZE"
log "Backup complete: $BACKUP_FILE"
Summary
You now have the tools for professional-quality Bash scripting:
Strict Mode (use in every script):
set -euo pipefail
IFS=$'\n\t'
Error Handling:
- Meaningful exit codes
- Logging functions (log, warn, error, die)
trap ERRfor error context- Retry logic for flaky operations
Debugging:
set -xfor tracing- Custom PS4 prompt
- Conditional debug output
- Syntax checking with
bash -n
Argument Parsing:
getoptsfor short options- Manual parsing for long options
- Comprehensive validation
Best Practices:
- Secure temporary files with
mktemp - Cleanup with
trap EXIT - Lockfiles for single-instance scripts
- Dependency checking
- Comprehensive documentation
Next: Apply these techniques to real-world automation scenarios and build reliable production scripts.
Written by the ShellRAG Team
The ShellRAG editorial team writes practical, beginner-friendly Bash Shell tutorials with tested code examples and real-world use cases. Every article is technically reviewed for accuracy and updated regularly.
Learn more about us →