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)"
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."
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!"
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 < <(df -h | tail -n +2 | grep -v tmpfs)
}
alert() {
local level="$1" mount="$2" usage="$3"
local icon="${level//CRITICAL/🔴}"
icon="${icon//WARNING/🟡}"
local msg="${icon} Disk $level on $HOSTNAME_ALIAS: $mount is ${usage}% full"
echo "$msg"
if [[ -n "$SLACK_WEBHOOK" ]]; then
curl -s -X POST "$SLACK_WEBHOOK" -H 'Content-type: application/json' -d "{"text": "$msg"}" > /dev/null
fi
}
check_disk
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
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") -gt $MAX_LOG_LINES ]]; then
tail -n $MAX_LOG_LINES "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"
fi
exit $EXIT_CODE
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
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."
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.
Comments · 0
No comments yet. Be the first to share your thoughts.