Skip to content

Bash Scripting

Shell scripts automate tasks that would otherwise require typing the same commands repeatedly or trusting humans to remember a procedure:

  • Daily system backups
  • Software installation and patching across multiple servers
  • Periodic system monitoring and health checks
  • Raising alarms and sending notifications
  • Troubleshooting, auditing, and compliance checks
  • Any sequence of steps you want to run consistently and repeatably

A script is simply a text file containing a sequence of commands. The shell executes them one by one - exactly as if you typed them.


The interpreter is the program that reads and executes your script. Common choices:

InterpreterPathNotes
bash/bin/bashMost common; extensive feature set; default on most Linux systems
sh/bin/shPOSIX-compliant; usually symlinked to bash or dash
dash/bin/dashFaster than bash; fewer features; many distros use for /bin/sh
zsh/bin/zshInteractive-focused; not ideal for portability
python3/usr/bin/python3For complex tasks requiring data structures, network, APIs
perl/usr/bin/perlPowerful text processing; legacy systems

The available shells on a system are listed in /etc/shells.


#!/bin/bash
# Lines beginning with # are comments (except the shebang)
echo "Hello, Linux!"

The first line is the shebang (#!) - it tells the kernel which interpreter to use. It’s not a comment - it’s a special directive parsed by the kernel.

Terminal window
# Save as hello.sh, make executable, and run:
chmod +x hello.sh
./hello.sh # run directly
bash hello.sh # run without chmod via bash interpreter
#!/bin/bash
# Interactive reading of a variable
echo "Enter your name:"
read name
echo "Hello, $name!"

CharacterPurpose
#Comment (except #! on line 1)
\Line continuation (join next line)
;Command separator (run sequentially)
$Variable reference or command substitution prefix
>Redirect stdout (overwrite)
>>Redirect stdout (append)
<Redirect stdin
|Pipe output to next command
&Run in background
&&AND - run next command only if previous succeeded
||OR - run next command only if previous failed
Terminal window
make ; make install ; make clean # run all, regardless of success/failure
make && make install && make clean # stop on first failure
cat file1 || cat file2 || cat file3 # stop on first success
# Long lines - use backslash continuation:
sudo apt-get install autoconf automake bison \
build-essential chrpath curl diffstat \
libsdl1.2-dev libtool lzop make screen

Every command and script exits with a numeric code:

  • 0 = success
  • Non-zero = failure (specific values vary by program)
Terminal window
ls /etc/passwd
echo $? # 0 (success)
ls /nonexistent
echo $? # 2 (no such file)

$? always holds the exit code of the most-recently-run command.

Use exit codes in scripts to signal success or failure:

#!/bin/bash
if [ -f "$1" ]; then
echo "File exists"
exit 0
fi
echo "File not found"
exit 1

Terminal window
name="Alice"
count=42
greeting="Hello, $name" # variable interpolation in double quotes
echo $name
echo ${name} # brackets allow adjacent text: ${name}s → Alices
echo "$name" # always quote variables to handle spaces safely
Terminal window
./script.sh arg1 arg2 arg3
ParameterValue
$0Script name
$1First argument
$2, $3Second, third argument …
$*All arguments as one string
$@All arguments as separate strings
$#Number of arguments

Variables are local to the current process by default. To pass them to child processes:

Terminal window
export VAR=value # set and export in one step
VAR=value; export VAR # or separately
# Child processes get a COPY - changes in the child don't affect the parent
export PATH="$PATH:/opt/myapp/bin" # add to PATH and export

Typing export with no arguments lists all currently exported variables.

Capture and use the output of a command:

Terminal window
current_date=$(date +%Y-%m-%d) # preferred modern syntax
kernel_version=$(uname -r)
ls /lib/modules/$(uname -r)/ # nested use
old_style=`date` # deprecated backtick syntax

Always use $() over backticks - it’s readable, nestable, and unambiguous.


Terminal window
function_name () {
commands
}
# Example:
backup_file () {
local src="$1"
local dest="${2:-/tmp}"
cp "$src" "$dest/$(basename $src).bak"
echo "Backed up $src to $dest"
}
# Call it:
backup_file /etc/hosts
backup_file /etc/nginx/nginx.conf /var/backups
  • Declare before first use
  • Parameters accessed via $1, $2 etc. (same as script params)
  • local keeps variables scoped to the function - prevents polluting global scope

Terminal window
if condition; then
commands
elif other_condition; then
commands
else
commands
fi
Terminal window
# Test file existence
if [ -f "$1" ]; then
echo "File $1 exists"
else
echo "File $1 does not exist"
fi
# Modern double-bracket syntax (preferred):
if [[ -f "$file" && -r "$file" ]]; then
echo "File exists and is readable"
fi

The double-bracket [[ ]] is safer - avoids subtle problems with empty variables and enables &&/|| inside the test.

ConditionTrue if
-e fileFile exists (any type)
-f fileFile exists and is a regular file
-d fileFile is a directory
-s fileFile exists and has non-zero size
-r fileFile is readable
-w fileFile is writable
-x fileFile is executable
-g fileFile has sgid set
-u fileFile has suid set
Terminal window
man 1 test # full list of test conditions
Terminal window
if [[ "$str1" == "$str2" ]]; then echo "equal"; fi
if [[ "$str1" != "$str2" ]]; then echo "not equal"; fi
if [[ -z "$str" ]]; then echo "empty string"; fi
if [[ -n "$str" ]]; then echo "non-empty string"; fi
Terminal window
if [[ $num -eq 0 ]]; then echo "zero"; fi
OperatorMeaning
-eqEqual to
-neNot equal to
-gtGreater than
-ltLess than
-geGreater than or equal to
-leLess than or equal to

Terminal window
x=5
y=3
echo $((x + y)) # preferred - shell arithmetic expansion
echo $((x * y))
echo $((x / y)) # integer division
count=$((count + 1)) # increment
let x=(1 + 2) # using let built-in
expr 8 + 8 # deprecated - use $((...)) instead

Terminal window
for var in list; do
commands
done
# Examples:
for file in *.log; do
echo "Processing $file"
gzip "$file"
done
for i in {1..10}; do
echo "Number $i"
done
# C-style:
for (( i=0; i<10; i++ )); do
echo "$i"
done
Terminal window
while condition; do
commands
done
# Example: read file line by line
while IFS= read -r line; do
echo "$line"
done < /etc/hosts
# Countdown:
count=5
while [[ $count -gt 0 ]]; do
echo "T-$count"
count=$((count - 1))
done

The opposite of while - runs while the condition is false:

Terminal window
until condition; do
commands
done
until ping -c1 google.com &>/dev/null; do
echo "Waiting for network..."
sleep 2
done
echo "Network is up"

Some commands exist both as external programs in /usr/bin and as bash built-ins:

Built-inNotes
echoBuilt-in is slightly different from /bin/echo
cdMust be built-in - cannot change parent process’s directory otherwise
pwdMore efficient as built-in
readAlways built-in
printfMore portable than echo for formatted output
letArithmetic
exportEnvironment management
ulimitResource limits
logoutEnd a login session

help echo, help read, help cd - get built-in documentation from within bash.