Skip to content

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.