Bash Scripting: Mastering Linux Automation – Part 2: Building Robust and Interactive Scripts

TLDR Icon Too Long; Didn't Read

In Part 1 of this series, we covered the Bash scripting fundamentals conditionals, loops, and text processing with tools like grep and awk. Now we’ll go a step further. It’s one thing to write a script that works, but it’s another to make one that’s reusable, maintainable, and smart enough to handle the unexpected.

A great script shouldn’t require you to edit the code every time you want to run it on a different file or with a new setting. It should handle user input gracefully, fail predictably when things go wrong, and be easy for you (or someone else) to read later without scratching your head.

In this guide, we’ll take your skills to the next level by focusing on three pillars of robust scripting: command-line arguments, modular functions, and advanced error handling. By the end, your scripts won’t just get the job done they’ll feel like proper tools.


Making Your Scripts Flexible: Command-Line Arguments & Options

Let’s say you’ve got a script that processes a log file. If the filename is hardcoded, you’ll need to crack open the script every time you want to work with a different file. That’s not just inefficient it’s annoying. Command-line arguments fix this by letting you pass the filename (or any data) straight into the script when you run it.

There are two primary ways to handle this input: arguments and options.

Arguments (Positional Parameters)

Arguments are the simplest form of input. They are data you pass to a script in a specific order. Bash stores these in special variables called positional parameters.

$1, $2, $3, ...:
These variables hold the individual arguments in the order you pass them. $1 is the first argument, $2 is the second, and so on.
$0:
This one is special. It always holds the name of the script itself. It’s very useful for printing help messages or error messages that reference the script’s name.
$#
This variable contains the total number of arguments passed to the script. It’s incredibly useful for validation for example, to check if the user provided the required number of inputs.
$@ and $*:
These variables represent all the arguments as a single list. "$@" is a better choice for most cases, especially when looping, as it preserves spaces in arguments that are enclosed in quotes. A more advanced, though less common, way to process arguments one by one is to use the shift command, which moves each argument to a lower number.



Let’s look at a simple demo script to see how these work.

#!/usr/bin/env bash
# This shebang is a best practice as it makes the script more portable.
# A script to demonstrate command-line arguments and parameters

# Print the script's name
echo "The script's name is: $0"

# Check if any arguments were provided
if [[ $# -eq 0 ]]; then
  echo "No arguments were provided. Please run the script with some inputs."
  exit 1
fi

# Print the total number of arguments
echo "You provided $# arguments."

# Show the first two arguments
echo "The first argument is: $1"
echo "The second argument is: $2"

# Iterate over all arguments safely
echo "--- Here are all arguments you provided: ---"
for arg in "$@"; do
  echo "- $arg"
done



Output with no arguments:

$ ./script.sh

The script's name is: ./script.sh
No arguments were provided. Please run the script with some inputs.



Output with arguments:

$ ./script.sh 'first arg' second_arg

The script's name is: ./script.sh
You provided 2 arguments.
The first argument is: first arg
The second argument is: second_arg
--- Here are all arguments you provided: ---
- first arg
- second_arg


If you run this with ./script.sh 'first arg' second_arg, you’ll see how "$@" correctly handles the space in 'first arg'. This is a foundational skill that will be the basis for all your interactive scripts.

Options (Flags)

While positional arguments work fine for simple scripts, they quickly become a pain if you have too many inputs or if their order isn’t fixed. Options (or flags) solve this by letting you pass inputs in any order and making it obvious what each one does.

A well-designed script should always have a help option so users know exactly how to run it. This usually means explaining each option and giving usage examples. We can do this neatly with a while loop and Bash’s built-in getopts command. getopts is perfect for handling single-letter flags like -f, -v, or -h making your script both user-friendly and professional.

The basic structure for handling options looks like this:

#!/usr/bin/env bash

# Define default values for our options
VERBOSE=false
FILENAME=""
HELP=false

# A function to display a help message
show_help() {
  echo "Usage: $0 [-f <file>] [-v] [-h]"
  echo "A simple script that demonstrates argument parsing."
  echo ""
  echo "Options:"
  echo "  -f    The file to process."
  echo "  -v    Enable verbose output."
  echo "  -h    Show this help message."
  echo ""
}

# The ':' at the beginning of the string suppresses getopts' default error messages.
# The 'f:' means the -f option requires an argument.
# 'v' and 'h' do not.
while getopts ":f:vh" opt; do
  case $opt in
    f)
      FILENAME="$OPTARG"
      ;;
    v)
      VERBOSE=true
      ;;
    h)
      HELP=true
      ;;
    \?)
      echo "Invalid option: -$OPTARG" >&2
      show_help
      exit 1
      ;;
    :)
      echo "Option -$OPTARG requires an argument." >&2
      show_help
      exit 1
      ;;
  esac
done

# If -h was provided, show the help message and exit
if [[ "$HELP" == "true" ]]; then
  show_help
  exit 0
fi

# Here's where your main script logic goes
if [[ "$VERBOSE" == "true" ]]; then
  echo "Verbose mode enabled."
fi

if [[ -n "$FILENAME" ]]; then
  echo "Processing file: $FILENAME"
else
  echo "No file was specified. Exiting."
  exit 1
fi



How getopts works:

while getopts ":f:vh" opt; do :
This loop processes each option. getopts stores the option it finds in the opt variable.
f: :
The colon after f tells getopts that this option requires a value, which will be stored in the special OPTARG variable.
case $opt in ... esac :
This case statement checks the value of opt and executes the corresponding code block.
\?) :
This case handles any invalid option.
:) :
This case handles an option that requires an argument but didn’t receive one.


Mastering getopts and case statements is a surefire way to make your scripts feel professional and robust.


Modular Scripting with Functions & Variable Scope

Once you’ve got your if statements and for loops under control, the next big step is making your scripts modular. If you notice yourself copy-pasting the same code in multiple places, that’s your cue to start using functions.

Functions let you group a set of commands into a single, reusable unit. Instead of scattering the same logic throughout your script, you can call it whenever you need it. The result? Cleaner code, fewer mistakes, and a script that’s far easier to maintain and debug.

The Golden Rule: Use local

One of the easiest mistakes for new scripters to make is letting a variable inside a function accidentally overwrite a global variable with the same name. The fix is simple: you always declare variables inside a function with the local keyword. This keeps the variable’s scope locked to the function and prevents unexpected side effects elsewhere in your script.

Let’s see a powerful example that demonstrates this golden rule.

#!/usr/bin/env bash

# This is a global variable
LOG_FILE="/var/log/my_app.log"

# A function that modifies a variable without 'local'
log_message_bad() {
  # This will overwrite the global 'LOG_FILE'
  LOG_FILE="~/bad_log.txt"
  echo "Writing message to: $LOG_FILE"
}

# A function that correctly uses 'local'
log_message_good() {
  # This creates a local variable 'log_path'
  local log_path="~/good_log.txt"
  echo "Writing message to: $log_path"
}

echo "--- Before calling functions ---"
echo "Global log file is: $LOG_FILE"

log_message_bad
echo "--- After calling bad function ---"
echo "Global log file is now: $LOG_FILE"

echo ""

log_message_good
echo "--- After calling good function ---"
echo "Global log file is still: $LOG_FILE"



Output:

$ ./script.sh

--- Before calling functions ---
Global log file is: /var/log/my_app.log
Writing message to: /home/user/bad_log.txt
--- After calling bad function ---
Global log file is now: /home/user/bad_log.txt

Writing message to: /home/user/good_log.txt
--- After calling good function ---
Global log file is still: /var/log/my_app.log


The output of this script clearly shows why using local is a non-negotiable best practice. log_message_bad has an unwanted side effect on the rest of the script, while log_message_good operates cleanly and predictably.

Building a Function Library

When your scripts start to rely on multiple functions, it’s worth keeping them organized. Instead of cramming everything into one giant file, we can organize a collection of related functions into a separate file.This way, you can just source the file into any script that needs those functions instead of rewriting them each time.

my_lib.sh A library of reusable functions:

# my_lib.sh - A library of reusable functions

# Function to check if a directory exists
check_dir() {
  local dir_path="$1"
  if [[ -d "$dir_path" ]]; then
    echo "SUCCESS: Directory '$dir_path' exists."
    return 0
  else
    echo "ERROR: Directory '$dir_path' not found."
    return 1
  fi
}

# Function to get a timestamp
get_timestamp() {
  echo "$(date +%Y-%m-%d_%H-%M-%S)"
}

main_script.sh Using the function library:

#!/usr/bin/env bash

# Source our function library
source my_lib.sh

# Now we can call the functions from the library
echo "Checking directory..."
check_dir "/etc"

echo "Checking a non-existent directory..."
check_dir "/path/that/does/not/exist"

echo "Current timestamp is: $(get_timestamp)"

Function libraries make your scripts modular, reusable, and easier to maintain. Instead of stuffing everything into one massive file, you keep the main script focused on the workflow, while your library handles the heavy lifting.


Advanced Error Handling: Trapping Signals and Using Exit Codes

In our first article, we covered the set -euo pipefail command a simple but powerful way to make scripts exit immediately on errors. That’s a solid foundation for robustness. But what if your script needs to tidy up temporary files or send you a failure alert before it crashes? That’s where advanced error handling steps in.

Using trap for Graceful Exits

The trap command is a built-in tool that catches (or “traps”) signals sent to your script. One of the most useful signals to trap is EXIT, which triggers right before your script finishes whether it succeeds or crashes.

Pro-Tip: You can also trap other signals, like INT (from a user pressing Ctrl+C) and TERM (from a system kill command), to ensure your script performs cleanup even when it’s interrupted.

#!/usr/bin/env bash
set -euo pipefail

# Create a temporary file
TEMP_FILE=$(mktemp)
echo "This is a temporary file." > "$TEMP_FILE"
echo "Temporary file created at: $TEMP_FILE"

# Define a cleanup function
cleanup() {
  echo "Script is exiting. Cleaning up temporary file..."
  rm -f "$TEMP_FILE"
  echo "Cleanup complete."
}

# Use trap to call the cleanup function when the script exits, or is interrupted
trap cleanup EXIT INT TERM

# Simulate a problem that causes the script to crash
# The cleanup function will still be called!
# Let's say we have to divide by zero:
# bad_result=$(( 10 / 0 ))

echo "Script has finished successfully."
# If we reach this point, the cleanup function will still be called automatically

If you uncomment the bad_result line and run the script, you’ll see the cleanup function is still executed before the script crashes. This is a game-changer for writing reliable, production-ready scripts.

Communicating with Exit Codes

Every command in Bash returns an exit code (also known as a return status) from 0 to 255. This code tells the system whether the command was successful or not.

  • 0: Indicates success. By convention, a zero exit code means “everything went as expected.”
  • 1-255: Indicates an error. You can use different non-zero values to signal specific problems.

You can view the exit code of the last executed command with the special variable $?.

# A successful command
ls /
echo $?

# An unsuccessful command
ls /non/existent/path
echo $?



Output:

$ ls /
bin   boot  dev   etc   home  lib   lib64  media  mnt  opt   proc  root  run   sbin  srv  sys  tmp  usr  var
$ echo $?
0

$ ls /non/existent/path
ls: cannot access '/non/existent/path': No such file or directory
$ echo $?
2


You can use the exit command in your scripts to terminate execution and return a specific exit code.

#!/usr/bin/env bash
# script.sh

if [[ $# -ne 1 ]]; then
  echo "Error: Please provide exactly one argument."
  exit 1
fi

echo "Success! Received one argument."
exit 0

By using exit with specific codes, you can build scripts that can be easily integrated into larger automation systems.


The Hands-On Project: A Robust System Health Check Script

Now let’s tie all these concepts together into a single, practical example: a script that performs a basic health check on your system. It will accept arguments, use functions for modularity, and handle errors gracefully just like a production-ready tool should.

A note on production scripts: In this demo, the script is set to remove its temporary log file on EXIT. In a real-world setup, you might tweak this. For example, you could:

  • Delete the log only on failure (trap cleanup ERR)
  • Keep the log persistent for later review

These decisions are part of what separates a good script from a great one they’re not just about functionality, but about how the tool fits into your workflow and operational needs.

The goal is to create a script that checks a few critical metrics and can be run with a -v flag for verbose output.

#!/usr/bin/env bash
set -euo pipefail

# --- Global Variables and Configuration ---
VERBOSITY=0
LOG_FILE="/tmp/health_check_$(date +%Y%m%d%H%M%S).log"
# Create the temporary log file
touch "$LOG_FILE"

# --- Function Library ---

# A function to log messages to the console and log file
log() {
  local level="$1"
  local message="$2"
  local timestamp=$(date +"%Y-%m-%d %H:%M:%S")
  echo "[$timestamp] [$level] $message" | tee -a "$LOG_FILE"
}

# A function to display a help message
show_help() {
  log INFO "Usage: $0 [-v] [-h]"
  log INFO "Performs a system health check."
  log INFO ""
  log INFO "Options:"
  log INFO "  -v    Enable verbose output. Displays all checks."
  log INFO "  -h    Show this help message and exit."
}

# A cleanup function to remove the temporary log file on exit
cleanup() {
  log INFO "Script is finished. Removing temporary log file."
  rm -f "$LOG_FILE"
}

# --- Health Check Functions ---

check_disk_space() {
  local disk_limit_percent=90
  local current_usage=$(df -h | grep '/$' | awk '{print $5}' | sed 's/%//g')
  if [[ "$current_usage" -gt "$disk_limit_percent" ]]; then
    log ERROR "CRITICAL: Disk usage is at ${current_usage}%!"
    return 1
  else
    if [[ "$VERBOSITY" -eq 1 ]]; then
      log INFO "Disk usage is good: ${current_usage}%."
    fi
  fi
  return 0
}

check_running_services() {
  local service_name="ssh"
  if systemctl is-active --quiet "$service_name"; then
    if [[ "$VERBOSITY" -eq 1 ]]; then
      log INFO "Service '$service_name' is running."
    fi
  else
    log ERROR "CRITICAL: Service '$service_name' is not running!"
    return 1
  fi
  return 0
}

# --- Main Script Logic ---

# Trap the EXIT signal to ensure cleanup is always run
trap cleanup EXIT INT TERM

# Parse command-line options
while getopts ":vh" opt; do
  case $opt in
    v)
      VERBOSITY=1
      ;;
    h)
      show_help
      exit 0
      ;;
    \?)
      log ERROR "Invalid option: -$OPTARG"
      show_help
      exit 1
      ;;
  esac
done

# Perform all checks
log INFO "Starting system health check..."
FINAL_STATUS=0

check_disk_space || FINAL_STATUS=1
check_running_services || FINAL_STATUS=1

if [[ "$FINAL_STATUS" -eq 0 ]]; then
  log INFO "All checks passed successfully."
else
  log ERROR "One or more checks failed. Please see the log file for details."
fi

# Exit with the final status code
exit "$FINAL_STATUS"



Output for all checks pass (non-verbose):

$ ./health_check.sh

[2025-08-15 13:45:00] [INFO] Starting system health check...
[2025-08-15 13:45:00] [INFO] All checks passed successfully.
[2025-08-15 13:45:00] [INFO] Script is finished. Removing temporary log file.



Output for a check fails (non-verbose):

$ ./health_check.sh
[2025-08-15 13:45:00] [INFO] Starting system health check...
[2025-08-15 13:45:00] [ERROR] CRITICAL: Disk usage is at 92%!
[2025-08-15 13:45:00] [ERROR] One or more checks failed. Please see the log file for details.
[2025-08-15 13:45:00] [INFO] Script is finished. Removing temporary log file.



This script is a perfect example of everything we’ve covered:

It uses command-line options (-v and -h) to control its behavior.

It’s modular, using functions to perform specific, reusable checks. The log function is a great example of a simple function library.

It uses a trap to guarantee cleanup of the log file, regardless of whether it succeeds or fails.

It returns a meaningful exit code (0 or 1) so other automation tools can tell if the check passed.

By using these techniques, you’re not just writing scripts; you’re building professional-grade tools that are robust, reusable, and easy to maintain. Your journey to mastering Linux automation has now truly begun.

Please follow and like us:
Abishek D Praphullalumar
Love to know your thoughts on this:

      Leave a reply


      PixelHowl HQ
      Your ultimate playground for DevOps adventures, thrilling tech news, and super-fun tutorials. Let's build the future, together!
      Chat with us!
      connect@pixelhowl.com
      Feel free to discuss your ideas with us!
      © 2025 PixelHowl. All rights reserved. Made with ♥ by tech enthusiasts.
      PixelHowl
      Logo