DEV Community

Anup Karanjkar
Anup Karanjkar

Posted on • Originally published at wowhow.cloud

Bash Scripting for DevOps: 20 Scripts That Automate Everything

Every production system has a graveyard of half-written Bash scripts that were good enough until they weren't. The patterns that separate robust shell automation from brittle one-liners are not complicated — they are just rarely taught together. This guide covers the idioms you need first, then 20 complete scripts for real DevOps workflows.

Try it yourself: Free Cron Expression Builder — free, no signup, runs in your browser.

Bash Scripting Fundamentals You Can't Skip

Every production Bash script should start with these three lines. They prevent silent failures, undefined variable bugs, and broken pipe errors from hiding real problems.

#!/usr/bin/env bash
set -euo pipefail
# -e: exit on any error
# -u: treat unset variables as errors
# -o pipefail: if any command in a pipeline fails, the pipeline fails
# Together these prevent scripts from continuing past errors silently

# Also useful for debugging:
# set -x    # Print each command before executing
# trap 'echo "Error on line $LINENO"' ERR  # More specific error info
Enter fullscreen mode Exit fullscreen mode

Script 1: Service Health Check

#!/usr/bin/env bash
# scripts/health-check.sh — Check multiple services, alert on failure
set -euo pipefail

SERVICES=(
  "web:https://myapp.com/health:200"
  "api:https://api.myapp.com/health:200"
  "docs:https://docs.myapp.com/:200"
)
ALERT_EMAIL="${ALERT_EMAIL:-ops@myapp.com}"
SLACK_WEBHOOK="${SLACK_WEBHOOK:-}"
TIMEOUT=10
FAILED=0

check_service() {
  local name="$1" url="$2" expected_code="$3"
  local actual_code
  actual_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT" "$url" 2>/dev/null || echo "000")

  if [[ "$actual_code" == "$expected_code" ]]; then
    echo "  [OK]   $name ($url) → $actual_code"
    return 0
  else
    echo "  [FAIL] $name ($url) → expected $expected_code, got $actual_code"
    return 1
  fi
}

send_alert() {
  local message="$1"
  echo "$message"

  if [[ -n "$SLACK_WEBHOOK" ]]; then
    curl -s -X POST "$SLACK_WEBHOOK"       -H 'Content-type: application/json'       -d "{"text": "🚨 Health Check Failure\n$message"}"       > /dev/null
  fi
}

echo "Health check — $(date -u '+%Y-%m-%d %H:%M:%S UTC')"

for service_def in "${SERVICES[@]}"; do
  IFS=':' read -r name url expected /dev/null || echo 000)"
  fi
done

echo ""
if [[ $FAILED -eq 0 ]]; then
  echo "All services healthy."
  exit 0
else
  echo "$FAILED service(s) FAILED."
  exit 1
fi
Enter fullscreen mode Exit fullscreen mode

Script 2: Log Rotation and Compression

#!/usr/bin/env bash
# scripts/rotate-logs.sh — Rotate, compress, and clean up application logs
set -euo pipefail

LOG_DIR="${LOG_DIR:-/var/log/myapp}"
RETENTION_DAYS="${RETENTION_DAYS:-14}"
MAX_SIZE_MB="${MAX_SIZE_MB:-100}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

rotate_if_large() {
  local log_file="$1"
  local size_mb
  size_mb=$(du -m "$log_file" 2>/dev/null | cut -f1)

  if [[ $size_mb -ge $MAX_SIZE_MB ]]; then
    local rotated="${log_file}.${TIMESTAMP}"
    mv "$log_file" "$rotated"
    touch "$log_file"
    chmod 640 "$log_file"
    gzip "$rotated"
    echo "Rotated: $log_file (was ${size_mb}MB) → ${rotated}.gz"
    # Signal app to reopen log file
    if systemctl is-active --quiet myapp 2>/dev/null; then
      systemctl kill --kill-who=main --signal=USR1 myapp
    fi
  fi
}

cleanup_old_logs() {
  local count
  count=$(find "$LOG_DIR" -name "*.gz" -mtime +"$RETENTION_DAYS" | wc -l)
  if [[ $count -gt 0 ]]; then
    find "$LOG_DIR" -name "*.gz" -mtime +"$RETENTION_DAYS" -delete
    echo "Deleted $count log files older than $RETENTION_DAYS days"
  fi
}

if [[ ! -d "$LOG_DIR" ]]; then
  echo "Log directory $LOG_DIR does not exist" >&2
  exit 1
fi

echo "Log rotation — $(date -u)"
for log_file in "$LOG_DIR"/*.log; do
  [[ -f "$log_file" ]] && rotate_if_large "$log_file"
done
cleanup_old_logs
echo "Done. Disk usage: $(du -sh "$LOG_DIR" | cut -f1)"
Enter fullscreen mode Exit fullscreen mode

Script 3: Automated PostgreSQL Backup

#!/usr/bin/env bash
# scripts/backup-postgres.sh — Full + incremental backups with S3 upload
set -euo pipefail

DB_HOST="${POSTGRES_HOST:-localhost}"
DB_PORT="${POSTGRES_PORT:-5432}"
DB_USER="${POSTGRES_USER:-postgres}"
DB_NAME="${POSTGRES_DB:-myapp}"
BACKUP_DIR="${BACKUP_DIR:-/backups/postgres}"
S3_BUCKET="${S3_BUCKET:-}"
RETENTION_DAYS=7
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"

mkdir -p "$BACKUP_DIR"

echo "[$(date -u)] Starting backup of $DB_NAME..."

# Perform the dump
PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump   -h "$DB_HOST"   -p "$DB_PORT"   -U "$DB_USER"   -F plain   --no-owner   --no-acl   "$DB_NAME" | gzip -9 > "$BACKUP_FILE"

BACKUP_SIZE=$(du -sh "$BACKUP_FILE" | cut -f1)
echo "[$(date -u)] Backup complete: $BACKUP_FILE ($BACKUP_SIZE)"

# Verify the backup is valid gzip
if ! gzip -t "$BACKUP_FILE" 2>/dev/null; then
  echo "ERROR: Backup file is corrupted!" >&2
  rm -f "$BACKUP_FILE"
  exit 1
fi

# Upload to S3 if configured
if [[ -n "$S3_BUCKET" ]]; then
  aws s3 cp "$BACKUP_FILE" "s3://${S3_BUCKET}/postgres/$(basename "$BACKUP_FILE")"     --storage-class STANDARD_IA
  echo "Uploaded to S3: s3://${S3_BUCKET}/postgres/$(basename "$BACKUP_FILE")"
fi

# Cleanup old local backups
deleted=$(find "$BACKUP_DIR" -name "*.sql.gz" -mtime +"$RETENTION_DAYS" -print -delete | wc -l)
[[ $deleted -gt 0 ]] && echo "Deleted $deleted old backups"

echo "[$(date -u)] Backup finished."
Enter fullscreen mode Exit fullscreen mode

Script 4: Zero-Downtime Deploy with Rollback

#!/usr/bin/env bash
# scripts/deploy.sh — Deploy with health check and automatic rollback
set -euo pipefail

APP_DIR="${APP_DIR:-/opt/myapp}"
COMPOSE_FILE="${APP_DIR}/docker-compose.yml"
HEALTH_URL="http://localhost:3000/health"
HEALTH_TIMEOUT=60
ROLLBACK_IMAGE="${APP_DIR}/.last-good-image"

log() { echo "[$(date -u '+%H:%M:%S')] $*"; }
fail() { echo "ERROR: $*" >&2; exit 1; }

# Save current image as rollback target
if [[ -f "$ROLLBACK_IMAGE" ]]; then
  PREVIOUS_IMAGE=$(cat "$ROLLBACK_IMAGE")
  log "Previous image: $PREVIOUS_IMAGE"
fi

rollback() {
  log "ROLLING BACK..."
  if [[ -f "$ROLLBACK_IMAGE" ]]; then
    local prev_image
    prev_image=$(cat "$ROLLBACK_IMAGE")
    docker tag "$prev_image" myapp:latest
    docker compose -f "$COMPOSE_FILE" up -d app
    log "Rolled back to $prev_image"
  else
    log "No rollback image available"
  fi
}

wait_healthy() {
  local timeout="$1"
  local elapsed=0
  log "Waiting for health check at $HEALTH_URL (timeout: ${timeout}s)..."

  while [[ $elapsed -lt $timeout ]]; do
    if curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
      log "Health check passed after ${elapsed}s"
      return 0
    fi
    sleep 5
    elapsed=$((elapsed + 5))
  done
  return 1
}

# Trap errors for rollback
trap 'log "Deploy failed. Running rollback..."; rollback; exit 1' ERR

log "Starting deploy..."

# Save current image for rollback
current_image=$(docker inspect --format='{{.Config.Image}}' myapp-app-1 2>/dev/null || echo "")
[[ -n "$current_image" ]] && echo "$current_image" > "$ROLLBACK_IMAGE"

# Pull new image
log "Pulling latest image..."
docker compose -f "$COMPOSE_FILE" pull app

# Rolling restart (start new, wait healthy, remove old)
log "Starting new container..."
docker compose -f "$COMPOSE_FILE" up -d --no-deps app

if ! wait_healthy "$HEALTH_TIMEOUT"; then
  fail "Health check timed out after ${HEALTH_TIMEOUT}s"
fi

log "Deploy successful!"
Enter fullscreen mode Exit fullscreen mode

Script 5: Disk Space Monitoring with Alerts

#!/usr/bin/env bash
# scripts/disk-monitor.sh — Alert when disk usage exceeds threshold
set -euo pipefail

THRESHOLD_WARN=75
THRESHOLD_CRIT=90
SLACK_WEBHOOK="${SLACK_WEBHOOK:-}"
HOSTNAME_ALIAS="${HOSTNAME_ALIAS:-$(hostname)}"

check_disk() {
  while IFS= read -r line; do
    local usage mount
    usage=$(echo "$line" | awk '{print $5}' | tr -d '%')
    mount=$(echo "$line" | awk '{print $6}')

    if [[ $usage -ge $THRESHOLD_CRIT ]]; then
      alert "CRITICAL" "$mount" "$usage"
    elif [[ $usage -ge $THRESHOLD_WARN ]]; then
      alert "WARNING" "$mount" "$usage"
    fi
  done  /dev/null
  fi
}

check_disk
Enter fullscreen mode Exit fullscreen mode

Script 6: Redis Memory Check and Eviction Policy Monitor

#!/usr/bin/env bash
# scripts/redis-check.sh — Monitor Redis memory, keys, and hit rate
set -euo pipefail

REDIS_CLI="${REDIS_CLI:-redis-cli}"
REDIS_AUTH="${REDIS_PASSWORD:-}"
AUTH_FLAG=$( [[ -n "$REDIS_AUTH" ]] && echo "-a $REDIS_AUTH" || echo "" )

redis_cmd() {
  # shellcheck disable=SC2086
  $REDIS_CLI $AUTH_FLAG "$@" 2>/dev/null
}

echo "=== Redis Status Report — $(date -u) ==="

# Memory
used_mb=$(redis_cmd info memory | grep "used_memory_human" | cut -d: -f2 | tr -d '
')
max_mb=$(redis_cmd info memory | grep "maxmemory_human" | cut -d: -f2 | tr -d '
')
echo "Memory: $used_mb / $max_mb"

# Hit rate
hits=$(redis_cmd info stats | grep "keyspace_hits:" | cut -d: -f2 | tr -d '
')
misses=$(redis_cmd info stats | grep "keyspace_misses:" | cut -d: -f2 | tr -d '
')
if [[ $((hits + misses)) -gt 0 ]]; then
  hit_rate=$(echo "scale=1; $hits * 100 / ($hits + $misses)" | bc)
  echo "Cache hit rate: ${hit_rate}% ($hits hits, $misses misses)"
fi

# Keyspace
redis_cmd info keyspace

# Eviction policy
policy=$(redis_cmd config get maxmemory-policy | tail -1)
echo "Eviction policy: $policy"

# Connected clients
clients=$(redis_cmd info clients | grep "connected_clients:" | cut -d: -f2 | tr -d '
')
echo "Connected clients: $clients"

# Top key prefixes (by count)
echo ""
echo "=== Key prefix breakdown ==="
redis_cmd --scan | sed 's/:.*//' | sort | uniq -c | sort -rn | head -10
Enter fullscreen mode Exit fullscreen mode

Script 7: Cron Job Wrapper with Logging and Lock

#!/usr/bin/env bash
# scripts/cron-wrapper.sh — Run any cron job with locking, logging, timeout
# Usage: cron-wrapper.sh    [args...]
set -euo pipefail

JOB_NAME="$1"; shift
TIMEOUT="$1"; shift
COMMAND="$@"

LOCK_FILE="/tmp/cron-lock-${JOB_NAME}"
LOG_FILE="/var/log/cron/${JOB_NAME}.log"
MAX_LOG_LINES=10000

mkdir -p "$(dirname "$LOG_FILE")"

log() { echo "[$(date -u '+%Y-%m-%d %H:%M:%S')] [$JOB_NAME] $*" | tee -a "$LOG_FILE"; }

# Exclusive lock — skip if already running
exec 9>"$LOCK_FILE"
if ! flock --nonblock 9; then
  log "Skipping: already running (lock held)"
  exit 0
fi

log "START: $COMMAND"
START_TIME=$(date +%s)

# Run with timeout
if timeout "$TIMEOUT" bash -c "$COMMAND" >> "$LOG_FILE" 2>&1; then
  EXIT_CODE=0
else
  EXIT_CODE=$?
fi

ELAPSED=$(($(date +%s) - START_TIME))

if [[ $EXIT_CODE -eq 0 ]]; then
  log "OK: completed in ${ELAPSED}s"
elif [[ $EXIT_CODE -eq 124 ]]; then
  log "TIMEOUT after ${TIMEOUT}s"
else
  log "FAILED with exit code $EXIT_CODE after ${ELAPSED}s"
fi

# Trim log file
if [[ $(wc -l  "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"
fi

exit $EXIT_CODE
Enter fullscreen mode Exit fullscreen mode

Cron Patterns That Actually Work

# /etc/cron.d/myapp — Production cron definitions

# Health check every 5 minutes
*/5 * * * * deploy /opt/myapp/scripts/cron-wrapper.sh health-check 30 /opt/myapp/scripts/health-check.sh

# Database backup daily at 2am UTC
0 2 * * * deploy /opt/myapp/scripts/cron-wrapper.sh postgres-backup 600 /opt/myapp/scripts/backup-postgres.sh

# Log rotation daily at 3am
0 3 * * * root /opt/myapp/scripts/rotate-logs.sh

# Disk check every 15 minutes
*/15 * * * * root /opt/myapp/scripts/disk-monitor.sh

# Redis check hourly
0 * * * * deploy /opt/myapp/scripts/redis-check.sh >> /var/log/redis-check.log 2>&1

# View counter flush every 30 seconds via a looping script (not cron)
# See write-behind pattern above
Enter fullscreen mode Exit fullscreen mode

Script 8: Environment Variable Validation at Startup

#!/usr/bin/env bash
# scripts/validate-env.sh — Fail fast if required vars are missing
set -euo pipefail

REQUIRED_VARS=(
  "DATABASE_URL"
  "REDIS_URL"
  "JWT_SECRET"
  "NODE_ENV"
  "PORT"
)

OPTIONAL_VARS=(
  "SLACK_WEBHOOK"
  "SENTRY_DSN"
  "S3_BUCKET"
)

MISSING=0
for var in "${REQUIRED_VARS[@]}"; do
  if [[ -z "${!var:-}" ]]; then
    echo "ERROR: Required environment variable '$var' is not set" >&2
    MISSING=$((MISSING + 1))
  fi
done

for var in "${OPTIONAL_VARS[@]}"; do
  if [[ -z "${!var:-}" ]]; then
    echo "WARN: Optional variable '$var' is not set"
  fi
done

if [[ $MISSING -gt 0 ]]; then
  echo "$MISSING required variable(s) missing. Aborting." >&2
  exit 1
fi

echo "All required environment variables are set."
Enter fullscreen mode Exit fullscreen mode

People Also Ask

What does set -euo pipefail do in Bash scripts?

set -e exits the script on any command that returns a non-zero exit code. set -u treats any unset variable reference as an error. set -o pipefail makes a pipeline fail if any command in the pipe fails, not just the last one. Together they prevent scripts from silently continuing past errors. Every production Bash script should start with these.

How do I prevent a cron job from running twice simultaneously?

Use flock with a lock file: flock --nonblock /tmp/my-job.lock my-command. If the lock is already held, the command exits immediately with code 1. The cron wrapper script above shows the full pattern with logging and timeout.

What is the best way to handle secrets in Bash scripts?

Never hardcode secrets. Read them from files (POSTGRES_PASSWORD=$(cat /run/secrets/postgres_password)), environment variables set externally (REDIS_PASSWORD=${REDIS_PASSWORD}), or a secrets manager (AWS Secrets Manager, HashiCorp Vault) via their CLI. Avoid passing secrets as command-line arguments — they appear in ps aux output. Set PGPASSWORD as an environment variable for pg_dump rather than using -W.

Ready-to-deploy Bash script libraries, DevOps automation templates, and monitoring starter kits are at WOWHOW developer tools. Browse all DevOps and automation resources at wowhow.cloud/browse.

Originally published at wowhow.cloud

Top comments (0)