Bash Shell Scripting for Linux Administrators

Tested on RHEL 10.2 (Coughlan)
Package bash 5.2.26-6.el10
coreutils 9.5-8.el10_2
systemd 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:

bash
mkdir -p /tmp/bash-script-lab && cd /tmp/bash-script-lab
output
(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.

bash
cat > hello.sh <<'EOF'
#!/bin/bash
echo "Hello from $(hostname) on $(date +%F)"
EOF
output
(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:

bash
chmod +x hello.sh
output
(no output on success)

Run it two ways — both are valid:

bash
bash hello.sh
output
Hello from localhost.localdomain on 2026-08-05
bash
./hello.sh
output
Hello from localhost.localdomain on 2026-08-05

bash 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.

bash
./hello.sh
echo "exit status after success: $?"
output
Hello from localhost.localdomain on 2026-08-05
exit status after success: 0
bash
false
echo "exit status after false: $?"
output
exit status after false: 1

Scripts 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".

bash
HOST=$(hostname -s)
DATE=$(date +%F)
echo "host=$HOST date=$DATE"
output
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.

bash
USAGE=$(LC_ALL=C df -P -h / | awk 'NR==2 {print $5}')
echo "root usage: $USAGE"
output
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:

bash
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
output
(no output on success)
bash
./greet.sh Alice
output
Hello, Alice
bash
./greet.sh
output
Usage: ./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:

bash
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" three
output
script=./show-args.sh count=3
  arg: one
  arg: two words
  arg: three

For 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:

bash
[ -d /etc ] && echo "/etc is a directory"
output
/etc is a directory
bash
[ -f /etc/passwd ] && echo "/etc/passwd is a file"
output
/etc/passwd is a file
bash
[ "abc" = "abc" ] && echo "strings equal"
output
strings equal
bash
[ 5 -gt 3 ] && echo "5 greater than 3"
output
5 greater than 3

You 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%.

bash
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.sh
output
OK: root filesystem is 35% full

The 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 [ ]:

bash
if systemctl is-active --quiet sshd; then
  echo "sshd is running"
else
  echo "sshd is not running"
fi
output
sshd is running

systemctl 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.

bash
for svc in sshd crond; do
  if systemctl is-active --quiet "$svc"; then
    echo "$svc: active"
  else
    echo "$svc: inactive"
  fi
done
output
sshd: active
crond: active

Filename patterns — list files in a directory (create sample files first if needed):

bash
mkdir -p data && echo sample > data/file1.txt
for f in data/*; do
  echo "file: $f"
done
output
file: data/file1.txt

Positional arguments — loop over hosts the operator passed:

bash
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.example
output
127.0.0.1: reachable
badhost.example: unreachable

Command 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:

bash
cat > hosts.txt <<'EOF'
# lab hosts
web1.example.com
db1.example.com

app1.example.com
EOF
output
(no output on success)

Read it line by line, skipping comments and empty lines:

bash
while IFS= read -r host || [ -n "$host" ]; do
  case "$host" in
    ''|\#*) continue ;;
  esac
  echo "ping target: $host"
done < hosts.txt
output
ping target: web1.example.com
ping target: db1.example.com
ping target: app1.example.com

IFS= 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:

bash
if [ ! -f "$USERFILE" ]; then
  echo "File not found: $USERFILE" >&2
  exit 1
fi

Build 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

bash
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.sh
output
sshd: active
crond: active
firewalld: active

Create 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.

bash
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.sh
output
created: scriptlab1
created: scriptlab2

The 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:

bash
sudo ./create-users.sh
output
skip: scriptlab1 already exists
skip: scriptlab2 already exists

Remove lab accounts when you finish. userdel accepts one login per invocation:

bash
for user in scriptlab1 scriptlab2; do
  sudo userdel -r "$user"
done
output
(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

bash
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.sh
output
Created 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
bash -n check-disk.sh && echo "syntax OK"
output
syntax OK

Trace mode — print each command before it runs:

bash
bash -x ./greet.sh Bob 2>&1 | head -6
output
+ '[' 1 -lt 1 ']'
+ echo 'Hello, Bob'
Hello, Bob

Lines 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 $var when the value might be empty or contain spaces
  • Forgetting fi, done, or esac to close a block

References


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.


Frequently Asked Questions

1. What is a Bash shell script?

A Bash shell script is a text file of shell commands saved with a shebang line so Bash runs it as a program. You use scripts to repeat administration tasks with the same steps every time instead of retyping commands.

2. What does the shebang line do?

The first line #!/bin/bash tells the kernel which interpreter to use when you run ./script.sh directly. Without a valid shebang and execute permission, you must call bash script.sh explicitly.

3. What is exit status in Bash?

Every command returns a number when it finishes. Zero means success; any non-zero value means failure. if tests that number, and you can read it immediately with $? or use it with && and ||.

4. What is the difference between bash script.sh and ./script.sh?

bash script.sh always uses the bash you typed on the command line and does not require execute permission. ./script.sh runs the file as an executable and uses the shebang to pick the interpreter. Both are common; pick one style per project and stay consistent.

5. Should I use set -e in every Bash script?

set -e stops the script when a command fails, which helps catch errors early, but it is not foolproof. Pipelines, commands inside if tests, and some builtins behave differently. Combine it with explicit checks on critical steps instead of treating it as a complete safety net.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)