Domain 3 · Scripting

Create simple
shell scripts

Write, execute, and debug bash scripts on RHEL 9. Variables, conditionals, loops, exit codes, and practical exam-ready script patterns.

7objectives
7topic areas
7quiz questions
bashshell

Objectives

What the exam tests

  • Use standard bash script components including shebang, comments, and exit codes
  • Use variables, including positional parameters and special variables
  • Use conditional structures: if/then/else/fi and case
  • Use loop structures: for, while, and until
  • Process script output using command substitution and piping
  • Use test conditions for files, strings, and integers
  • Make scripts executable and run them correctly

RHCSA scripts are intentionally short — typically 10–30 lines. The exam tests whether you can write a working script under time pressure, not whether you can write elegant code.

Coverage weight by topic

Script basics / shebang
Very high
Variables & parameters
Very high
Conditionals (if/case)
High
Loops (for/while)
High
Exit codes & $?
High
Test expressions
High
Command substitution
Medium

Script basics

Script structure and the shebang

#!/bin/bash # Script name: myscript.sh # Purpose: Brief description of what this script does # Author: Your name # Script body goes here echo "Hello, World!" # Always exit with a meaningful code exit 0
ElementPurpose
#!/bin/bashShebang — tells the kernel which interpreter to use. Must be the very first line.
# commentAnything after # is ignored by bash (except the shebang on line 1).
exit 0Exit with code 0 (success). Any non-zero value signals failure.

On the exam, #!/bin/bash is the correct shebang. #!/bin/sh is valid but less feature-rich — avoid it unless explicitly required.

Making scripts executable and running them

# Create a script file vim myscript.sh # Make it executable (required before running as ./script) chmod +x myscript.sh chmod 755 myscript.sh # equivalent — rwxr-xr-x # Run the script — three valid methods ./myscript.sh # must be in current dir (needs +x) bash myscript.sh # interpreter explicit — no +x needed /path/to/myscript.sh # absolute path (needs +x) # Run with debugging output (very useful on exam) bash -x myscript.sh # print each command before executing bash -n myscript.sh # syntax check only — do not run

A script without execute permission will fail with Permission denied when run as ./script.sh. Always chmod +x after creating a script.

Exit codes

# $? holds the exit code of the last command (0=success, 1-255=error) ls /etc/passwd echo "Exit code: $?" # prints 0 ls /nonexistent echo "Exit code: $?" # prints 2 (no such file or dir) # Use exit codes in scripts if ! systemctl is-active httpd > /dev/null 2>&1; then echo "httpd is not running" exit 1 fi exit 0
CodeMeaning
0Success
1General error
2Misuse of shell built-in or no such file
126Command found but not executable
127Command not found
128+nFatal signal n received (e.g., 130 = Ctrl+C)

Input and output

# Output echo "Simple message" echo -e "Line1\nLine2" # -e enables escape sequences printf "%-10s %5d\n" "Alice" 42 # formatted output # Read user input read username read -p "Enter your name: " username # with prompt read -s -p "Password: " password # silent (no echo) read -t 10 -p "Input (10s): " answer # with timeout # Redirect script output ./script.sh > output.log ./script.sh >> output.log 2>&1

Variables and parameters

Variables — declaring and using

# Assign a variable — NO spaces around the equals sign name="Alice" count=42 greeting="Hello, World" # Reference a variable — prefix with $ echo "$name" echo "Count is: $count" # Braces — required when followed by more text echo "${name}s are cool" # Alices are cool # Read-only variable (constant) readonly MAX=100 # Unset a variable unset name # Environment variable — available to child processes export MYVAR="value"

No spaces around = when assigning. name = "Alice" is a syntax error — bash interprets name as a command.

Positional parameters

#!/bin/bash # Called as: ./script.sh arg1 arg2 arg3 echo "Script name: $0" # ./script.sh echo "First arg: $1" # arg1 echo "Second arg: $2" # arg2 echo "All args: $@" # arg1 arg2 arg3 echo "All as one: $*" # arg1 arg2 arg3 (subtle difference) echo "Arg count: $#" # 3 echo "Process ID: $$" # PID of this script echo "Last exit: $?" # exit code of previous command echo "Background PID: $!" # PID of last background process
VariableValue
$0Name of the script itself
$1$9First through ninth positional argument
${10}Tenth argument and beyond (braces required)
$#Number of positional arguments passed
$@All arguments as separate quoted words
$*All arguments as a single word
$?Exit status of the most recently executed command
$$PID of the current shell / script
$!PID of the most recently backgrounded process

Command substitution

# Capture command output into a variable today=$(date +%Y-%m-%d) hostname=$(hostname -f) disk_usage=$(df -h / | awk 'NR==2{print $5}') echo "Today is $today on $hostname" echo "Root usage: $disk_usage" # Inline (nested) echo "Users logged in: $(who | wc -l)" # Old backtick syntax — avoid in new scripts today=`date` # works but harder to read and nest

Always prefer $(command) over backticks. It is clearer, nestable, and doesn't require escaping backslashes inside.

Variable expansion tricks

# Default value — use val if var is unset or empty echo "${name:-default}" # Assign default if unset : "${name:=World}" # String length str="Hello" echo "${#str}" # 5 # Substring: ${var:start:length} echo "${str:1:3}" # ell # Strip prefix (shortest match) file="/etc/httpd/conf/httpd.conf" echo "${file##*/}" # httpd.conf (strip up to last /) echo "${file%/*}" # /etc/httpd/conf (strip from last /)

Conditionals

if / then / else / elif / fi

#!/bin/bash # Basic if if [ "$1" == "hello" ]; then echo "You said hello" fi # if / else if [ $# -eq 0 ]; then echo "No arguments provided" exit 1 else echo "Got $# argument(s)" fi # if / elif / else score=75 if [ $score -ge 90 ]; then echo "A" elif [ $score -ge 80 ]; then echo "B" elif [ $score -ge 70 ]; then echo "C" else echo "F" fi

Spaces inside [ ] are mandatory. [$var -eq 0] is a syntax error — it must be [ $var -eq 0 ].

Test expressions — file, string, integer

File tests

  • -e file — exists (any type)
  • -f file — is a regular file
  • -d dir — is a directory
  • -r file — readable
  • -w file — writable
  • -x file — executable
  • -s file — size > 0 (not empty)
  • -L file — is a symbolic link

String tests

  • -z str — string is empty (zero length)
  • -n str — string is non-empty
  • str1 == str2 — strings are equal
  • str1 != str2 — strings differ
  • str1 < str2 — alphabetically less
  • str1 > str2 — alphabetically greater

Integer comparisons

-eq — equal to
-ne — not equal to
-lt — less than
-le — less than or equal
-gt — greater than
-ge — greater than or equal

Compound conditions and logical operators

# AND — both conditions must be true if [ -f "$file" ] && [ -r "$file" ]; then echo "File exists and is readable" fi # OR — either condition can be true if [ "$1" == "yes" ] || [ "$1" == "y" ]; then echo "Confirmed" fi # NOT — invert a condition if ! [ -d "$dir" ]; then echo "Directory does not exist — creating" mkdir -p "$dir" fi # [[ ]] — extended test (bash-specific, safer for strings) if [[ "$name" == Alice* ]]; then # glob matching echo "Name starts with Alice" fi

case statement

#!/bin/bash # case is cleaner than many elif branches for matching a single value case "$1" in start) systemctl start httpd echo "httpd started" ;; stop) systemctl stop httpd echo "httpd stopped" ;; restart|reload) systemctl restart httpd echo "httpd restarted" ;; status) systemctl status httpd ;; *) echo "Usage: $0 {start|stop|restart|status}" exit 1 ;; esac

Each pattern ends with ), the block ends with ;;, and the whole construct closes with esac. The *) pattern is a catch-all default.

Loops

for loop — iterating over a list

# Iterate over a literal list for fruit in apple banana cherry; do echo "$fruit" done # Iterate over a numeric range for i in {1..5}; do echo "Iteration $i" done # C-style for loop for (( i=1; i<=5; i++ )); do echo "i = $i" done # Iterate over files in a directory for file in /etc/*.conf; do echo "Config file: $file" done # Iterate over command output for user in $(awk -F: '$3 >= 1000 {print $1}' /etc/passwd); do echo "Regular user: $user" done # Iterate over all script arguments for arg in "$@"; do echo "Argument: $arg" done

while loop — condition-based iteration

# Loop while condition is true count=1 while [ $count -le 5 ]; do echo "Count: $count" count=$(( count + 1 )) done # Read a file line by line — classic use case while read line; do echo "Line: $line" done < /etc/hosts # Infinite loop with break while true; do read -p "Enter 'quit' to exit: " input if [ "$input" == "quit" ]; then break fi echo "You said: $input" done

The while read line; do … done < file pattern is one of the most practical scripts on the RHCSA exam — know it cold.

until loop and loop control

# until — loop UNTIL condition becomes true (opposite of while) count=1 until [ $count -gt 5 ]; do echo "$count" count=$(( count + 1 )) done # Loop control keywords for i in {1..10}; do if [ $i -eq 3 ]; then continue # skip this iteration fi if [ $i -eq 7 ]; then break # exit the loop entirely fi echo "$i" done # Output: 1 2 4 5 6

Arithmetic in bash

# Arithmetic expansion — returns value result=$(( 5 + 3 )) result=$(( 10 * 4 )) result=$(( $count + 1 )) # Arithmetic command — for increment/decrement (no $ needed inside) (( count++ )) (( count-- )) (( count += 5 )) (( total = count * 2 )) # In a condition — non-zero = true if (( count > 10 )); then echo "count is greater than 10" fi # Alternative: expr (older, avoid in new scripts) result=$(expr 5 + 3)

Full script examples

Argument validation check_args.sh
#!/bin/bash # Validates that exactly one argument was passed if [ $# -ne 1 ]; then echo "Usage: $0 <username>" >&2 exit 1 fi user="$1" if id "$user" > /dev/null 2>&1; then echo "User '$user' exists" exit 0 else echo "User '$user' does not exist" >&2 exit 1 fi
Checks argument count, then verifies whether a user account exists. Sends error messages to stderr (>&2) and exits with meaningful codes — a pattern directly tested on the RHCSA.
Backup with timestamp backup.sh
#!/bin/bash # Creates a timestamped gzip backup of a directory SOURCE="${1:-/etc}" DEST="${2:-/tmp/backups}" TIMESTAMP=$(date +%Y%m%d_%H%M%S) ARCHIVE="$DEST/backup_${TIMESTAMP}.tar.gz" if ! [ -d "$SOURCE" ]; then echo "Error: source '$SOURCE' is not a directory" >&2 exit 1 fi mkdir -p "$DEST" tar -czf "$ARCHIVE" "$SOURCE" && \ echo "Backup created: $ARCHIVE" || \ { echo "Backup failed" >&2; exit 1; } exit 0
Uses default values (${1:-/etc}), validates input, creates directories as needed, and chains commands with && and || for inline success/failure handling.
Create users from a file create_users.sh
#!/bin/bash # Reads usernames from a file and creates accounts # Usage: ./create_users.sh userlist.txt if [ $# -ne 1 ] || ! [ -f "$1" ]; then echo "Usage: $0 <userfile>" >&2 exit 1 fi while read username; do if id "$username" >/dev/null 2>&1; then echo "SKIP: '$username' already exists" else useradd "$username" && \ echo "CREATED: $username" || \ echo "FAILED: $username" >&2 fi done < "$1" exit 0
Combines while read file processing, user existence checking with id, conditional creation, and proper error handling. A complete, exam-ready user provisioning script.
Service health check service_check.sh
#!/bin/bash # Checks and optionally restarts a list of services SERVICES=("sshd" "crond" "firewalld") FAILED=0 for svc in "${SERVICES[@]}"; do if systemctl is-active --quiet "$svc"; then echo "[OK] $svc is running" else echo "[FAIL] $svc is not running — attempting restart" systemctl start "$svc" && \ echo "[OK] $svc restarted successfully" || \ { echo "[ERR] $svc failed to start" >&2; FAILED=$(( FAILED + 1 )); } fi done exit $FAILED
Iterates over a bash array, uses systemctl is-active --quiet for clean status checks, counts failures, and exits with the failure count as the exit code — allowing the script result to be used in automation.
Disk usage warning disk_warn.sh
#!/bin/bash # Warns when any filesystem exceeds a usage threshold THRESHOLD=80 df -h | awk 'NR>1 {print $5, $6}' | while read usage mount; do pct=${usage%%%} # strip the % sign if [ "$pct" -ge "$THRESHOLD" ] 2>/dev/null; then echo "WARNING: $mount is at ${usage} capacity" fi done
Pipes df output through awk and into a while read loop. Shows variable parameter expansion (${usage%%%} strips a trailing %).

Cheat sheet

Most-tested constructs — quick reference

Shebang
#!/bin/bash
Make executable
chmod +x script.sh
Debug mode
bash -x script.sh
Syntax check
bash -n script.sh
Assign variable
name="value"
Use variable
echo "$name"
Command substitution
val=$(command)
Arithmetic
result=$(( a + b ))
Arg count
$#
All args
"$@"
Last exit code
$?
Default value
${var:-default}
File exists?
[ -f "$file" ]
Dir exists?
[ -d "$dir" ]
String empty?
[ -z "$str" ]
Integers equal?
[ $a -eq $b ]
Integer greater?
[ $a -gt $b ]
Read a file line by line
while read l; do … done < f
For range
for i in {1..10}; do
Exit success
exit 0
Exit failure
exit 1
Stderr redirect
echo "err" >&2
Suppress output
cmd >/dev/null 2>&1
AND chain
cmd1 && cmd2
OR chain
cmd1 || cmd2
Read with prompt
read -p "Prompt: " var

Test flag quick-reference

FlagTypeTrue when…
-efilePath exists (file, dir, symlink, etc.)
-ffilePath exists and is a regular file
-dfilePath exists and is a directory
-rfileFile is readable by current user
-wfileFile is writable by current user
-xfileFile is executable by current user
-sfileFile exists and has size greater than zero
-zstringString has zero length (is empty)
-nstringString has non-zero length (is not empty)
-eqintegerInteger a equals integer b
-neintegerInteger a does not equal integer b
-ltintegerInteger a is less than integer b
-leintegerInteger a is less than or equal to b
-gtintegerInteger a is greater than integer b
-geintegerInteger a is greater than or equal to b

Practice quiz

Question 1 of 7

A script runs without errors but prints nothing. You want to trace exactly which commands execute and with what values. Which command should you use?

bash -x (xtrace) prints each command with expanded values before executing it — invaluable for debugging logic. -n only checks syntax without running. -v prints each line before processing (before expansion). -e causes the script to exit on any error.

Question 2 of 7

Which line contains a syntax error?

Option B has spaces around =. Bash interprets name as a command and = and "Alice" as its arguments — resulting in a "command not found" error. Variable assignment in bash requires no spaces: name="Alice".

Question 3 of 7

A script is called as ./backup.sh /data /tmp. Inside the script, what does $# evaluate to?

$# is the count of positional arguments — not including $0 (the script name). Two arguments were passed (/data and /tmp), so $# is 2. The script name is $0, first arg is $1, second is $2.

Question 4 of 7

You want to loop through every line in /etc/hosts and print each one. Which construct is correct?

while read line; do … done < /etc/hosts is the correct pattern for line-by-line file processing. Option A passes the filename as a single word. Option D uses $(cat) which word-splits on spaces too, breaking lines with spaces. Option C has invalid until read syntax.

Question 5 of 7

Which test expression checks that the variable $dir refers to an existing directory?

-d is true only if the path exists and is a directory. -f tests for a regular file (false for directories). -e tests for existence of any type. -x tests executability (also true for directories that are traversable, but that's not the right semantic here).

Question 6 of 7

What is the output of this snippet?
count=5; if [ $count -gt 3 ] && [ $count -lt 10 ]; then echo "yes"; else echo "no"; fi

count is 5. The test 5 -gt 3 is true AND 5 -lt 10 is true, so both conditions pass and the then branch executes, printing yes.

Question 7 of 7

Which shebang line correctly declares a bash script and must appear on line 1?

The shebang is #! followed immediately by the interpreter path — no space between # and !. It must be the very first line of the file with no blank lines or spaces before it. Option A has a space inside the shebang making it a comment, not a directive.