Skip to content
Search RunEntLinux

Searches general documentation on RunEntLinux. Each distribution has its own documentation search.

    Browse this documentation

    Shell Scripting Basics

    Applies to all Enterprise Linux distributions (Bash 4+/5)

    Have you typed the same series of commands a hundred times and wished you could write them once and run them again? That is what a shell script is for. It groups commands, decisions, and loops into a single file so that repetitive work—backups, checks, deployments—runs automatically. This page takes you from the shebang line all the way to writing robust, debuggable, practical scripts.

    The default interactive shell on Enterprise Linux (CentOS Stream, AlmaLinux, Rocky Linux, and friends) is Bash, so this page uses Bash syntax throughout.

    • How to create, make executable, and run a script
    • Variables, quoting, command substitution, and positional parameters
    • if / case conditionals and for / while / until loops
    • Functions, exit codes, and the robust set -euo pipefail idiom
    • Reading input, arithmetic, arrays, and here-documents
    • Debugging with bash -x and linting with ShellCheck
    • Two ready-to-run scripts: a directory backup and a service health check
    • A system running EL, or any Linux with Bash 4+/5
    • Comfort with a text editor (vim or nano both work)
    • Basic command-line knowledge (cd, ls, cat, etc.)
    • Some sections need sudo privileges (installing ShellCheck, reading systemd status)

    Create a file called hello.sh with the following content:

    hello.sh
    #!/usr/bin/env bash
    echo "Hello, $USER!"
    echo "Current directory is $(pwd)"

    The first line, #!/usr/bin/env bash, is called the shebang. It tells the system which interpreter should run the file. Compared with a hard-coded #!/bin/bash, /usr/bin/env bash searches your PATH for bash, which is more portable.

    Next, give the script execute permission and run it:

    Make executable and run
    $ chmod +x hello.sh
    $ ./hello.sh

    Note that ./ cannot be omitted. The current directory is usually not in PATH, so typing just hello.sh will give a “command not found” error.

    When assigning a variable, there must be no spaces around the equals sign. Reference it with a $ prefix:

    Variable basics
    name="centos"
    greeting="Hello, ${name}" # Preferred: ${} makes the boundary explicit
    echo "$greeting"

    Quoting rules are where scripts most often go wrong:

    • Double quotes "...": variables are expanded, but “word splitting” and glob expansion are prevented. Use double quotes in almost every case.
    • Single quotes '...': literal output—nothing is expanded.
    • No quotes: spaces inside a value split it into multiple words, and * is treated as a glob.
    Why quoting matters
    file="my report.txt"
    rm "$file" # Correct: removes the single file named "my report.txt"
    # rm $file # Wrong: tries to remove two files, "my" and "report.txt"

    Make "${var}" your default: the double quotes keep things safe, and the braces make the variable’s boundary clear (e.g. "${name}_backup").

    Use $(...) to assign the output of a command to a variable. It nests cleanly and is clearer than the old backtick form `...`, so prefer it:

    Command substitution
    today=$(date +%Y-%m-%d)
    count=$(ls -1 | wc -l)
    echo "Today is ${today}; this directory has ${count} entries"

    A script reads its command-line arguments through positional variables:

    VariableMeaning
    $0The script’s own name
    $1 $2 …The 1st, 2nd argument
    $#Number of arguments
    $@All arguments (quoted as "$@", each argument is preserved separately)
    $?Exit code of the previous command
    args.sh
    #!/usr/bin/env bash
    echo "Script name: $0"
    echo "First argument: $1"
    echo "Argument count: $#"
    echo "All arguments: $@"
    Run with arguments
    $ ./args.sh alma rocky

    Bash uses if/elif/else/fi for branching. For tests, prefer [[ ... ]] (a Bash builtin that is safer than the old [ ... ] and supports more syntax):

    if conditionals
    if [[ "$1" == "start" ]]; then
    echo "Starting…"
    elif [[ "$1" == "stop" ]]; then
    echo "Stopping…"
    else
    echo "Usage: $0 {start|stop}"
    fi

    Common test operators:

    CategoryFormMeaning
    String[[ "$a" == "$b" ]]Equal
    String[[ -z "$a" ]] / [[ -n "$a" ]]Empty / non-empty
    Numeric[[ "$a" -eq "$b" ]]Equal
    Numeric[[ "$a" -lt "$b" ]] / -gt / -le / -geLess than / greater than, etc.
    File[[ -f "$p" ]]Is a regular file
    File[[ -d "$p" ]]Is a directory
    File[[ -e "$p" ]]Path exists

    Use && (run the next command only if the previous one succeeded) and || (run the next only if the previous one failed) for quick decisions:

    Short-circuit logic
    mkdir -p /opt/app && echo "Directory ready"
    ping -c1 example.com || echo "Network unreachable"

    When matching against fixed values, case is clearer than a long chain of elif:

    case branches
    case "$1" in
    start) echo "start" ;;
    stop) echo "stop" ;;
    restart) echo "restart" ;;
    *) echo "Usage: $0 {start|stop|restart}" ;;
    esac

    Each branch ends with ;;, *) is the catch-all, and the whole block ends with esac (case spelled backwards).

    Forms of the for loop
    # Iterate over a list
    for distro in alma rocky centos; do
    echo "Distribution: $distro"
    done
    # Numeric range
    for i in {1..5}; do
    echo "Iteration $i"
    done
    # Iterate over files (quote it to handle names with spaces)
    for f in /etc/*.conf; do
    echo "Config file: $f"
    done

    while loops as long as the condition is true; until is the opposite—it loops while the condition is false, until the condition becomes true:

    while and until
    count=1
    while [[ $count -le 3 ]]; do
    echo "while: $count"
    count=$(( count + 1 ))
    done
    n=1
    until [[ $n -gt 3 ]]; do
    echo "until: $n"
    n=$(( n + 1 ))
    done

    Use break to leave a loop early and continue to skip the rest of the current iteration:

    break and continue
    for i in {1..10}; do
    [[ $i -eq 3 ]] && continue # skip 3
    [[ $i -eq 6 ]] && break # stop at 6
    echo "$i"
    done

    Functions wrap up repeated logic. Their arguments are read the same way, with $1, $2:

    Function definition and arguments
    log() {
    echo "[$(date +%T)] $1"
    }
    add() {
    local sum=$(( $1 + $2 )) # local keeps the variable inside the function
    return 0 # return gives an exit code (0–255), not a value
    }
    log "Starting calculation"
    add 3 4
    echo "Exit code: $?"

    Every command produces an exit code after it runs: 0 means success, anything non-zero means failure. Read the previous command’s exit code with $?, and make a script exit with a specific code using exit N:

    Exit codes
    if ! command -v git &>/dev/null; then
    echo "git is not installed" >&2
    exit 1
    fi
    exit 0

    Exit codes are the standard way scripts communicate success or failure to each other and to systemd or CI, so always make a script return non-zero when it fails.

    Nearly every serious script starts with this one line:

    The start of a robust script
    #!/usr/bin/env bash
    set -euo pipefail

    Item by item:

    • set -e: terminate the script immediately if any command fails (returns non-zero), so an error is not silently ignored while the script keeps running.
    • set -u: error out when referencing an undefined variable, catching typos early (like writing $naem instead of $name).
    • set -o pipefail: a pipeline fails if any stage in it fails. By default a pipeline’s exit code reflects only the last command, which can hide earlier errors.

    Together they make a script “fail fast” rather than soldiering on with a broken state.

    Reading input
    read -r -p "Enter the hostname: " hostname
    echo "You entered: $hostname"

    The -r in read -r tells read not to treat backslashes as escapes, and you should always include it—otherwise a \ in a path gets swallowed.

    Use $(( )) for integer arithmetic:

    Arithmetic
    a=10
    b=3
    echo "Sum: $(( a + b ))"
    echo "Quotient: $(( a / b ))" # integer division, result is 3
    echo "Remainder: $(( a % b ))"
    Arrays
    distros=(alma rocky centos)
    echo "First: ${distros[0]}"
    echo "All: ${distros[@]}"
    echo "Count: ${#distros[@]}"
    distros+=(fedora) # append an element
    for d in "${distros[@]}"; do
    echo "- $d"
    done

    Use <<EOF to feed multiple lines of text straight into a command—handy for generating config files:

    here-document
    cat > /tmp/info.txt <<EOF
    Hostname: $(hostname)
    Date: $(date +%F)
    EOF

    EOF is just a conventional end marker; you can use any word. If you do not want the variables inside expanded, write the opening as <<'EOF' (quote the marker).

    FormEffect
    cmd > fileOverwrite file with standard output
    cmd >> fileAppend standard output to file
    cmd 2> fileWrite standard error to file
    cmd > file 2>&1Write both standard output and standard error to file
    cmd1 | cmd2Feed cmd1’s output as cmd2’s input
    Redirection and pipe examples
    # Log both normal output and errors
    ./deploy.sh > deploy.log 2>&1
    # Count logged-in users
    who | wc -l
    # Keep only errors, discard normal output
    ./check.sh 2> errors.log > /dev/null

    In 2>&1, 2 is standard error and 1 is standard output; it means “point standard error to wherever standard output currently goes.” That is why it must come after > file to take effect.

    Having a script print each command as it actually runs is the fastest way to troubleshoot:

    Run with debugging on
    $ bash -x ./myscript.sh

    You can also enable debugging for just one section inside the script:

    Local debugging
    set -x # start printing the execution trace
    risky_function
    set +x # turn it off

    set -x prints each expanded command prefixed with +, so you can see exactly what your variables resolved to.

    ShellCheck is the “spell checker” of the scripting world: it catches missing quotes, undefined variables, and suspicious patterns before you ever run the script. It is available from the EPEL repository:

    Install ShellCheck (from EPEL)
    $ sudo dnf install epel-release
    $ sudo dnf install ShellCheck
    Check a script
    $ shellcheck myscript.sh

    ShellCheck flags issues with codes (such as SC2086); follow its suggestions to fix them. Making “run shellcheck before anything else” a habit will save you from most of the pitfalls on this page.

    Pack a given directory into a date-stamped tar.gz and store it in a backup directory:

    /opt/scripts/backup-dir.sh
    #!/usr/bin/env bash
    #
    # backup-dir.sh — archive a given directory with a timestamped filename
    # Usage: ./backup-dir.sh <source-dir> [backup-dir]
    #
    set -euo pipefail
    # First argument is the source directory; the second is optional (default /var/backups)
    src="${1:-}"
    dest="${2:-/var/backups}"
    # Argument check
    if [[ -z "$src" ]]; then
    echo "Usage: $0 <source-dir> [backup-dir]" >&2
    exit 1
    fi
    if [[ ! -d "$src" ]]; then
    echo "Error: source directory does not exist: $src" >&2
    exit 1
    fi
    # Make sure the backup directory exists
    mkdir -p "$dest"
    # Build the archive name from the source dir name plus a timestamp
    base=$(basename "$src")
    timestamp=$(date +%Y%m%d-%H%M%S)
    archive="${dest}/${base}-${timestamp}.tar.gz"
    echo "Backing up ${src} -> ${archive}"
    # -C changes to the parent dir so absolute paths do not end up in the archive
    tar -czf "$archive" -C "$(dirname "$src")" "$base"
    echo "Done, size: $(du -h "$archive" | cut -f1)"

    Example run:

    Run the backup script
    $ chmod +x /opt/scripts/backup-dir.sh
    $ ./backup-dir.sh /etc /var/backups

    Check whether a set of systemd services are active, and exit with a non-zero code if any of them is not—that way a monitoring system or CI can recognize it as a failure:

    /opt/scripts/health-check.sh
    #!/usr/bin/env bash
    #
    # health-check.sh — check that critical services are running
    # Exit code: 0 = all OK; non-zero = some services failed (the number = failure count)
    #
    set -euo pipefail
    # Services to check; edit as needed
    services=(sshd chronyd firewalld)
    failed=0
    for svc in "${services[@]}"; do
    # is-active returns "active" and exit code 0 when the service is running
    if systemctl is-active --quiet "$svc"; then
    echo "[OK] $svc is running"
    else
    echo "[FAIL] $svc is not running" >&2
    failed=$(( failed + 1 ))
    fi
    done
    if [[ "$failed" -eq 0 ]]; then
    echo "All services healthy."
    exit 0
    else
    echo "${failed} service(s) failed." >&2
    exit "$failed"
    fi

    Example run:

    Run the health check
    $ chmod +x /opt/scripts/health-check.sh
    $ ./health-check.sh
    $ echo "Exit code: $?"

    systemctl is-active --quiet exits 0 when the service is running and non-zero otherwise, which is exactly what an if test needs. The script uses the number of failed services as its exit code so an external program can gauge the severity.

    Error [: command not found or [[: command not found

    Section titled “Error [: command not found or [[: command not found”

    The bracket [ is actually a command, so it needs spaces around it. Writing if [$x -eq 1] is parsed as the command [$x, which errors out. The correct form is if [[ "$x" -eq 1 ]]—note the spaces between [[, the variable, and the operators.

    Unquoted variables break on spaces or globs

    Section titled “Unquoted variables break on spaces or globs”

    An unquoted variable undergoes “word splitting” and glob expansion. With a filename containing spaces, cp $file /backup breaks, and $dir/* can be expanded unexpectedly in some cases. Almost every variable reference should be written as "$var". ShellCheck catches this automatically (SC2086).

    set -e does not exit on every failure. A command in an if condition, on the left of && or ||, or invoked inside a function may not trigger an exit when it fails. If you intentionally want a command to be allowed to fail, write cmd || true explicitly; conversely, if a script seems to “keep running while broken,” check whether it landed in one of these exempt positions.

    bad interpreter: No such file or directory (CRLF line endings)

    Section titled “bad interpreter: No such file or directory (CRLF line endings)”

    Scripts edited on Windows—or pasted from certain editors—may have CRLF (\r\n) line endings. The extra \r at the end of the shebang line makes the system look for a nonexistent interpreter /usr/bin/env bash\r, producing bad interpreter. To fix it:

    Convert line endings
    $ sudo dnf install dos2unix
    $ dos2unix myscript.sh

    Or use sed -i 's/\r$//' myscript.sh. Get into the habit of editing scripts on Linux and configuring your editor to use LF line endings.