Bash DevOps Scripts
Production-ready Bash scripts for deployments, backups, log rotation, health checks, and CI/CD pipeline helpers.
Deploy Script
A production deploy script encodes your deployment runbook as executable code. The benefits are consistency (every deploy follows the same steps), auditability (the script is version-controlled), and safety (health checks and rollback are built in rather than remembered under pressure). This example uses the Capistrano-style releases directory pattern, which keeps the last N releases on disk and makes rollback instant.
#!/usr/bin/env bash
# deploy.sh — deploy application with health check and rollback
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly APP_NAME="myapp"
readonly DEPLOY_DIR="/var/www/${APP_NAME}"
readonly RELEASES_DIR="${DEPLOY_DIR}/releases"
readonly CURRENT_LINK="${DEPLOY_DIR}/current"
readonly SHARED_DIR="${DEPLOY_DIR}/shared"
readonly KEEP_RELEASES=5
readonly HEALTH_CHECK_URL="http://localhost:8080/health"
readonly HEALTH_CHECK_RETRIES=10
readonly HEALTH_CHECK_INTERVAL=3
log() { echo "[$(date '+%H:%M:%S')] $*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
# Create a timestamped directory for this release
setup_release() {
RELEASE_DIR="${RELEASES_DIR}/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$RELEASE_DIR"
log "Release dir: $RELEASE_DIR"
}
# Fetch the application code from git
fetch_code() {
local branch="${1:-main}"
log "Fetching $branch..."
git clone --depth=1 --branch "$branch" \
"[email protected]:myorg/${APP_NAME}.git" "$RELEASE_DIR"
}
# Link shared resources that persist across releases (config, uploads, logs)
link_shared() {
ln -sf "${SHARED_DIR}/config/.env" "${RELEASE_DIR}/.env"
ln -sf "${SHARED_DIR}/uploads" "${RELEASE_DIR}/public/uploads"
ln -sf "${SHARED_DIR}/logs" "${RELEASE_DIR}/log"
}
# Build the application
build() {
log "Building..."
cd "$RELEASE_DIR"
npm ci --production
npm run build
}
# Run database migrations
migrate() {
log "Running migrations..."
cd "$RELEASE_DIR"
./bin/migrate up
}
# Atomically swap the current symlink — readers see old or new, never in-between
activate_release() {
log "Activating release..."
ln -sfn "$RELEASE_DIR" "${CURRENT_LINK}.next"
mv -Tf "${CURRENT_LINK}.next" "$CURRENT_LINK"
}
# Restart the application service
restart_app() {
log "Restarting $APP_NAME..."
systemctl restart "${APP_NAME}"
}
# Poll the health endpoint until it responds or retries are exhausted
health_check() {
log "Checking health..."
local attempt=1
until curl -sf "$HEALTH_CHECK_URL" &>/dev/null; do
if (( attempt >= HEALTH_CHECK_RETRIES )); then
die "Health check failed after $HEALTH_CHECK_RETRIES attempts"
fi
log " Attempt $attempt/$HEALTH_CHECK_RETRIES — waiting ${HEALTH_CHECK_INTERVAL}s..."
sleep "$HEALTH_CHECK_INTERVAL"
(( attempt++ ))
done
log "Health check passed"
}
# Remove old releases, keeping only the last KEEP_RELEASES
cleanup_old_releases() {
log "Cleaning up old releases..."
local count
count=$(ls -1 "$RELEASES_DIR" | wc -l)
if (( count > KEEP_RELEASES )); then
ls -1t "$RELEASES_DIR" | tail -n +$(( KEEP_RELEASES + 1 )) | while IFS= read -r rel; do
log " Removing old release: $rel"
rm -rf "${RELEASES_DIR:?}/${rel}"
done
fi
}
# Roll back to the previous release
rollback() {
log "Rolling back..."
local previous
previous=$(ls -1t "$RELEASES_DIR" | sed -n '2p')
if [[ -z "$previous" ]]; then
die "No previous release to roll back to"
fi
ln -sfn "${RELEASES_DIR}/${previous}" "${CURRENT_LINK}.next"
mv -Tf "${CURRENT_LINK}.next" "$CURRENT_LINK"
systemctl restart "${APP_NAME}"
log "Rolled back to: $previous"
}
main() {
local branch="${1:-main}"
log "=== Deploy ${APP_NAME} from ${branch} ==="
mkdir -p "$RELEASES_DIR" "$SHARED_DIR"
setup_release
fetch_code "$branch"
link_shared
build
migrate
activate_release
restart_app
health_check
cleanup_old_releases
log "=== Deploy complete ==="
}
case "${1:-deploy}" in
rollback) rollback ;;
*) main "$@" ;;
esac
Backup Script
A backup script needs to be reliable enough to run unattended every night for years. Key properties: it logs everything, cleans up after itself, uploads off-site, and enforces a retention policy so storage costs do not grow unboundedly.
#!/usr/bin/env bash
# backup.sh — backup databases and files to S3
set -euo pipefail
readonly BACKUP_DIR="/var/backups/myapp"
readonly S3_BUCKET="s3://my-backups/myapp"
readonly TIMESTAMP=$(date +%Y%m%d_%H%M%S)
readonly RETENTION_DAYS=30
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "/var/log/backup.log"; }
die() { log "ERROR: $*"; exit 1; }
backup_postgres() {
local db="${1:?database name required}"
local dumpfile="${BACKUP_DIR}/postgres_${db}_${TIMESTAMP}.sql.gz"
log "Backing up PostgreSQL database: $db"
pg_dump -Fc -d "$db" | gzip -9 > "$dumpfile"
log " Dump: $dumpfile ($(du -sh "$dumpfile" | cut -f1))"
echo "$dumpfile"
}
backup_files() {
local src="${1:?source directory required}"
local name="${2:?backup name required}"
local archive="${BACKUP_DIR}/${name}_${TIMESTAMP}.tar.gz"
log "Backing up files: $src"
tar -czf "$archive" -C "$(dirname "$src")" "$(basename "$src")" \
--exclude="*.log" \
--exclude="tmp/" \
--exclude=".git/"
log " Archive: $archive ($(du -sh "$archive" | cut -f1))"
echo "$archive"
}
upload_to_s3() {
local file="$1"
local s3_key="${S3_BUCKET}/$(basename "$file")"
log "Uploading to S3: $s3_key"
aws s3 cp "$file" "$s3_key" --storage-class STANDARD_IA
log " Upload complete"
}
cleanup_local() {
log "Removing local backups older than ${RETENTION_DAYS} days..."
find "$BACKUP_DIR" -type f -mtime +"$RETENTION_DAYS" -delete
}
cleanup_s3() {
log "Removing S3 backups older than ${RETENTION_DAYS} days..."
aws s3 ls "${S3_BUCKET}/" | while IFS= read -r line; do
local date_str
date_str=$(echo "$line" | awk '{print $1}')
local file
file=$(echo "$line" | awk '{print $4}')
local file_epoch
file_epoch=$(date -d "$date_str" +%s)
local cutoff
cutoff=$(date -d "${RETENTION_DAYS} days ago" +%s)
if (( file_epoch < cutoff )); then
aws s3 rm "${S3_BUCKET}/${file}"
log " Removed: $file"
fi
done
}
main() {
mkdir -p "$BACKUP_DIR"
log "=== Backup started ==="
db_file=$(backup_postgres "myapp_production")
upload_to_s3 "$db_file"
files_archive=$(backup_files "/var/www/myapp/public/uploads" "uploads")
upload_to_s3 "$files_archive"
cleanup_local
cleanup_s3
log "=== Backup complete ==="
}
main "$@"
Log Rotation Script
Application logs grow without bound unless rotated. This script compresses log files that exceed a size threshold and deletes archived logs older than the retention period. It uses truncate rather than deleting and recreating the live log file, so the application’s open file descriptor remains valid.
#!/usr/bin/env bash
# rotate-logs.sh — compress and archive application logs
set -euo pipefail
readonly LOG_DIR="/var/log/myapp"
readonly ARCHIVE_DIR="/var/log/myapp/archive"
readonly MAX_SIZE_MB=100
readonly KEEP_DAYS=30
rotate_log() {
local logfile="$1"
local size_mb
size_mb=$(du -sm "$logfile" | cut -f1)
if (( size_mb < MAX_SIZE_MB )); then
return 0 # not large enough to rotate yet
fi
local ts
ts=$(date +%Y%m%d_%H%M%S)
local archive="${ARCHIVE_DIR}/$(basename "$logfile").${ts}.gz"
mkdir -p "$ARCHIVE_DIR"
cp "$logfile" - | gzip -9 > "$archive"
truncate -s 0 "$logfile" # empty the live log without closing the file descriptor
echo "Rotated: $logfile (${size_mb}MB) -> $archive"
}
# Rotate all .log files in the log directory
for f in "${LOG_DIR}"/*.log; do
[[ -f "$f" ]] && rotate_log "$f"
done
# Delete old archives
find "$ARCHIVE_DIR" -name "*.gz" -mtime +"$KEEP_DAYS" -delete
echo "Cleanup complete"
Health Check Script
A health check script provides a single entry point for verifying that all components of a system are working. Running it from a monitoring system, a load balancer, or a post-deploy step gives immediate feedback on system state.
#!/usr/bin/env bash
# health-check.sh — check multiple services and endpoints
set -uo pipefail
FAILURES=0
CHECKS=0
# check() runs a command and reports pass/fail — keeping all results consistent
check() {
local name="$1"
local cmd=("${@:2}")
(( CHECKS++ ))
if "${cmd[@]}" &>/dev/null; then
printf '[OK] %s\n' "$name"
else
printf '[FAIL] %s\n' "$name" >&2
(( FAILURES++ ))
fi
}
# HTTP endpoints
check "API /health" curl -sf --max-time 5 "http://localhost:8080/health"
check "API /ready" curl -sf --max-time 5 "http://localhost:8080/ready"
check "Admin panel" curl -sf --max-time 5 "http://localhost:3000/"
# Database connectivity
check "PostgreSQL" pg_isready -h localhost -p 5432 -U myapp
check "Redis" redis-cli ping
# Service status
check "nginx running" systemctl is-active --quiet nginx
check "app running" systemctl is-active --quiet myapp
check "worker running" pgrep -f "myapp-worker" > /dev/null
# Disk space — alert if over 90%
disk_pct=$(df -h / | awk 'NR==2{gsub(/%/,""); print $5}')
if (( disk_pct < 90 )); then
printf '[OK] Disk space (%d%%)\n' "$disk_pct"
else
printf '[FAIL] Disk space critical (%d%%)\n' "$disk_pct" >&2
(( FAILURES++ ))
fi
(( CHECKS++ ))
echo ""
echo "Results: $(( CHECKS - FAILURES ))/$CHECKS passed"
if (( FAILURES > 0 )); then
echo "UNHEALTHY: $FAILURES check(s) failed" >&2
exit 1
fi
echo "HEALTHY"
CI/CD Helper Functions
CI/CD pipelines are easier to read and maintain when common operations are abstracted into functions. These helpers add consistent formatting, timing, and notifications without duplicating code across pipeline stages.
#!/usr/bin/env bash
# ci-helpers.sh — reusable CI/CD utilities
# Source this file in your pipeline scripts: source ci-helpers.sh
# Print a section header that renders as a collapsible group in GitHub Actions logs
ci_section() {
echo ""
echo "##[group]$*" # GitHub Actions group syntax — collapses in the UI
echo "========================================"
echo " $*"
echo "========================================"
}
# Time a step and report how long it took — helps identify slow pipeline stages
timed() {
local start
start=$(date +%s)
"$@"
local end
end=$(date +%s)
echo " Completed in $(( end - start ))s"
}
# Announce a step with a timestamp — useful for correlating log lines
step() {
printf '\n[%s] >>> %s\n' "$(date '+%H:%M:%S')" "$*"
}
# Post a Slack notification — skips silently if no webhook is configured
notify_slack() {
local message="$1"
local color="${2:-good}" # good (green), warning (yellow), danger (red)
[[ -z "${SLACK_WEBHOOK_URL:-}" ]] && return 0
curl -sf -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
-d "$(jq -n \
--arg text "$message" \
--arg color "$color" \
'{attachments: [{color: $color, text: $text}]}')"
}
# Typical CI pipeline using the helpers above
ci_section "Install dependencies"
timed npm ci
ci_section "Lint"
timed npm run lint
ci_section "Test"
timed npm test
ci_section "Build"
timed npm run build
notify_slack ":white_check_mark: Build passed for *${CI_BRANCH}* by ${CI_AUTHOR}" "good"
What’s Next
The final tutorial covers Bash interview preparation — the top 25 questions and answers you’ll encounter in DevOps and SRE interviews.