Shell scripting is how Linux sysadmins stop doing the same task twice. If you have ever run a sequence of commands more than once, that sequence belongs in a script. Bash scripts are plain text files with Linux commands — no compiler needed, no runtime to install. This guide teaches you real scripting from the ground up.
Your First Script
#!/bin/bash
# myscript.sh
echo "Hello, $(whoami)!"
echo "Today is: $(date +'%A, %B %d %Y')"
echo "Current directory: $(pwd)"
Save it, make it executable, and run it:
chmod +x myscript.sh
./myscript.sh
The #!/bin/bash shebang line tells the OS which interpreter to use. Always include it.
Variables
#!/bin/bash
NAME="alok"
BACKUP_DIR="/var/backups"
TODAY=$(date +%Y-%m-%d) # Command substitution
echo "User: $NAME"
echo "Backup dir: ${BACKUP_DIR}/daily"
echo "Date: $TODAY"
Rules for variables: no spaces around =; use ${VAR} when concatenating; single quotes suppress expansion.
Reading User Input
#!/bin/bash
read -p "Enter server hostname: " HOST
read -sp "Enter password: " PASS
echo
echo "Connecting to $HOST..."
Conditionals
#!/bin/bash
FILE="/etc/nginx/nginx.conf"
if [ -f "$FILE" ]; then
echo "Config found."
elif [ -d "/etc/nginx" ]; then
echo "Directory exists but config missing."
else
echo "Nginx not installed."
fi
Common test operators:
-f— file exists and is a regular file-d— directory exists-z "$VAR"— string is empty-n "$VAR"— string is not empty-eq,-ne,-gt,-lt— integer comparisons=,!=— string comparisons
if [ "$USER" = "root" ]; then
echo "Running as root."
fi
if [ $# -eq 0 ]; then
echo "Usage: $0 filename"
exit 1
fi
Loops
For Loop
for SERVER in web01 web02 db01; do
echo "Pinging $SERVER..."
ping -c 1 $SERVER &>/dev/null && echo " OK" || echo " UNREACHABLE"
done
for FILE in /var/log/*.log; do
echo "Processing: $FILE"
gzip "$FILE"
done
While Loop
COUNT=0
while [ $COUNT -lt 5 ]; do
echo "Count: $COUNT"
COUNT=$((COUNT + 1))
done
Functions
#!/bin/bash
log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1"
}
backup_dir() {
local SRC="$1"
local DEST="$2"
log "Backing up $SRC to $DEST"
tar -czf "$DEST/backup-$(date +%Y%m%d).tar.gz" "$SRC"
log "Done."
}
backup_dir /etc/nginx /var/backups
Exit Codes and Error Handling
#!/bin/bash
set -e # Exit on any error
set -u # Treat unset variables as errors
set -o pipefail # Catch errors in pipes
cp /etc/hosts /backup/ || { echo "Backup failed!"; exit 1; }
systemctl restart nginx
if [ $? -ne 0 ]; then
echo "Nginx restart failed"
exit 1
fi
Practical Script: Disk Space Alert
#!/bin/bash
THRESHOLD=80
df -h | grep -vE "^(Filesystem|tmpfs)" | while read LINE; do
USAGE=$(echo "$LINE" | awk "{print $5}" | tr -d "%")
MOUNT=$(echo "$LINE" | awk "{print $6}")
if [ "$USAGE" -gt "$THRESHOLD" ]; then
echo "ALERT: $MOUNT is at ${USAGE}% on $(hostname)"
fi
done
Script Arguments
#!/bin/bash
ENVIRONMENT=$1
VERSION=$2
if [ -z "$ENVIRONMENT" ] || [ -z "$VERSION" ]; then
echo "Usage: $0 environment version"
exit 1
fi
echo "Deploying version $VERSION to $ENVIRONMENT"
# $0=script name, $1 $2...=arguments, $#=count, $@=all args
Summary
Shell scripting turns repetitive work into repeatable, reliable automation. Start small — convert your most common command sequences into scripts. Add error handling with set -e, use functions to keep code organized, and always test on a non-production system first. Over time, your script library becomes one of your most valuable tools as a sysadmin.
Debugging Shell Scripts
Even experienced developers write shell scripts that misbehave silently. Bash provides several built-in tools that make finding and fixing bugs much easier.
The simplest technique is to add bash -x when running your script. Trace mode prints every command before it executes, with a + prefix, so you can see exactly what the shell is doing:
bash -x myscript.sh
To trace only a section of a script without rerunning it from outside, insert set -x before the suspect block and set +x after it:
#!/bin/bash
echo "Starting..."
set -x # begin tracing
for f in /tmp/*.log; do
gzip "$f"
done
set +x # stop tracing
echo "Done."
Always check the exit code of important commands with echo $? — a value of 0 means success; anything else is failure. Rather than checking manually, add set -e at the top of your script to make it abort automatically on any unhandled non-zero exit code:
#!/bin/bash
set -e # abort on any error
set -u # treat unset variables as errors
set -o pipefail # fail if any command in a pipe fails
cp important.conf /backup/ || { echo "Backup failed!" >&2; exit 1; }
set -u catches a very common class of bug — typos in variable names that silently expand to empty strings. set -o pipefail is equally important: by default, a pipeline like broken_cmd | tee output.log returns the exit code of tee (success), hiding the fact that broken_cmd failed.
For thorough static analysis before you even run a script, install ShellCheck:
sudo apt install shellcheck # Debian/Ubuntu
shellcheck myscript.sh
ShellCheck catches quoting errors, undeclared variables, deprecated syntax, and dozens of other common mistakes, and it explains every warning with a link to a detailed rationale.
Frequently Asked Questions
-
When should I use
#!/bin/bashvs#!/bin/sh?
Use#!/bin/shwhen you need maximum portability and are sticking to POSIX-compliant syntax — important for scripts that run on minimal systems like Alpine Linux, BusyBox environments, or inside Docker images where bash may not be installed. Use#!/bin/bashwhen you need bash-specific features: arrays,[[conditionals, process substitution,localvariables in functions, orset -o pipefail. If you are not sure, start with#!/bin/bashand switch to/bin/shonly if portability becomes a requirement. -
What does
set -edo and should I always use it?
set -e(also writtenset -o errexit) causes the script to exit immediately when any command returns a non-zero exit status. It prevents scripts from continuing blindly after a failure. However, it has some surprising edge cases: commands insideifconditions or after||are exempt, which can create false confidence. Use it in most scripts, but combine it withset -o pipefailand explicit error handling (|| { echo "step failed"; exit 1; }) for robust production scripts. -
How do I pass arguments to a function in bash?
Inside a bash function, arguments are accessed as$1,$2,$3, and so on — the same syntax as script arguments, but scoped to the function call. Call the function by name followed by its arguments:my_function arg1 arg2. Use"$@"inside the function to forward all arguments to another command while preserving quoting. Declare local variables withlocal varnameto avoid polluting the global scope. -
How do I check if the previous command succeeded?
The special variable$?holds the exit code of the most recently executed command —0for success, non-zero for failure. Check it directly:if [ $? -ne 0 ]; then echo "Failed"; fi. More idiomatically, test the command inline:if command; then echo "OK"; else echo "Failed"; fi. Or use short-circuit operators:command && echo "OK" || echo "Failed". Avoid checking$?after any other command runs, as it is immediately overwritten.