March 26, 20269 min read

Bash Scripting for Developers — Automate Everything

A practical guide to Bash scripting for developers who want to automate deployments, data processing, and repetitive tasks. Covers variables, loops, functions, error handling, and real-world scripts.

bash scripting linux automation shell
Ad 336x280

Every developer eventually hits the same wall: you're running the same sequence of commands for the fifth time today. SSH into the server, pull the latest code, run migrations, restart the service, check the logs. Or you're processing a batch of files, renaming them, converting formats, moving them to the right directories.

You could keep doing it manually. Or you could spend 20 minutes writing a Bash script that does it forever.

Bash scripting isn't glamorous. It won't get you Twitter followers. But it's the single most practical skill gap between junior and senior developers. Seniors automate relentlessly. Everything that can be scripted gets scripted.

The Basics You Actually Need

Create a file, make it executable, run it:

#!/bin/bash
echo "Hello from a script"
chmod +x script.sh
./script.sh

The #!/bin/bash shebang tells the OS which interpreter to use. Always include it. Without it, the script might run with sh instead of bash, and they're not the same thing.

Variables

# Assignment (NO spaces around =)
name="Alice"
count=42
directory="/var/log/myapp"

# Usage
echo "Hello, $name"
echo "Processing $count files in $directory"

# Command substitution
current_date=$(date +%Y-%m-%d)
file_count=$(ls -1 | wc -l)
git_hash=$(git rev-parse --short HEAD)

echo "Deploying commit $git_hash on $current_date"

The $() syntax runs a command and captures its output. You'll use this constantly.

Common mistake: spaces around =. In most languages, name = "Alice" is fine. In Bash, it tries to run name as a command with = and "Alice" as arguments. No spaces.

Conditionals

# String comparison
if [[ "$env" == "production" ]]; then
    echo "Running production deploy"
elif [[ "$env" == "staging" ]]; then
    echo "Running staging deploy"
else
    echo "Unknown environment: $env"
    exit 1
fi

# Numeric comparison
if [[ $count -gt 100 ]]; then
    echo "Too many files"
fi

# File checks
if [[ -f "config.json" ]]; then
    echo "Config exists"
fi

if [[ -d "node_modules" ]]; then
echo "Dependencies installed"
fi

if [[ ! -f ".env" ]]; then
echo "ERROR: .env file missing"
exit 1
fi

Always use [[ ]] (double brackets), not [ ]. Double brackets handle spaces in variables, support && and ||, and do pattern matching. Single brackets are a legacy POSIX thing that bites you with word splitting.

OperatorMeaning
-f fileFile exists and is a regular file
-d dirDirectory exists
-z "$var"String is empty
-n "$var"String is not empty
-eq, -ne, -gt, -ltNumeric comparisons
==, !=String comparisons (inside [[ ]])

Loops

# Loop through files
for file in *.log; do
    echo "Processing $file"
    gzip "$file"
done

# Loop through a list
for server in web1 web2 web3 db1; do
    echo "Deploying to $server..."
    ssh "$server" "cd /app && git pull && systemctl restart app"
done

# Loop through command output
for branch in $(git branch --merged | grep -v main); do
    echo "Deleting merged branch: $branch"
    git branch -d "$branch"
done

# While loop (reading lines from a file)
while IFS= read -r line; do
    echo "Processing: $line"
done < urls.txt

# C-style for loop
for ((i = 1; i <= 10; i++)); do
    echo "Attempt $i"
done
Important: don't do for file in $(ls .log). If filenames have spaces, this breaks. Use for file in .log directly -- the glob handles spaces correctly.

Functions

log() {
    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[$timestamp] $*"
}

check_dependency() {
local cmd="$1"
if ! command -v "$cmd" &>/dev/null; then
log "ERROR: $cmd is not installed"
return 1
fi
log "$cmd found: $(command -v "$cmd")"
return 0
}

deploy() {
local environment="$1"
local version="$2"

log "Deploying version $version to $environment"

check_dependency "docker" || exit 1
check_dependency "kubectl" || exit 1

docker build -t "myapp:$version" .
docker push "registry.example.com/myapp:$version"
kubectl set image "deployment/myapp" "app=myapp:$version" -n "$environment"

log "Deploy complete"
}

deploy "production" "v1.4.2"

Use local for function variables. Without it, variables are global, which leads to incredibly confusing bugs in longer scripts.

Error Handling — The Most Ignored Part

By default, Bash happily continues after errors. This is terrifying in deployment scripts:

#!/bin/bash
cd /app/production        # Fails silently if directory doesn't exist
rm -rf *                  # Deletes everything in the CURRENT directory instead

Fix this with set options at the top of every script:

#!/bin/bash
set -euo pipefail

# -e: Exit immediately if any command fails
# -u: Treat unset variables as errors
# -o pipefail: Pipeline fails if any command in the pipe fails
set -e stops execution on failure:
set -e
cd /nonexistent    # Script exits here
echo "This never runs"
set -u catches typos:
set -u
echo "$DATABSE_URL"  # Error: DATABSE_URL: unbound variable
                      # (Without -u, silently becomes empty string)
set -o pipefail catches pipe failures:
# Without pipefail, this "succeeds" because wc succeeds
curl https://broken-api.com | wc -l

# With pipefail, curl's failure propagates
set -o pipefail
curl https://broken-api.com | wc -l  # Script exits with curl's error code

For cleanup on exit:

cleanup() {
    log "Cleaning up temporary files..."
    rm -rf "$tmp_dir"
}

trap cleanup EXIT # Runs on normal exit, errors, and signals

Arguments and Options

#!/bin/bash
set -euo pipefail

usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS] <environment>

Deploy the application to the specified environment.

Options:
-v, --version VERSION Version to deploy (default: latest git tag)
-d, --dry-run Show what would be done without doing it
-f, --force Skip confirmation prompt
-h, --help Show this help message
EOF
exit 1
}

VERSION=""
DRY_RUN=false
FORCE=false

while [[ $# -gt 0 ]]; do
case "$1" in
-v|--version)
VERSION="$2"
shift 2
;;
-d|--dry-run)
DRY_RUN=true
shift
;;
-f|--force)
FORCE=true
shift
;;
-h|--help)
usage
;;
-*)
echo "Unknown option: $1"
usage
;;
*)
ENVIRONMENT="$1"
shift
;;
esac
done

if [[ -z "${ENVIRONMENT:-}" ]]; then
echo "ERROR: Environment is required"
usage
fi

This pattern handles both short and long options, and gives helpful error messages. Copy-paste it into every script that takes arguments.

Real-World Scripts

Database backup:
#!/bin/bash
set -euo pipefail

DB_NAME="${1:?Usage: $0 <database_name>}"
BACKUP_DIR="/backups/postgres"
RETENTION_DAYS=30
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}_${DATE}.sql.gz"

mkdir -p "$BACKUP_DIR"

echo "Backing up $DB_NAME..."
pg_dump "$DB_NAME" | gzip > "$BACKUP_FILE"

size=$(du -h "$BACKUP_FILE" | cut -f1)
echo "Backup complete: $BACKUP_FILE ($size)"

# Clean old backups echo "Removing backups older than $RETENTION_DAYS days..." find "$BACKUP_DIR" -name "${DB_NAME}_*.sql.gz" -mtime +"$RETENTION_DAYS" -delete

remaining=$(find "$BACKUP_DIR" -name "${DB_NAME}_*.sql.gz" | wc -l)
echo "$remaining backups remaining"

Deployment script:
#!/bin/bash
set -euo pipefail

APP_DIR="/opt/myapp"
REPO="git@github.com:yourorg/myapp.git"
BRANCH="${1:-main}"
HEALTH_URL="http://localhost:3000/health"
MAX_RETRIES=30

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

log "Deploying branch: $BRANCH"

cd "$APP_DIR"
git fetch origin
git checkout "$BRANCH"
git pull origin "$BRANCH"

log "Installing dependencies..."
npm ci --production

log "Running migrations..."
npx prisma migrate deploy

log "Building..."
npm run build

log "Restarting service..."
systemctl restart myapp

log "Waiting for health check..."
for ((i = 1; i <= MAX_RETRIES; i++)); do
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
log "Application is healthy (attempt $i)"
exit 0
fi
sleep 1
done

log "ERROR: Health check failed after $MAX_RETRIES seconds"
log "Rolling back..."
git checkout HEAD~1
npm ci --production
npm run build
systemctl restart myapp
exit 1

Log analyzer:
#!/bin/bash
set -euo pipefail

LOG_FILE="${1:?Usage: $0 <log_file>}"

echo "=== Log Analysis: $LOG_FILE ==="
echo ""

total=$(wc -l < "$LOG_FILE")
errors=$(grep -c "ERROR" "$LOG_FILE" || true)
warnings=$(grep -c "WARN" "$LOG_FILE" || true)

echo "Total lines: $total"
echo "Errors: $errors"
echo "Warnings: $warnings"
echo ""

echo "=== Top 10 Error Messages ==="
grep "ERROR" "$LOG_FILE" \
| sed 's/.*ERROR //' \
| sort \
| uniq -c \
| sort -rn \
| head -10

echo ""
echo "=== Requests per Hour ==="
grep -oP '\d{4}-\d{2}-\d{2} \d{2}' "$LOG_FILE" \
| sort \
| uniq -c \
| tail -24

echo ""
echo "=== Top 10 Slowest Endpoints ==="
grep -oP 'took \K\d+ms.*' "$LOG_FILE" \
| sort -rn \
| head -10

Text Processing Power Tools

These one-liners are worth memorizing:

# Find and replace in all files
grep -rl "old_api_url" src/ | xargs sed -i 's|old_api_url|new_api_url|g'

# Count lines of code by language
find src/ -name ".ts" -o -name ".tsx" | xargs wc -l | sort -n | tail -20

# Extract unique IP addresses from nginx logs
awk '{print $1}' access.log | sort -u | wc -l

# Find large files in git history
git rev-list --objects --all | \
    git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | \
    grep '^blob' | sort -k3 -rn | head -20

# Kill all processes matching a pattern
pgrep -f "node.*dev-server" | xargs kill -9

# Watch a log file with color
tail -f app.log | grep --color=auto -E "ERROR|WARN|$"

# Parallel execution
cat servers.txt | xargs -P 4 -I {} ssh {} "sudo apt update && sudo apt upgrade -y"

Debugging

When a script isn't doing what you expect:

# Print every command before executing it
set -x

# Or enable it for just a section
set -x
some_problematic_function
set +x

The output shows exactly what's happening, with variables expanded:

+ git_hash=a1b2c3d
+ echo 'Deploying commit a1b2c3d on 2026-03-26'
Deploying commit a1b2c3d on 2026-03-26

ShellCheck — Your Best Friend

ShellCheck is a linter for shell scripts. Install it and run it on everything:
shellcheck deploy.sh

It catches real bugs: unquoted variables, useless use of cat, broken conditionals, POSIX compatibility issues. Most editors have ShellCheck integrations that highlight problems in real-time.

Bash vs Python — When to Switch

Use Bash when:


  • Orchestrating CLI tools and system commands

  • Simple file operations and text processing

  • Scripts under ~200 lines

  • Deployment automation, cron jobs


Switch to Python when:

  • You need data structures beyond arrays

  • Error handling gets complicated

  • You're parsing JSON or XML

  • The script exceeds 200 lines

  • You need cross-platform compatibility


There's no shame in a Bash script that calls a Python script for the complex parts. That's actually good architecture.

The developers I've worked with who are most productive all share one trait: they script everything. Not because they're lazy, but because they know humans make mistakes on repetitive tasks and scripts don't. Every deployment script, every data migration, every environment setup -- scripted, version-controlled, and documented by its own existence. If you're building side projects on CodeUp, wrapping your dev workflow in a few Bash scripts is one of the highest-leverage things you can do.

Ad 728x90