| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10coreutils 9.5-8.el10_2systemd 257-23.el10_2.2 |
| Applies to | RHEL, Rocky Linux, AlmaLinux, Oracle Linux, CentOS Stream, Fedora |
| Privilege | Normal user for service checks and most examples; sudo or root for user creation and removal |
| Scope | Shebang, execute permission, variables, command substitution, positional parameters, test and [ ], if and for, safe file reading, exit codes, and compact RHEL-family administration scripts using sshd, crond, and firewalld unit names. Does not cover arrays, functions, or advanced parameter expansion — see the linked shell scripting course for those topics. |
| Related guides | Linux command line basics Shell scripting tutorial Bash if else Bash for loop Bash script arguments |
A Bash shell script turns repeatable administration work into a file you can run the same way every time. This lesson walks through the RHCSA-sized workflow: write a script, pass arguments, branch on conditions, loop over lists, read input files safely, and check whether commands succeeded. When you want a full course on functions, arrays, and parsing flags, continue with the shell scripting tutorial — this page stays compact and practical.
What Is a Bash Shell Script?
When you type commands interactively, Bash reads one line, runs it, and waits for the next. A shell script is a text file of those same commands. Bash runs them in order from top to bottom.
Administration scripts are useful when the same checks or changes happen on many hosts or on a schedule:
- Verify disk space or service state before a change window
- Create accounts from a username list HR emailed you
- Archive log directories with a dated tarball
This article teaches the building blocks for those tasks. It does not replace the dedicated lessons on Bash if else, Bash for loop, or Bash script arguments — it links to them when a topic deserves a deeper dive.
Create and Run Your First Bash Script
Every example below uses /tmp/bash-script-lab as a scratch directory. Create it once:
mkdir -p /tmp/bash-script-lab && cd /tmp/bash-script-lab(no output on success)A minimal script needs three pieces: a shebang that names the interpreter, the commands you want to run, and a way to invoke the file.
cat > hello.sh <<'EOF'
#!/bin/bash
echo "Hello from $(hostname) on $(date +%F)"
EOF(no output on success)The first line #!/bin/bash is the shebang. When you run ./hello.sh, the kernel starts /bin/bash and passes the script path as an argument.
Mark the file executable so you can run it by path:
chmod +x hello.sh(no output on success)Run it two ways — both are valid:
bash hello.shHello from localhost.localdomain on 2026-08-05./hello.shHello from localhost.localdomain on 2026-08-05bash hello.sh does not require execute permission because you are calling the interpreter yourself. ./hello.sh requires chmod +x and a correct shebang.
Exit status
Every command returns an exit status (also called an exit code). 0 means success; any non-zero value means failure.
./hello.sh
echo "exit status after success: $?"Hello from localhost.localdomain on 2026-08-05
exit status after success: 0false
echo "exit status after false: $?"exit status after false: 1Scripts should exit with a meaningful code when something goes wrong. You will use exit 1 in the argument-validation example later.
Working directory
Bash does not change directory when a script starts. Relative paths such as data/logs refer to wherever the operator was when they ran the script, not necessarily where the script file lives. For lab scripts under /tmp/bash-script-lab, cd there first or use absolute paths inside the script when files must be found reliably.
Use Variables and Command Substitution
Variables store text for reuse. Assign with NAME=value (no spaces around =). Read with "$NAME".
HOST=$(hostname -s)
DATE=$(date +%F)
echo "host=$HOST date=$DATE"host=localhost date=2026-08-05$(command) is command substitution: Bash runs the command, captures its stdout, and replaces the $(...) with that text. Administration scripts often store hostnames, dates, or disk-usage figures this way.
USAGE=$(LC_ALL=C df -P -h / | awk 'NR==2 {print $5}')
echo "root usage: $USAGE"root usage: 35%LC_ALL=C keeps df headers and numeric formatting predictable for parsing. The -P flag prints one filesystem per line so a long device name cannot push the use-percent column onto a second line.
Always quote expansions that might contain spaces: "$VAR" instead of bare $VAR. For complex command output, prefer a dedicated field from a stable tool (df, systemctl, id) instead of parsing many columns by hand unless you have no alternative.
Process Script Arguments
Positional parameters carry words the operator typed after the script name:
$0— script name or path as invoked$1,$2, … — first, second, … argument$#— argument count (not counting$0)"$@"— expands every positional argument as a separate word while preserving spaces within each argument. Use the quoted form unless you intentionally want word splitting and filename expansion. ShellCheck also recommends quoted"$@"to preserve argument boundaries.
Build a script that requires one name:
cat > greet.sh <<'EOF'
#!/bin/bash
if [ $# -lt 1 ]; then
echo "Usage: $0 <name>" >&2
exit 1
fi
echo "Hello, $1"
EOF
chmod +x greet.sh(no output on success)./greet.sh AliceHello, Alice./greet.shUsage: ./greet.sh <name>The script prints the usage line on stderr and exits with 1 when no name is supplied.
Show how quoting preserves a multi-word argument:
cat > show-args.sh <<'EOF'
#!/bin/bash
echo "script=$0 count=$#"
for arg in "$@"; do
echo " arg: $arg"
done
EOF
chmod +x show-args.sh
./show-args.sh one "two words" threescript=./show-args.sh count=3
arg: one
arg: two words
arg: threeFor shift, getopts, and forwarding "$@" to other commands, see Bash script arguments.
Default argument values
Later scripts use ${1:-default} to substitute a fallback when the first argument is missing or empty. In ${1:-users.txt}, Bash uses $1 when it is set and non-empty; otherwise it substitutes users.txt.
Test Conditions in Bash
Before if, you need a way to ask true/false questions. The test command and its bracket form [ expression ] evaluate a condition and return exit status 0 for true and 1 for false. Bash provides test and [ as related builtins; an external [ command may also exist on the system.
Spacing inside [ ] is mandatory — [ -f /etc/passwd ] works; [ -f/etc/passwd ] does not.
Common test types:
- Strings:
[ "$a" = "$b" ],[ -n "$a" ](non-empty),[ -z "$a" ](empty) - Integers:
[ "$a" -eq "$b" ],[ "$a" -gt "$b" ](use-eq,-gt, not=or>) - Files:
[ -f path ](regular file),[ -d path ](directory),[ -r path ](readable)
Quick checks from the shell:
[ -d /etc ] && echo "/etc is a directory"/etc is a directory[ -f /etc/passwd ] && echo "/etc/passwd is a file"/etc/passwd is a file[ "abc" = "abc" ] && echo "strings equal"strings equal[ 5 -gt 3 ] && echo "5 greater than 3"5 greater than 3You can also test a command directly: if command; then runs the command and branches on its exit status. The Bash if else lesson covers elif, [[ ]], and nested conditions in more detail.
Use if and else
An administration script often compares a measured value to a threshold. The disk check below reads root filesystem usage and warns when it crosses 80%.
cat > check-disk.sh <<'EOF'
#!/bin/bash
THRESH=80
USAGE=$(LC_ALL=C df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
if [ "$USAGE" -ge "$THRESH" ]; then
echo "WARNING: root filesystem is ${USAGE}% full"
else
echo "OK: root filesystem is ${USAGE}% full"
fi
EOF
chmod +x check-disk.sh
./check-disk.shOK: root filesystem is 35% fullThe if line runs [ "$USAGE" -ge "$THRESH" ] and picks the branch from its exit status. Quote "$USAGE" so an empty value does not break the test.
You can test a command without [ ]:
if systemctl is-active --quiet sshd; then
echo "sshd is running"
else
echo "sshd is not running"
fisshd is runningsystemctl is-active exits 0 when the unit is active, which if treats as true.
Process Items with for Loops
A for loop runs the same block once per item in a list.
Static list — check several services. On RHEL-family systems the SSH and cron units are typically sshd and crond; systemctl is-active does not require root for read-only status checks.
for svc in sshd crond; do
if systemctl is-active --quiet "$svc"; then
echo "$svc: active"
else
echo "$svc: inactive"
fi
donesshd: active
crond: activeFilename patterns — list files in a directory (create sample files first if needed):
mkdir -p data && echo sample > data/file1.txt
for f in data/*; do
echo "file: $f"
donefile: data/file1.txtPositional arguments — loop over hosts the operator passed:
cat > check-hosts.sh <<'EOF'
#!/bin/bash
if [ $# -lt 1 ]; then
echo "Usage: $0 <host> [host...]" >&2
exit 1
fi
for host in "$@"; do
if ping -c1 -W1 "$host" &>/dev/null; then
echo "$host: reachable"
else
echo "$host: unreachable"
fi
done
EOF
chmod +x check-hosts.sh
./check-hosts.sh 127.0.0.1 badhost.example127.0.0.1: reachable
badhost.example: unreachableCommand output — you can loop over $(command), but word splitting makes it unsafe for paths with spaces. Prefer a while read loop for line-based data. The Bash for loop article shows more list patterns and cautions.
Always quote the loop variable when you use it: "$svc", "$host", "$f".
Read and Process Files Safely
The pattern for line in $(cat file) breaks on spaces and runs an extra cat process. Use while IFS= read -r instead.
Create a host list with comments and blank lines:
cat > hosts.txt <<'EOF'
# lab hosts
web1.example.com
db1.example.com
app1.example.com
EOF(no output on success)Read it line by line, skipping comments and empty lines:
while IFS= read -r host || [ -n "$host" ]; do
case "$host" in
''|\#*) continue ;;
esac
echo "ping target: $host"
done < hosts.txtping target: web1.example.com
ping target: db1.example.com
ping target: app1.example.comIFS= prevents leading or trailing whitespace from trimming fields. -r keeps backslashes literal. The || [ -n "$host" ] guard processes the last line even when the file does not end with a newline.
case compares the line against shell patterns: '' matches an empty line, while \#* matches a line beginning with #. Redirect < hosts.txt so the loop reads the file, not stdin from the keyboard.
The same pattern works for username lists before useradd in the administration scripts section.
Handle Errors and Exit Codes
Reliable scripts check critical steps instead of assuming every command succeeded.
| Pattern | Purpose |
|---|---|
command || exit 1 |
Stop when a command fails |
command && next |
Run next only on success |
if ! command; then ... fi |
Handle an expected failure |
exit 1 in usage blocks |
Signal bad invocation to cron or Ansible |
set -e tells Bash to exit when a command returns non-zero. It helps catch typos early, but pipelines and commands inside if tests follow special rules, so do not treat it as a substitute for explicit checks on useradd, tar, or systemctl.
Meaningful messages on stderr (>&2) plus a non-zero exit make failures obvious in logs:
if [ ! -f "$USERFILE" ]; then
echo "File not found: $USERFILE" >&2
exit 1
fiBuild Practical Administration Scripts
The four scripts below are small enough to read in one screen each. They combine variables, tests, loops, and exit codes from the sections above.
Check filesystem usage
You already built check-disk.sh in the if section. Run it from cron with the same threshold, or pass the threshold as $1 once you are comfortable with arguments.
Verify whether services are active
cat > check-services.sh <<'EOF'
#!/bin/bash
for svc in sshd crond firewalld; do
if systemctl is-active --quiet "$svc"; then
echo "$svc: active"
else
echo "$svc: inactive"
fi
done
EOF
chmod +x check-services.sh
./check-services.shsshd: active
crond: active
firewalld: activeCreate users from an input file
This script needs root or sudo. It skips users that already exist. ${1:-users.txt} uses the first argument when present and non-empty; otherwise it reads users.txt in the current directory.
cat > users.txt <<'EOF'
scriptlab1
scriptlab2
EOF
cat > create-users.sh <<'EOF'
#!/bin/bash
USERFILE="${1:-users.txt}"
STATUS=0
if [ ! -f "$USERFILE" ]; then
echo "File not found: $USERFILE" >&2
exit 1
fi
while IFS= read -r user || [ -n "$user" ]; do
[ -z "$user" ] && continue
if id "$user" &>/dev/null; then
echo "skip: $user already exists"
elif useradd -m "$user"; then
echo "created: $user"
else
echo "failed: could not create $user" >&2
STATUS=1
fi
done < "$USERFILE"
exit "$STATUS"
EOF
chmod +x create-users.sh
sudo ./create-users.shcreated: scriptlab1
created: scriptlab2The script continues with later usernames when one useradd fails, but exits with status 1 if any account creation failed — so cron or Ansible can detect a partial failure.
A second run reports skips instead of errors:
sudo ./create-users.shskip: scriptlab1 already exists
skip: scriptlab2 already existsRemove lab accounts when you finish. userdel accepts one login per invocation:
for user in scriptlab1 scriptlab2; do
sudo userdel -r "$user"
done(no output on success)Successful removal is normally silent. Re-run id scriptlab1 afterward if you want to confirm the accounts are gone.
Archive selected directories
mkdir -p logs && echo log > logs/app.log
cat > archive.sh <<'EOF'
#!/bin/bash
DEST="backup-$(date +%F).tar.gz"
if tar -czf "$DEST" data logs; then
echo "Created $DEST ($(du -h "$DEST" | awk '{print $1}'))"
else
echo "Archive creation failed" >&2
exit 1
fi
EOF
chmod +x archive.sh
./archive.shCreated backup-2026-08-05.tar.gz (4.0K)tar packs data and logs relative to the current working directory. Run the script from the directory that contains those folders.
Debug and Validate Bash Scripts
Before you run a new script against production systems, validate syntax and trace execution.
Syntax check — parse without running:
bash -n check-disk.sh && echo "syntax OK"syntax OKTrace mode — print each command before it runs:
bash -x ./greet.sh Bob 2>&1 | head -6+ '[' 1 -lt 1 ']'
+ echo 'Hello, Bob'
Hello, BobLines starting with + show what Bash executed. Use trace mode when a condition or quote behaves unexpectedly.
ShellCheck (optional, not installed by default on every host) scans scripts for quoting mistakes, unreachable code, and portability issues. Install the shellcheck package on your workstation and run shellcheck script.sh before you commit changes.
Common syntax problems:
- Missing spaces inside
[ ] - Unquoted
$varwhen the value might be empty or contain spaces - Forgetting
fi,done, oresacto close a block
References
- GNU Bash manual — Shell Commands
- GNU Bash manual — Conditional Constructs
- GNU Bash manual — Looping Constructs
- test(1) — file and string tests
- ShellCheck wiki — common warning explanations
Summary
You turned interactive Bash commands into reusable administration scripts: a shebang and execute permission let you run ./script.sh, while bash script.sh works without the executable bit. Variables and $(command) capture hostnames, dates, and disk figures; "$@" and $# let scripts respond to arguments the operator passes on the command line.
test and [ ] answer file, string, and integer questions; if branches on those tests or on a command's exit status. for loops walk service unit names, filenames, or "$@", and while IFS= read -r is the safe way to consume one line per iteration from a file. The four administration examples — disk threshold with LC_ALL=C df -P, service state, users from a list, and dated archives — are templates you can extend on RHEL-family hosts.
Before you deploy a script under cron or configuration management, run bash -n for syntax and bash -x when a branch misfires. When you need deeper coverage of elif chains, loop variants, or flag parsing, continue with the shell scripting tutorial and the linked topic guides rather than growing one file into an unmaintainable catch-all.

