Day 14 - Shell Scripting Basics

2026-02-27·8 min read
linuxbashshellscriptinggetoptsloggingtraps

Day14 shell scripting

This lesson turns frequent command sequences into reusable Bash scripts. It covers script structure, strict mode, arguments and options, exit codes, functions, logging, input and output, and safe cleanup with traps.

Scope for today

Focus is portable Bash scripting for admin tasks and automation. Advanced topics such as arrays, associative maps, and unit tests appear later in the series.

Prerequisites

  • Day 1 through Day 13 completed
  • Bash and a text editor available

Script anatomy and a standard template

Use a clear shebang, strict mode, and a simple logging helper. Save this as ~/bin/template.sh and mark it executable.

bash
#!/usr/bin/env bash
# Purpose: one line summary
# Usage: template.sh [-n] [-f FILE] [ARGS...]
# Example: template.sh -n -f sample.txt foo bar
 
set -Eeuo pipefail
# -E: inherit ERR trap in functions
# -e: exit on error
# -u: error on unset variables
# -o pipefail: propagate failures in pipelines
 
IFS=$'\n\t'   # safer splitting on newlines and tabs
 
SCRIPT_NAME=${0##*/}
VERSION="1.0.0"
VERBOSE=0
LOG_FILE=""
 
cleanup() {
  # remove temp files or unlock resources
  # test that a var is set before using it
  [[ -n "${TMPDIR:-}" && -d "${TMPDIR:-}" ]] && rm -rf "$TMPDIR" || true
}
 
trap cleanup EXIT
trap 'echo "[$SCRIPT_NAME] error on line $LINENO" >&2' ERR
 
log() { printf '%s\n' "$*"; }
log_err() { printf '%s\n' "$*" >&2; }
logv() { (( VERBOSE )) && printf '%s\n' "$*"; }
 
usage() {
  cat <<USAGE
$SCRIPT_NAME v$VERSION
Usage: $SCRIPT_NAME [-n] [-f FILE] [-o LOG] [ARGS...]
  -n            dry run
  -f FILE       input file
  -o LOG        write log to file
  -v            verbose
  -h            help
USAGE
}
 
DRY_RUN=0
INPUT=""
 
while getopts ":nf:o:vh" opt; do
  case $opt in
    n) DRY_RUN=1 ;;
    f) INPUT=$OPTARG ;;
    o) LOG_FILE=$OPTARG ;;
    v) VERBOSE=1 ;;
    h) usage; exit 0 ;;
    :) log_err "Option -$OPTARG requires an argument"; usage; exit 2 ;;
    \?) log_err "Unknown option: -$OPTARG"; usage; exit 2 ;;
  esac
done
shift $((OPTIND-1))
 
# optional logging to a file
if [[ -n "$LOG_FILE" ]]; then
  exec > >(tee -a "$LOG_FILE") 2>&1
fi
 
main() {
  # create a private temp dir for this run
  TMPDIR=$(mktemp -d)
  logv "Temp dir: $TMPDIR"
 
  # example guard
  if [[ -n "$INPUT" && ! -r "$INPUT" ]]; then
    log_err "Cannot read input file: $INPUT"; exit 3
  fi
 
  # dry run helper
  run() { if (( DRY_RUN )); then printf '[dry-run] %s\n' "$*"; else "$@"; fi }
 
  # demo actions
  run echo "Processing..."
  [[ -n "$INPUT" ]] && run cp "$INPUT" "$TMPDIR/"
 
  log "Done"
}
 
main "$@"

Make it executable and try the help.

bash
chmod +x ~/bin/template.sh
~/bin/template.sh -h
Why strict mode helps

Unexpected empty variables or partial pipeline failures cause many bugs. set -Eeuo pipefail fails fast and surfaces errors with context.

Exit codes and testing conditions

Programs return an exit status from 0 to 255. Zero means success. The shell stores the last status in $?.

bash
false; echo $?
true;  echo $?

Use if with commands directly.

bash
if cp source.txt dest.txt; then
  echo "copy ok"
else
  echo "copy failed" >&2
fi

Modern test syntax uses [[ ... ]] for conditions.

bash
file="/etc/hosts"
if [[ -f "$file" && -r "$file" ]]; then echo readable; fi
 
num=42
if [[ $num -gt 10 ]]; then echo bigger; fi
 
s="hello"
if [[ $s == h* ]]; then echo prefix; fi

Functions, scope, and returns

Functions group work and keep scripts readable. Return codes indicate success or failure.

bash
is_port_open() {
  local host=$1 port=$2
  timeout 1 bash -c "</dev/tcp/$host/$port" &>/dev/null
}
 
if is_port_open 127.0.0.1 22; then echo open; else echo closed; fi

Variables created with local live only inside the function. Use return codes instead of echoing booleans when possible.

Reading input and here documents

Read line by line and handle spaces correctly.

bash
while IFS= read -r line; do
  printf 'line: %s\n' "$line"
done < file.txt

Use a here document for small template content.

bash
cat > config.ini <<'EOF'
[server]
port=8080
mode=prod
EOF
Quoted heredoc

Use a quoted delimiter such as <<'EOF' to avoid variable expansion inside the block.

Traps and safe cleanup

Traps run code when signals or shell events occur. Use them to clean temporary files or to unlock resources.

bash
TMPDIR=$(mktemp -d)
cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT

Handle INT and TERM as needed.

bash
trap 'echo interrupted; exit 130' INT
trap 'echo terminated; exit 143' TERM

Simple logging and timestamps

Add timestamps and levels to log messages.

bash
log()      { printf '%s %s\n' "$(date +%F' '%T)" "$*"; }
log_warn() { printf '%s WARN %s\n' "$(date +%F' '%T)" "$*" >&2; }
log_err()  { printf '%s ERR  %s\n' "$(date +%F' '%T)" "$*" >&2; }

Redirect both stdout and stderr to a file while still seeing output.

bash
script.sh |& tee script.log

Command line options with getopts

getopts parses short flags in a POSIX friendly way.

bash
#!/usr/bin/env bash
set -Eeuo pipefail
name=""
count=1
 
usage(){ echo "Usage: $0 -n NAME [-c COUNT]"; }
while getopts ":n:c:h" opt; do
  case $opt in
    n) name=$OPTARG ;;
    c) count=$OPTARG ;;
    h) usage; exit 0 ;;
    :) echo "Missing arg for -$OPTARG" >&2; usage; exit 2 ;;
    \?) echo "Unknown option -$OPTARG" >&2; usage; exit 2 ;;
  esac
done
shift $((OPTIND-1))
 
[[ -z "$name" ]] && { echo "-n required" >&2; usage; exit 2; }
for i in $(seq 1 "$count"); do echo "Hello $name"; done
Long options

getopts does not handle --long options. For long flags, write a small parser or use a higher level language for complex CLIs.

Idempotent and safe file edits

Prefer creating a new file and using a move to replace atomically.

bash
tmp=$(mktemp)
sed 's/DEBUG=false/DEBUG=true/' app.env > "$tmp"
install -m 0644 "$tmp" app.env
rm -f "$tmp"

The install command sets mode and updates the target in a single step.

Reusable script: sync a directory with checks

This script syncs a local directory to a remote host with preflight checks and a dry run. Save as ~/bin/sync-site.sh.

bash
#!/usr/bin/env bash
set -Eeuo pipefail
 
usage(){ echo "Usage: $0 -s SRC -d DEST -h HOST [-n]"; }
DRY=0; SRC=""; DEST=""; HOST=""
while getopts ":s:d:h:n" opt; do
  case $opt in
    s) SRC=$OPTARG ;;
    d) DEST=$OPTARG ;;
    h) HOST=$OPTARG ;;
    n) DRY=1 ;;
    :) usage; exit 2 ;;
    \?) usage; exit 2 ;;
  esac
done
shift $((OPTIND-1))
 
[[ -z "$SRC" || -z "$DEST" || -z "$HOST" ]] && { usage; exit 2; }
[[ ! -d "$SRC" ]] && { echo "Source not a directory" >&2; exit 3; }
 
opts=(-avzP -e ssh --delete)
(( DRY )) && opts+=(--dry-run)
 
# refuse to run with root as remote user
[[ "$HOST" == root@* ]] && { echo "Refusing root target" >&2; exit 4; }
 
rsync "${opts[@]}" "$SRC/" "$HOST:$DEST/"

Make it executable and dry run once.

bash
chmod +x ~/bin/sync-site.sh
~/bin/sync-site.sh -s ./site -d /srv/site -h user@server -n

Practical lab

  1. Create a script from the template that backs up a file to a timestamped directory, with -n dry run and -v verbose.

  2. Extend it to accept -o LOG and write both stdout and stderr to the log while still printing to the terminal.

  3. Add a trap to clean up a temporary work folder.

  4. Write a small script that reads domains.txt and fetches the HTTP status for each with curl -s -o /dev/null -w '%{http_code}' and prints domain,status lines. Add a -c N flag to control concurrency using xargs -P.

Troubleshooting

  • bad substitution or [: missing ] appears. Use Bash explicitly with #!/usr/bin/env bash and avoid features from other shells.
  • set -e exits too early during a compound command. Group risky commands with || true, or prefer explicit if checks where a failure is expected.
  • Variables are empty in subshells. Remember that pipelines run in subshells in some shells. Avoid relying on side effects of commands within pipelines.
  • getopts stops early. Ensure the options string matches flags and that flags expecting values include a trailing colon.
  • Traps do not run. Use set -E so the ERR trap propagates through functions, and avoid exec which replaces the shell process.

Next steps

Day 15 schedules work with cron and systemd timers. It covers crontab format, environment and PATH considerations, logging, and how to convert a cron job to a systemd timer with stronger dependency control.