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.
What You Will Learn
Section titled “What You Will Learn”- How to create, make executable, and run a script
- Variables, quoting, command substitution, and positional parameters
if/caseconditionals andfor/while/untilloops- Functions, exit codes, and the robust
set -euo pipefailidiom - Reading input, arithmetic, arrays, and here-documents
- Debugging with
bash -xand linting with ShellCheck - Two ready-to-run scripts: a directory backup and a service health check
Prerequisites
Section titled “Prerequisites”- A system running EL, or any Linux with Bash 4+/5
- Comfort with a text editor (
vimornanoboth work) - Basic command-line knowledge (
cd,ls,cat, etc.) - Some sections need sudo privileges (installing ShellCheck, reading systemd status)
Your First Script
Section titled “Your First Script”Create a file called hello.sh with the following content:
#!/usr/bin/env bashecho "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:
$ chmod +x hello.sh$ ./hello.shNote that ./ cannot be omitted. The current directory is usually not in PATH, so typing just hello.sh will give a “command not found” error.
Variables and Quoting
Section titled “Variables and Quoting”When assigning a variable, there must be no spaces around the equals sign. Reference it with a $ prefix:
name="centos"greeting="Hello, ${name}" # Preferred: ${} makes the boundary explicitecho "$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.
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").
Command Substitution
Section titled “Command Substitution”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:
today=$(date +%Y-%m-%d)count=$(ls -1 | wc -l)echo "Today is ${today}; this directory has ${count} entries"Positional Parameters
Section titled “Positional Parameters”A script reads its command-line arguments through positional variables:
| Variable | Meaning |
|---|---|
$0 | The 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 |
#!/usr/bin/env bashecho "Script name: $0"echo "First argument: $1"echo "Argument count: $#"echo "All arguments: $@"$ ./args.sh alma rockyConditionals
Section titled “Conditionals”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 [[ "$1" == "start" ]]; then echo "Starting…"elif [[ "$1" == "stop" ]]; then echo "Stopping…"else echo "Usage: $0 {start|stop}"fiCommon test operators:
| Category | Form | Meaning |
|---|---|---|
| String | [[ "$a" == "$b" ]] | Equal |
| String | [[ -z "$a" ]] / [[ -n "$a" ]] | Empty / non-empty |
| Numeric | [[ "$a" -eq "$b" ]] | Equal |
| Numeric | [[ "$a" -lt "$b" ]] / -gt / -le / -ge | Less than / greater than, etc. |
| File | [[ -f "$p" ]] | Is a regular file |
| File | [[ -d "$p" ]] | Is a directory |
| File | [[ -e "$p" ]] | Path exists |
&& and ||
Section titled “&& and ||”Use && (run the next command only if the previous one succeeded) and || (run the next only if the previous one failed) for quick decisions:
mkdir -p /opt/app && echo "Directory ready"ping -c1 example.com || echo "Network unreachable"case for Multiple Branches
Section titled “case for Multiple Branches”When matching against fixed values, case is clearer than a long chain of elif:
case "$1" in start) echo "start" ;; stop) echo "stop" ;; restart) echo "restart" ;; *) echo "Usage: $0 {start|stop|restart}" ;;esacEach branch ends with ;;, *) is the catch-all, and the whole block ends with esac (case spelled backwards).
for Loops
Section titled “for Loops”# Iterate over a listfor distro in alma rocky centos; do echo "Distribution: $distro"done
# Numeric rangefor 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"donewhile and until
Section titled “while and until”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:
count=1while [[ $count -le 3 ]]; do echo "while: $count" count=$(( count + 1 ))done
n=1until [[ $n -gt 3 ]]; do echo "until: $n" n=$(( n + 1 ))doneUse break to leave a loop early and continue to skip the rest of the current iteration:
for i in {1..10}; do [[ $i -eq 3 ]] && continue # skip 3 [[ $i -eq 6 ]] && break # stop at 6 echo "$i"doneFunctions
Section titled “Functions”Functions wrap up repeated logic. Their arguments are read the same way, with $1, $2:
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 4echo "Exit code: $?"Exit Codes
Section titled “Exit Codes”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:
if ! command -v git &>/dev/null; then echo "git is not installed" >&2 exit 1fiexit 0Exit 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.
Robustness: set -euo pipefail
Section titled “Robustness: set -euo pipefail”Nearly every serious script starts with this one line:
#!/usr/bin/env bashset -euo pipefailItem 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$naeminstead 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, Arithmetic, and Arrays
Section titled “Reading Input, Arithmetic, and Arrays”Reading User Input
Section titled “Reading User Input”read -r -p "Enter the hostname: " hostnameecho "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.
Arithmetic
Section titled “Arithmetic”Use $(( )) for integer arithmetic:
a=10b=3echo "Sum: $(( a + b ))"echo "Quotient: $(( a / b ))" # integer division, result is 3echo "Remainder: $(( a % b ))"Array Basics
Section titled “Array Basics”distros=(alma rocky centos)echo "First: ${distros[0]}"echo "All: ${distros[@]}"echo "Count: ${#distros[@]}"
distros+=(fedora) # append an elementfor d in "${distros[@]}"; do echo "- $d"donehere-documents
Section titled “here-documents”Use <<EOF to feed multiple lines of text straight into a command—handy for generating config files:
cat > /tmp/info.txt <<EOFHostname: $(hostname)Date: $(date +%F)EOFEOF 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).
Redirection and Pipes
Section titled “Redirection and Pipes”| Form | Effect |
|---|---|
cmd > file | Overwrite file with standard output |
cmd >> file | Append standard output to file |
cmd 2> file | Write standard error to file |
cmd > file 2>&1 | Write both standard output and standard error to file |
cmd1 | cmd2 | Feed cmd1’s output as cmd2’s input |
# Log both normal output and errors./deploy.sh > deploy.log 2>&1
# Count logged-in userswho | wc -l
# Keep only errors, discard normal output./check.sh 2> errors.log > /dev/nullIn 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.
Debugging Scripts
Section titled “Debugging Scripts”Having a script print each command as it actually runs is the fastest way to troubleshoot:
$ bash -x ./myscript.shYou can also enable debugging for just one section inside the script:
set -x # start printing the execution tracerisky_functionset +x # turn it offset -x prints each expanded command prefixed with +, so you can see exactly what your variables resolved to.
Linting with ShellCheck
Section titled “Linting with ShellCheck”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:
$ sudo dnf install epel-release$ sudo dnf install ShellCheck$ shellcheck myscript.shShellCheck 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.
Project 1: Timestamped Directory Backup
Section titled “Project 1: Timestamped Directory Backup”Pack a given directory into a date-stamped tar.gz and store it in a backup directory:
#!/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 checkif [[ -z "$src" ]]; then echo "Usage: $0 <source-dir> [backup-dir]" >&2 exit 1fi
if [[ ! -d "$src" ]]; then echo "Error: source directory does not exist: $src" >&2 exit 1fi
# Make sure the backup directory existsmkdir -p "$dest"
# Build the archive name from the source dir name plus a timestampbase=$(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 archivetar -czf "$archive" -C "$(dirname "$src")" "$base"
echo "Done, size: $(du -h "$archive" | cut -f1)"Example run:
$ chmod +x /opt/scripts/backup-dir.sh$ ./backup-dir.sh /etc /var/backupsProject 2: Service Health Check
Section titled “Project 2: Service Health Check”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:
#!/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 neededservices=(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 )) fidone
if [[ "$failed" -eq 0 ]]; then echo "All services healthy." exit 0else echo "${failed} service(s) failed." >&2 exit "$failed"fiExample run:
$ 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.
Frequently Asked Questions
Section titled “Frequently Asked Questions”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 as expected
Section titled “set -e does not exit as expected”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:
$ sudo dnf install dos2unix$ dos2unix myscript.shOr 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.
Further Reading
Section titled “Further Reading”- Scheduled Tasks (Timers) — run your scripts automatically on a schedule
- Command Cheat Sheet — quick lookup for common commands
- File Permissions — understand
chmodand the executable bit in depth