Shell Scripting for Beginners: Automate Tasks on Linux

Shell Scripting for Beginners: Automate Tasks on Linux

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/bash vs #!/bin/sh?
    Use #!/bin/sh when 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/bash when you need bash-specific features: arrays, [[ conditionals, process substitution, local variables in functions, or set -o pipefail. If you are not sure, start with #!/bin/bash and switch to /bin/sh only if portability becomes a requirement.
  • What does set -e do and should I always use it?
    set -e (also written set -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 inside if conditions or after || are exempt, which can create false confidence. Use it in most scripts, but combine it with set -o pipefail and 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 with local varname to 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 — 0 for 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.