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

🏋️ Exercise 1: Strict Mode Practice

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"
 
🏋️ Exercise 2: Error Handling Challenge

Task: Write a script that:

  1. Downloads a file from a URL with retry logic (3 attempts, 5-second delay)
  2. Verifies the download with SHA256 checksum
  3. Logs all attempts and results
  4. Uses strict mode and proper error handling
  5. 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"
 
🏋️ Exercise 3: Complete Production Script

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 ERR for error context
  • Retry logic for flaky operations

Debugging:

  • set -x for tracing
  • Custom PS4 prompt
  • Conditional debug output
  • Syntax checking with bash -n

Argument Parsing:

  • getopts for 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.

Was this page helpful?
SR

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 →