kitestacks-homelab/homelab-mastery/build-guide/without-ai/02-bash-scripting.md
kenpat 1e8319ee75 docs: comprehensive homelab-mastery rewrite with full build guides
Complete documentation suite for KiteStacks covering all 11 services across
2-host active-active architecture. Includes beginner track (with AI, 8 files)
and advanced track (without AI, 7 files) with time estimates, real troubleshooting
cases, and command-by-command explanations. Updates certifications roadmap to
reflect July 7 2026 A+ Core 2 exam goal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 01:08:43 -05:00

7.9 KiB
Raw Blame History

Without AI — Part 2: Bash Scripting

Track: Advanced (No AI)
Time for this section: 12 weeks

Bash is the language of the Linux shell. Almost every automation script in this homelab is a Bash script. You do not need to master it — you need to be able to read it, write simple scripts, and understand what a script does before you run it.


What Is a Script?

A script is a text file containing a sequence of shell commands. Instead of typing commands one by one, you put them in a file and run the file.

#!/usr/bin/env bash
# This is a comment

echo "Hello from my script"

The first line (#!/usr/bin/env bash) is called the shebang. It tells Linux which interpreter to use to run this file. Without it, Linux may use the wrong shell.

To run a script:

chmod +x myscript.sh    # Make it executable
./myscript.sh           # Run it

Or without making it executable:

bash myscript.sh

Variables

Variables store values you want to reuse:

name="kenpat"
port=3000
greeting="Hello, $name"

echo $name          # prints: kenpat
echo $port          # prints: 3000
echo $greeting      # prints: Hello, kenpat
echo "${name}s"     # prints: kenpats (braces needed when appending)

Special variables:

$0        # The script's own filename
$1 $2 $3  # Command-line arguments (first, second, third)
$#        # Number of arguments passed
$?        # Exit code of the last command (0 = success, non-zero = error)
$$        # Current process ID (PID)
$HOME     # Your home directory path
$USER     # Your username

Read-only environment variables:

export MY_VAR="value"    # Make available to child processes
printenv                 # List all environment variables
printenv MY_VAR          # Print one variable

Conditionals (if/else)

if [[ condition ]]; then
    # commands if true
elif [[ other_condition ]]; then
    # commands if second condition is true
else
    # commands if nothing was true
fi

Common conditions:

[[ -f /path/to/file ]]     # True if file exists and is a regular file
[[ -d /path/to/dir ]]      # True if directory exists
[[ -s /path/to/file ]]     # True if file exists and is non-empty
[[ -z "$var" ]]            # True if variable is empty
[[ -n "$var" ]]            # True if variable is NOT empty
[[ "$a" == "$b" ]]         # True if strings are equal
[[ "$a" != "$b" ]]         # True if strings are NOT equal
[[ $n -eq 5 ]]             # True if number equals 5
[[ $n -gt 5 ]]             # True if number is greater than 5
[[ $n -lt 5 ]]             # True if number is less than 5

Real example from the homelab:

if [[ $# -ne 1 ]]; then
    echo "Usage: $0 '<cloudflare_tunnel_token>'" >&2
    exit 2
fi

This checks that exactly one argument was provided ($# -ne 1 means "number of args is not equal to 1"). If not, it prints usage instructions and exits with code 2 (error). The >&2 sends the message to stderr (error output) instead of stdout (normal output).


Loops

For loop — iterate over a list:

for item in one two three; do
    echo "Item: $item"
done

# Iterate over files
for file in *.yml; do
    echo "Found compose file: $file"
done

# Iterate over a range of numbers
for i in {1..10}; do
    echo "Number: $i"
done

While loop — repeat while a condition is true:

count=0
while [[ $count -lt 5 ]]; do
    echo "Count: $count"
    count=$(( count + 1 ))
done

# Wait until a container is healthy
while [[ "$(docker inspect --format '{{.State.Health.Status}}' authentik)" != "healthy" ]]; do
    echo "Waiting for authentik..."
    sleep 5
done
echo "Authentik is healthy"

Functions

greet() {
    local name="$1"    # local = only exists inside this function
    echo "Hello, $name"
}

greet "kenpat"   # prints: Hello, kenpat
greet "world"    # prints: Hello, world

Why local variables matter: Without local, variables are global and can accidentally overwrite values from other parts of the script.


Error Handling

set -euo pipefail

Put this near the top of every script you write. It sets three behaviors:

  • -e — exit immediately if any command fails (returns non-zero exit code)
  • -u — exit if you use an undefined variable
  • -o pipefail — if any command in a pipeline fails, the whole pipeline fails

Without this, a script can silently continue after an error, potentially causing damage downstream (like deleting data after a failed backup).

Checking a command's result:

if curl -s https://example.com > /dev/null; then
    echo "Site is up"
else
    echo "Site is down"
fi

Exit codes:

exit 0    # Success
exit 1    # Generic error
exit 2    # Misuse (bad arguments)

String Manipulation

var="TUNNEL_TOKEN=abc123"

# Split by delimiter, take field 2
echo "$var" | cut -d= -f2        # prints: abc123

# But what if the value itself contains = signs?
echo "$var" | cut -d= -f2-       # prints: abc123 (f2- = from field 2 to end)

# Remove trailing newline
echo "hello" | tr -d '\n'

# Convert to lowercase
echo "HELLO" | tr '[:upper:]' '[:lower:]'

# Replace text
echo "hello world" | sed 's/world/there/'   # prints: hello there
echo "aabbcc" | sed 's/b/B/g'               # prints: aaBBcc (g = all occurrences)

# Extract with grep
echo "addr: 192.168.1.1" | grep -oP '\d+\.\d+\.\d+\.\d+'  # prints: 192.168.1.1

Here Documents (heredoc)

A heredoc lets you write multi-line strings inline:

cat <<'EOF'
This is line one
This is line two
Variables like $HOME are NOT expanded (because of the quotes around EOF)
EOF

cat <<EOF
This is line one
HOME is: $HOME   (expanded because no quotes)
EOF

Used in this homelab to write multi-line content to files:

cat > /tmp/fix.sql <<'EOF'
BEGIN;
UPDATE ServerSetting SET Value='{"enabled":true}' WHERE "Key"=40;
COMMIT;
EOF

Real Scripts in This Homelab

The Token Rotation Script

~/kitestacks-homelab/scripts/rollout-cloudflared-token.sh:

#!/usr/bin/env bash
set -euo pipefail

if [[ $# -ne 1 ]]; then
  echo "Usage: $0 '<cloudflare_tunnel_token>'" >&2
  exit 2
fi

token="$1"
monk_dir="${MONK_CLOUDFLARED_DIR:-$HOME/kitestacks-live/docker/cloudflared}"
kscloud1_host="${KSCLOUD1_HOST:?set KSCLOUD1_HOST, for example user@host}"
kscloud1_key="${KSCLOUD1_KEY:-$HOME/.ssh/id_ed25519_kscloud1}"
kscloud1_dir="${KSCLOUD1_CLOUDFLARED_DIR:-/opt/kitestacks/docker/cloudflared}"

Walking through each line:

  • set -euo pipefail — fail fast and safely
  • $# -ne 1 — check exactly one argument was given
  • ${MONK_CLOUDFLARED_DIR:-$HOME/...} — use environment variable if set, otherwise use default
  • ${KSCLOUD1_HOST:?...} — if KSCLOUD1_HOST is not set, exit with that error message

This is a real production script. Read it in full at that path.


Writing Your Own Scripts

Template for any script:

#!/usr/bin/env bash
set -euo pipefail

# --- Configuration (change these) ---
MY_VAR="${MY_ENV_VAR:-default_value}"
TARGET_HOST="${1:?Usage: $0 <hostname>}"

# --- Functions ---
log() {
    echo "[$(date '+%H:%M:%S')] $*"
}

die() {
    echo "ERROR: $*" >&2
    exit 1
}

# --- Main ---
log "Starting..."

if [[ ! -d "$TARGET_HOST" ]]; then
    die "Directory does not exist: $TARGET_HOST"
fi

log "Done."

Practice Exercises

  1. Write a script that checks if Docker is running and prints "Docker is up" or "Docker is down"
  2. Write a script that takes a service name as an argument and shows its logs: ./show-logs.sh forgejo
  3. Write a script that loops through all directories in ~/kitestacks-live/docker/ and prints the service name and whether it has a .env file
  4. Write a script that checks if a URL returns 200 OK and prints "UP" or "DOWN": ./check-url.sh https://gitforge.kitestacks.com
  5. Read and understand every line of scripts/rollout-cloudflared-token.sh

Next: Part 3 — Python Basics