Best Tech & Security Platform
Followed by 1000+

GEANTECHNOLOGY

Your Trusted Source for IT Tutorials and Tech Insights

Donate

How to Learn Bash Scripting: A Step-by-Step Guide for Absolute Beginners

Aug 6, 2026 ahmed mokdad 11 min read

Bash scripting turns repetitive terminal work into one-click automation. If you have ever typed the same sequence of commands more than twice, you need a script. This guide assumes you have never written one before. We will build your skills one step at a time, from your first “Hello World” to a script you can actually use at work.

By the end of this guide, you will write, run, and debug Bash scripts using variables, conditionals, loops, arrays, and safe error handling.

Table of Contents

  1. Step 1: Write and Run Your First Script
  2. Step 2: Store Data with Variables
  3. Step 3: Ask the User for Input
  4. Step 4: Check Files Before You Touch Them
  5. Step 5: Make Decisions with If Statements
  6. Step 6: Repeat Tasks with Loops
  7. Step 7: Control Streams and Redirection
  8. Step 8: Pass Arguments to Your Script
  9. Step 9: Work with Arrays
  10. Step 10: Add Safety and Best Practices
  11. Build Your First Real Script
  12. Conclusion and Next Steps

Step 1: Write and Run Your First Script

A Bash script is just a text file that lists commands the shell runs in order. Open any text editor and create a file named hello.sh.

Add the Shebang

The first line tells the system which interpreter to use. This line is called the shebang.

#!/bin/bash
echo "Hello, World!"

Save the file. Before you run it, give the file permission to execute:

chmod +x hello.sh
./hello.sh

The ./ tells the shell to look for the script in the current folder. If you skip chmod +x, the shell refuses to run the file because it does not trust unknown code by default.

Beginner tip: If #!/bin/bash does not work on your system, run which bash to find the correct path and use that instead.

Step 2: Store Data with Variables

Variables let your script remember values and reuse them later. You do not declare types. You just assign a value.

#!/bin/bash
username="alex"
echo "Welcome, $username"

The $ sign tells Bash to replace the variable name with its value. Without the $, Bash prints the word “username” literally.

Variables with Command Output

You can also store the result of a command inside a variable:

#!/bin/bash
current_date=$(date)
echo "Today is $current_date"

The $( ) syntax runs the command inside and captures whatever it prints. This technique powers almost every real-world script you will write.

Step 3: Ask the User for Input

Scripts become interactive when they pause and wait for the user to type something.

#!/bin/bash
read -p "Enter your project name: " project_name
echo "Creating folder for $project_name..."
mkdir "$project_name"

The read command stores whatever the user types into the variable project_name. The -p flag shows a prompt so the user knows what to type.

Reading Secret Input

When you ask for passwords, hide the characters:

#!/bin/bash
read -sp "Enter your API key: " api_key
echo ""
echo "Key stored securely."

The -s flag suppresses the characters on screen. The empty echo moves the cursor to a new line after the hidden input.

Step 4: Check Files Before You Touch Them

Scripts crash when they assume files exist. Bash gives you built-in tests to verify paths before acting.

Common File Tests

TestWhat It Checks
-fPath exists and is a regular file
-dPath exists and is a directory
-rFile is readable
-wFile is writable
-xFile is executable
-sFile exists and is not empty
-ePath exists (any type)

Here is a safe backup pattern:

#!/bin/bash
source_file="/etc/nginx/nginx.conf"
backup_dir="/backup/nginx"

if [[ -f "$source_file" ]]; then
    echo "Source file found."
    
    if [[ ! -d "$backup_dir" ]]; then
        mkdir -p "$backup_dir"
        echo "Created backup directory."
    fi
    
    cp "$source_file" "$backup_dir/nginx.conf.backup"
    echo "Backup completed."
else
    echo "ERROR: $source_file not found." >&2
    exit 1
fi

This script validates every assumption. It checks that the source file exists, creates the destination directory only when needed, and exits with a clear error message when something goes wrong.

Checking File Age

Use -nt and -ot to compare timestamps:

#!/bin/bash
if [[ "app.log" -nt "app.log.1" ]]; then
    echo "Rotating logs..."
    mv app.log app.log.1
    touch app.log
fi

This prevents overwriting fresh data with stale backups.

Step 5: Make Decisions with If Statements

Conditionals let your script choose different paths based on tests.

Always Use Double Brackets

Write [[ ]] instead of [ ]. The double bracket is a Bash built-in that handles strings safely and supports pattern matching.

#!/bin/bash
filename="report.csv"

if [[ "$filename" == *.csv ]]; then
    echo "Processing CSV file..."
fi

Full If-Else Structure

#!/bin/bash
usage=$(df / | awk 'NR==2 {print $5}' | tr -d '%')

if [[ "$usage" -gt 90 ]]; then
    echo "CRITICAL: Disk at ${usage}%." >&2
    exit 1
elif [[ "$usage" -gt 75 ]]; then
    echo "WARNING: Disk at ${usage}%."
else
    echo "OK: Disk at ${usage}%."
fi

This function returns different messages based on severity. The calling script can decide whether to alert, page, or continue.

Combining Conditions

#!/bin/bash
if [[ -f "$config_file" ]] && [[ -r "$config_file" ]]; then
    source "$config_file"
elif [[ ! -f "$config_file" ]]; then
    echo "Config missing. Using defaults."
else
    echo "Config exists but is not readable." >&2
    exit 1
fi

The && operator requires both conditions to pass. The || operator passes if either condition passes.

Step 6: Repeat Tasks with Loops

Loops process collections and repeat tasks without copy-pasting code.

For Loops with Ranges

Generate sequences without external tools:

#!/bin/bash
for instance in {1..5}; do
    echo "Provisioning worker-${instance}..."
done

This creates worker-1 through worker-5. The brace expansion happens before the loop runs.

For Loops with Files

Process files found by another command:

#!/bin/bash
OLDIFS="$IFS"
IFS=$'\n'

for config in $(find /etc/nginx/sites-enabled -maxdepth 1 -type f); do
    echo "Testing $config..."
    nginx -t -c "$config" || echo "FAILED: $config" >&2
done

IFS="$OLDIFS"

The IFS change prevents splitting on spaces within filenames. Always save the old value and restore it after the loop.

While Loops with Counters

#!/bin/bash
retry_count=0
max_retries=5

while [[ "$retry_count" -lt "$max_retries" ]]; do
    if curl -sf http://api.service/health > /dev/null; then
        echo "Service is healthy."
        break
    fi
    
    retry_count=$((retry_count + 1))
    echo "Attempt $retry_count failed. Retrying in 5 seconds..."
    sleep 5
done

if [[ "$retry_count" -eq "$max_retries" ]]; then
    echo "ERROR: Service failed after $max_retries attempts." >&2
    exit 1
fi

This implements a retry pattern. The loop continues until the service responds or the limit exhausts.

Infinite Loops with Break

#!/bin/bash
while true; do
    queue_size=$(redis-cli LLEN job_queue)
    
    if [[ "$queue_size" -eq 0 ]]; then
        echo "Queue empty. Exiting."
        break
    fi
    
    echo "Processing $queue_size items..."
    sleep 1
done

Infinite loops work well for daemons. Always include a clear exit condition.

Step 7: Control Streams and Redirection

Every program connects to three channels. Bash gives you direct control over all of them.

StreamFile DescriptorWhat It Carries
Standard Input (STDIN)0Data entering the program
Standard Output (STDOUT)1Normal output
Standard Error (STDERR)2Error messages

Redirection Operators

OperatorAction
>Redirect STDOUT to a file (overwrite)
>>Redirect STDOUT to a file (append)
2>Redirect STDERR to a file
&>Redirect both STDOUT and STDERR
<Feed a file into STDIN
|Pipe STDOUT into another command

Split output and errors into separate files:

#!/bin/bash
bash deploy.sh > build.log 2> errors.log

The > captures normal output. The 2> captures only error messages. This separation saves hours during debugging.

Piping Between Commands

#!/bin/bash
cat access.log | grep "404" | wc -l

This counts 404 errors without temporary files. Data flows from left to right.

Appending to Logs

#!/bin/bash
echo "Build started at $(date)" >> build.log
./run_tests.sh >> build.log 2>> error.log

This preserves history. Your log grows instead of resetting every run.

Step 8: Pass Arguments to Your Script

Hardcoded values make scripts fragile. Arguments let you reuse the same script across tasks.

Positional Parameters

Bash stores arguments in numbered variables:

#!/bin/bash
echo "First argument: $1"
echo "Second argument: $2"
echo "Total arguments: $#"
echo "All arguments: $@"

Run it with:

./backup.sh production /data /backup

The script receives production as $1, /data as $2, and /backup as $3.

Name Your Arguments Immediately

Raw positional parameters become unreadable. Assign them to descriptive variables:

#!/bin/bash
set -euo pipefail

readonly env="${1:?Usage: $0 <env> <source> <dest>}"
readonly source_dir="${2:?Usage: $0 <env> <source> <dest>}"
readonly dest_dir="${3:?Usage: $0 <env> <source> <dest>}"

echo "Backing up $source_dir to $dest_dir for $env..."

The :? syntax exits with your error message if the argument is missing.

Self-Deleting Scripts

For one-time setup scripts, remove the file after execution:

#!/bin/bash
echo "Installing dependencies..."
apt-get update && apt-get install -y curl jq
rm -f "$0"

The $0 variable holds the script’s own filename.

Step 9: Work with Arrays

Space-separated strings break when filenames contain spaces. Arrays solve this.

Declaring and Accessing Arrays

#!/bin/bash
servers=("web01" "web02" "db01" "db02")

echo "First server: ${servers[0]}"
echo "All servers: ${servers[@]}"
echo "Total count: ${#servers[@]}"

The @ symbol expands to all elements, preserving each as a separate word.

Looping Through Arrays Safely

#!/bin/bash
log_files=("/var/log/nginx/access.log" "/var/log/app.log")

for log_file in "${log_files[@]}"; do
    if [[ -f "$log_file" ]]; then
        size=$(du -h "$log_file" | cut -f1)
        echo "$log_file size: $size"
    else
        echo "WARNING: $log_file not found." >&2
    fi
done

Quoting "${log_files[@]}" prevents word splitting. Without quotes, a filename like /var/log/my app.log splits into two broken paths.

Building Arrays Dynamically

#!/bin/bash
set -euo pipefail

matching_files=()

for file in /var/log/*.log; do
    if [[ -s "$file" ]]; then
        matching_files+=("$file")
    fi
done

echo "Found ${#matching_files[@]} non-empty log files"

This starts with an empty array and adds elements conditionally.

Step 10: Add Safety and Best Practices

Silent failures destroy systems. A script that continues after an error might delete the wrong files

The Safety Trio

Add these three settings to every script:

#!/bin/bash
set -euo pipefail
  • set -e: Exits immediately when any command fails
  • set -u: Treats unset variables as fatal errors
  • set -o pipefail: Makes pipelines fail if any command fails

Together they flip Bash from “keep going” to “stop at the first sign of trouble.”

Trapping Cleanup Code

Use trap to run cleanup whether your script succeeds or crashes:

#!/bin/bash
set -euo pipefail

work_dir=$(mktemp -d)

cleanup() {
    local exit_code=$?
    rm -rf "$work_dir"
    exit "$exit_code"
}

trap cleanup EXIT SIGINT SIGTERM

echo "Working in $work_dir..."

The trap registers cleanup to run on exit, interruption, or termination.

Structured Logging

Replace plain echo with functions that include timestamps:

bash

#!/bin/bash
log_info() { echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') $*"; }
log_warn() { echo "[WARN] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2; }
log_error() { echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2; }

Warnings and errors go to STDERR. This lets you pipe normal output to files while keeping diagnostics visible.

Validate Dependencies

Check that required tools exist before using them:

#!/bin/bash
check_dependency() {
    local cmd="$1"
    if ! command -v "$cmd" > /dev/null 2>&1; then
        echo "ERROR: '$cmd' not found. Please install it." >&2
        exit 1
    fi
}

check_dependency "docker"
check_dependency "jq"

This fails fast with a helpful message instead of cryptic errors halfway through execution.

Build Your First Real Script

Combine everything into a project that actually does something useful. This script backs up a directory, checks disk space, and logs everything:

#!/bin/bash
set -euo pipefail

readonly source_dir="${1:?Usage: $0 <source_dir> <backup_dir>}"
readonly backup_dir="${2:?Usage: $0 <source_dir> <backup_dir>}"
readonly timestamp=$(date +%Y%m%d_%H%M%S)
readonly log_file="$backup_dir/backup_$timestamp.log"

log_info() { echo "[INFO] $(date '+%H:%M:%S') $*" | tee -a "$log_file"; }
log_error() { echo "[ERROR] $(date '+%H:%M:%S') $*" >&2 | tee -a "$log_file"; }

if [[ ! -d "$source_dir" ]]; then
    log_error "Source directory $source_dir does not exist."
    exit 1
fi

if [[ ! -d "$backup_dir" ]]; then
    log_info "Creating backup directory..."
    mkdir -p "$backup_dir"
fi

log_info "Starting backup of $source_dir..."
tar -czf "$backup_dir/backup_$timestamp.tar.gz" -C "$source_dir" .
log_info "Backup completed: $backup_dir/backup_$timestamp.tar.gz"

usage=$(df "$backup_dir" | awk 'NR==2 {print $5}' | tr -d '%')
if [[ "$usage" -gt 90 ]]; then
    log_error "Disk usage critical at ${usage}%."
    exit 1
fi

log_info "Disk usage OK at ${usage}%."

Run it with:

bash

./backup.sh /home/alex/projects /backup/alex

This script validates inputs, creates missing directories, compresses data, checks disk space, and logs every step. It is a real tool you can use today.

Conclusion

You started with a single echo command and ended with a production-style backup script. The ten steps in this guide give you a foundation that most working developers use daily. Practice each step on your own machine before moving to the next one.

Want more articles and tutorials like this?

Get new tutorials, security alerts, and IT tips straight to your inbox.

Donate

1 Comment

  1. That makes so much sense, I’ve definitely struggled with those repetitive tasks and wish I’d known where to start.

Leave a Comment

Your email address will not be published. Required fields are marked *