Documentation Index
Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
Use this file to discover all available pages before exploring further.
Linux Shell Scripting
Automate repetitive tasks and build powerful scripts with Bash. Shell scripting is the duct tape of system administration — it is how you turn a sequence of commands you type repeatedly into something the machine does for you. Every DevOps pipeline, every deployment script, every monitoring check started as a shell script.
Basic Script Structure
Every Bash script starts with a shebang line that tells the system which interpreter to use.
#!/bin/bash
# The shebang (#!/bin/bash) must be the very first line of the file.
# Without it, the system may try to interpret your script with the wrong shell.
# Comments start with # and are ignored by the interpreter.
# Write comments for your future self -- you will forget what the script does in 3 months.
echo "Hello, World!"
# Make the script executable (required before you can run it directly)
chmod +x myscript.sh
# Run it
./myscript.sh
# Or explicitly invoke bash (does not require execute permission)
bash myscript.sh
Best practice: Always start scripts with set -euo pipefail right after the shebang. This combination catches most common bugs: -e exits on any error, -u treats unset variables as errors, -o pipefail catches failures in piped commands. Without this, scripts silently continue after errors, which is how you accidentally delete the wrong directory.
#!/bin/bash
set -euo pipefail
# Now if any command fails, the script stops immediately
# If you reference an undefined variable, the script stops
# If any command in a pipeline fails, the pipeline fails
Variables
#!/bin/bash
# Assign variables (no spaces around the = sign -- this is a common gotcha)
NAME="John"
AGE=30
# Use variables with $ prefix
echo "Name: $NAME"
echo "Age: $AGE"
# Use curly braces when the variable name could be ambiguous
echo "File: ${NAME}_report.txt" # Without braces, bash looks for $NAME_report
# Command substitution -- capture the output of a command into a variable
CURRENT_DIR=$(pwd)
DATE=$(date +%Y-%m-%d)
FILE_COUNT=$(ls -1 | wc -l)
echo "Today is $DATE, we are in $CURRENT_DIR with $FILE_COUNT files"
# Special variables (these are set automatically)
echo "Script name: $0" # The name of the script itself
echo "First argument: $1" # First argument passed to the script
echo "All arguments: $@" # All arguments as separate words
echo "Argument count: $#" # Number of arguments
echo "Last exit code: $?" # Exit code of the last command (0 = success)
echo "Current PID: $$" # Process ID of the current script
Always quote your variables: Use "$VAR" not $VAR. Without quotes, a variable containing spaces gets split into multiple words, causing subtle bugs. For example, rm $FILE where FILE=“my report.txt” tries to delete “my” and “report.txt” separately. rm "$FILE" does the right thing.
Conditionals
Bash conditionals test exit codes. In Linux, an exit code of 0 means success (true), and anything else means failure (false). This is the opposite of most programming languages.
#!/bin/bash
# Test based on script arguments
if [ "$1" == "start" ]; then
echo "Starting..."
elif [ "$1" == "stop" ]; then
echo "Stopping..."
else
echo "Usage: $0 {start|stop}"
exit 1 # Exit with error code to signal incorrect usage
fi
File and String Tests
# File tests (the checks you use most often in real scripts)
if [ -f "/etc/nginx/nginx.conf" ]; then
echo "Nginx config exists" # -f tests if a regular file exists
fi
if [ -d "/var/log" ]; then
echo "Log directory exists" # -d tests if a directory exists
fi
if [ -r "$FILE" ]; then
echo "File is readable" # -r tests read permission
fi
if [ -s "$FILE" ]; then
echo "File is not empty" # -s tests if file exists and has size > 0
fi
# String tests
if [ -z "$VAR" ]; then
echo "Variable is empty" # -z tests for zero-length string
fi
if [ -n "$VAR" ]; then
echo "Variable is not empty" # -n tests for non-zero-length string
fi
# Numeric comparisons (use -eq, -ne, -lt, -gt, not == or <)
if [ "$COUNT" -gt 10 ]; then
echo "More than 10 items"
fi
Modern Test Syntax
# Double brackets [[ ]] are the modern, safer alternative to [ ]
# They support pattern matching and do not require quoting variables
if [[ "$HOSTNAME" == *prod* ]]; then
echo "This is a production server"
fi
# Combine conditions with && (AND) and || (OR)
if [[ -f "$CONFIG" && -r "$CONFIG" ]]; then
echo "Config file exists and is readable"
fi
Loops
#!/bin/bash
# For loop with a range
for i in {1..5}; do
echo "Number: $i"
done
# For loop over a list of values
for env in dev staging production; do
echo "Deploying to $env..."
done
# While loop with a counter
counter=1
while [ $counter -le 5 ]; do
echo "Count: $counter"
((counter++))
done
# Loop through files in a directory (a very common pattern)
for file in /var/log/*.log; do
echo "Processing: $file"
wc -l "$file" # Count lines in each log file
done
# Read a file line by line (use this for config files, server lists, etc.)
while IFS= read -r line; do
echo "Server: $line"
ssh "$line" "uptime"
done < servers.txt
Gotcha with for loops and files: If no files match a glob pattern like *.txt, bash passes the literal string “*.txt” to the loop. Protect against this with shopt -s nullglob at the top of your script, which makes unmatched globs expand to nothing instead.
Functions
Functions let you organize code into reusable blocks. They are essential once your script grows beyond 20-30 lines.
#!/bin/bash
# Define a function (two equivalent syntaxes)
greet() {
local name="$1" # 'local' keeps the variable scoped to this function
echo "Hello, $name!"
}
# Call the function with an argument
greet "Alice"
greet "Bob"
# A more practical example: a function that logs with timestamps
log() {
local level="$1"
local message="$2"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $message"
}
log "INFO" "Deployment started"
log "ERROR" "Connection to database failed"
# Functions can return exit codes (0 = success, 1+ = failure)
check_service() {
if systemctl is-active --quiet "$1"; then
return 0 # Success
else
return 1 # Failure
fi
}
if check_service "nginx"; then
log "INFO" "Nginx is running"
else
log "ERROR" "Nginx is down, attempting restart..."
sudo systemctl start nginx
fi
Practical Script: Deployment Example
Here is a realistic script that ties together variables, conditionals, functions, and error handling. This is the kind of script you would actually use in production — notice how every step validates its preconditions, logs what it does, and has a path for failure.
#!/bin/bash
set -euo pipefail
# ============================================================
# Configuration -- all tunables at the top for easy adjustment
# ============================================================
APP_NAME="myapp"
DEPLOY_DIR="/opt/$APP_NAME"
BACKUP_DIR="/opt/backups"
LOG_FILE="/var/log/${APP_NAME}-deploy.log"
HEALTH_CHECK_RETRIES=5 # How many times to check if the service is healthy
HEALTH_CHECK_INTERVAL=2 # Seconds between health checks
# ============================================================
# Helper function: timestamped logging to both stdout and file
# ============================================================
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# ============================================================
# Input validation -- fail fast with a clear error message
# ============================================================
if [ $# -lt 1 ]; then
echo "Usage: $0 <artifact-path>"
echo "Example: $0 /tmp/myapp-v2.3.1.tar.gz"
exit 1
fi
ARTIFACT="$1"
if [ ! -f "$ARTIFACT" ]; then
log "ERROR: Artifact not found: $ARTIFACT"
exit 1
fi
# ============================================================
# Backup current version before deploying (you will thank yourself later)
# ============================================================
log "Creating backup..."
mkdir -p "$BACKUP_DIR"
BACKUP_PATH="$BACKUP_DIR/${APP_NAME}-$(date +%Y%m%d-%H%M%S)"
cp -r "$DEPLOY_DIR" "$BACKUP_PATH"
log "Backup saved to $BACKUP_PATH"
# ============================================================
# Deploy new version
# ============================================================
log "Deploying $ARTIFACT..."
tar -xzf "$ARTIFACT" -C "$DEPLOY_DIR"
# ============================================================
# Restart the service and verify it came up healthy
# ============================================================
log "Restarting $APP_NAME..."
sudo systemctl restart "$APP_NAME"
# Check health with retries -- services often need a few seconds to initialize
for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
sleep "$HEALTH_CHECK_INTERVAL"
if systemctl is-active --quiet "$APP_NAME"; then
log "Deployment successful (healthy after $((i * HEALTH_CHECK_INTERVAL))s)"
exit 0
fi
log "Health check $i/$HEALTH_CHECK_RETRIES: not ready yet..."
done
# If we get here, the service never became healthy
log "ERROR: Service failed to start after deployment. Rolling back..."
rm -rf "$DEPLOY_DIR"
cp -r "$BACKUP_PATH" "$DEPLOY_DIR"
sudo systemctl restart "$APP_NAME"
log "Rolled back to previous version"
exit 1
What makes this production-ready: (1) It validates inputs before doing anything destructive. (2) It creates a backup before overwriting. (3) It uses retries for the health check instead of a single fixed sleep. (4) It rolls back automatically on failure. A script without rollback capability is a script you will regret running at 2 AM.
Cron Jobs
Cron is the Linux scheduler. It runs scripts at specified times, unattended. Think of it as setting an alarm for your server.
# Edit your crontab (each user has their own)
crontab -e
# Cron format: minute hour day-of-month month day-of-week command
# ┌───────── minute (0-59)
# │ ┌─────── hour (0-23)
# │ │ ┌───── day of month (1-31)
# │ │ │ ┌─── month (1-12)
# │ │ │ │ ┌─ day of week (0-7, 0 and 7 are Sunday)
# │ │ │ │ │
# * * * * * command
# Run backup every day at 2:00 AM
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
# Run health check every 5 minutes
*/5 * * * * /opt/scripts/healthcheck.sh
# Run cleanup every Sunday at midnight
0 0 * * 0 /opt/scripts/cleanup.sh
# Run report on the first day of every month at 9:00 AM
0 9 1 * * /opt/scripts/monthly-report.sh
# View current cron jobs
crontab -l
Common cron pitfalls: (1) Cron runs in a minimal environment — your $PATH is different from your interactive shell, so use full paths to commands (/usr/bin/python3 not just python3). (2) Always redirect output to a log file (>> /var/log/myscript.log 2>&1), otherwise cron sends email for every run. (3) Cron does not load your .bashrc — if your script depends on environment variables, source them explicitly.
Key Takeaways
- Start every script with
#!/bin/bash and set -euo pipefail
- Always quote variables (
"$VAR") to handle spaces and special characters safely
- Use
local in functions to avoid polluting the global scope
- Test files with
-f, -d, -r, -s before operating on them
- Use
[[ ]] (double brackets) instead of [ ] for safer, more powerful tests
- Cron runs in a minimal environment — use full paths and redirect output
- Write logs with timestamps in production scripts so you can trace what happened
Congratulations — you have completed the Linux Crash Course.
Next: Docker Crash Course →