• Post author:Bhaskar Soni
  • Post published:
  • Post category:DevOps
  • Post comments:0 Comments
  • Reading time:13 mins read

Most outages we investigate start with a small shell script that nobody tested. The cron job that silently skipped a step, the deploy that half-finished, the cleanup that deleted the wrong directory. Good Bash scripts for DevOps work are not clever; they are boring, strict and tested. In this post we share a safe skeleton and seven complete scripts we run on real Ubuntu servers. Every script below was syntax-checked with bash -n, linted with ShellCheck 0.11 and executed on Ubuntu 24.04 in our sandbox, except where we say otherwise.

The safe script skeleton every DevOps engineer should start from

First, stop writing scripts from a blank file. Start from a skeleton that fails loudly and cleans up after itself.

#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'

readonly SCRIPT_NAME="${0##*/}"
TMP_DIR="$(mktemp -d)"

log()  { printf '%s [%s] %s\n' "$(date -u +%FT%TZ)" "$1" "${*:2}" >&2; }
die()  { log ERROR "$@"; exit 1; }

usage() {
  cat <<USAGE
Usage: ${SCRIPT_NAME} [-h] [-v] <target>
  -h  show this help
  -v  verbose output
USAGE
}

cleanup() {
  local rc=$?
  rm -rf -- "${TMP_DIR}"
  (( rc != 0 )) && log ERROR "${SCRIPT_NAME} failed with exit code ${rc}"
  exit "${rc}"
}
trap cleanup EXIT
trap 'log ERROR "line ${LINENO}: ${BASH_COMMAND}"' ERR

VERBOSE=0
while getopts ':hv' opt; do
  case "${opt}" in
    h) usage; exit 0 ;;
    v) VERBOSE=1 ;;
    *) usage; exit 2 ;;
  esac
done
shift $((OPTIND - 1))
[[ $# -ge 1 ]] || { usage; exit 2; }

target="$1"
(( VERBOSE )) && log INFO "working on ${target} in ${TMP_DIR}"
log INFO "done"

Why each line matters:

  • set -E makes the ERR trap fire inside functions too. -e exits on any failing command, -u fails on unset variables, and -o pipefail makes a | b fail if a fails.
  • The EXIT trap always runs, so temp files disappear even after a crash or Ctrl-C.
  • Logging goes to stderr with a UTC timestamp. Stdout stays clean for data you may pipe elsewhere.
  • Quoting rule: quote every expansion ("${var}"), use -- before file arguments, and prefer [[ ]] over [ ]. If you need to split a string, use read -ra or mapfile, never an unquoted $var.

Seven Bash scripts for DevOps teams, tested and explained

1. Service health check with proper exit codes

Monitoring tools such as Nagios, Icinga and Zabbix read exit codes: 0 is OK, 1 is WARNING, 2 is CRITICAL. This script honours that contract.

#!/usr/bin/env bash
# healthcheck.sh - exit 0 OK, 1 WARN (slow or 4xx), 2 CRIT (down or 5xx)
set -Eeuo pipefail

URL="${1:?usage: healthcheck.sh <url> [timeout_s] [warn_ms]}"
TIMEOUT="${2:-5}"
WARN_MS="${3:-1500}"

# curl prints the -w line even when it fails, so capture first and judge after
result="$(curl -sS -o /dev/null --max-time "${TIMEOUT}" --retry 2 --retry-delay 1 \
          -w '%{http_code} %{time_total}' "${URL}" 2>/dev/null)" || true
read -r code time_total <<< "${result:-000 0}"

ms=$(awk -v t="${time_total}" 'BEGIN { printf "%d", t * 1000 }')

if [[ "${code}" == "000" ]]; then
  echo "CRITICAL - ${URL} unreachable or timed out after ${TIMEOUT}s"; exit 2
elif (( code >= 500 )); then
  echo "CRITICAL - ${URL} returned HTTP ${code} in ${ms}ms"; exit 2
elif (( code >= 400 )); then
  echo "WARNING - ${URL} returned HTTP ${code} in ${ms}ms"; exit 1
elif (( ms > WARN_MS )); then
  echo "WARNING - ${URL} HTTP ${code} but slow: ${ms}ms"; exit 1
fi
echo "OK - ${URL} HTTP ${code} in ${ms}ms"

Tested against an unreachable address, the script printed CRITICAL - http://10.255.255.1:9/ unreachable or timed out after 2s and exited 2. Against a local server returning 503, it exited 2 as well. Our first version used || echo "000 0" inside a process substitution; that caused an intermittent broken-pipe error, so we capture the output into a variable instead.

2. Safe deploy with symlink swap and rollback

Deploying by overwriting files in place leaves a window where the app is half old and half new. Instead, unpack into a timestamped release directory and swap a current symlink atomically.

#!/usr/bin/env bash
# deploy.sh - atomic symlink-swap deploy with rollback
set -Eeuo pipefail

APP_ROOT="${APP_ROOT:-/srv/myapp}"
KEEP="${KEEP_RELEASES:-5}"
RELEASES="${APP_ROOT}/releases"
CURRENT="${APP_ROOT}/current"
log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*"; }

deploy() {
  local artifact="${1:?usage: deploy.sh deploy <artifact.tar.gz>}"
  local rel; rel="${RELEASES}/$(date -u +%Y%m%d%H%M%S)"
  mkdir -p "${rel}"
  tar -xzf "${artifact}" -C "${rel}"
  if [[ -x "${rel}/healthcheck.sh" ]]; then
    "${rel}/healthcheck.sh" || { log "healthcheck failed"; rm -rf -- "${rel}"; exit 1; }
  fi
  ln -sfn "${rel}" "${CURRENT}.tmp" && mv -Tf "${CURRENT}.tmp" "${CURRENT}"
  log "deployed $(readlink -f "${CURRENT}")"
  find "${RELEASES}" -mindepth 1 -maxdepth 1 -type d | sort | head -n -"${KEEP}" |
    while IFS= read -r old; do
      [[ "${old}" == "$(readlink -f "${CURRENT}")" ]] || rm -rf -- "${old}"
    done
}

rollback() {
  local cur prev
  cur="$(readlink -f "${CURRENT}")"
  prev="$(find "${RELEASES}" -mindepth 1 -maxdepth 1 -type d | sort |
          grep -B1 -x "${cur}" | head -n1)"
  [[ -n "${prev}" && "${prev}" != "${cur}" ]] || { log "nothing to roll back to"; exit 1; }
  ln -sfn "${prev}" "${CURRENT}.tmp" && mv -Tf "${CURRENT}.tmp" "${CURRENT}"
  log "rolled back to ${prev}"
}

case "${1:-}" in
  deploy)   deploy "${2:-}" ;;
  rollback) rollback ;;
  *) echo "usage: $0 deploy <artifact.tar.gz> | rollback" >&2; exit 2 ;;
esac

The trick is mv -T on a symlink, which is a single rename() call. We deployed three tarballs, confirmed current/version.txt read v3, rolled back to v2, and verified that pruning kept only the two newest releases. After the swap, reload your service (systemctl reload myapp) so workers pick up the new path.

3. Log archive and cleanup with CERT-In retention

The CERT-In directions of April 2022 require organisations in India to keep system logs for a rolling 180 days. Therefore the retention default below is 180, not 30.

#!/usr/bin/env bash
# logclean.sh - gzip logs older than 7 days, delete archives older than 180 days
set -Eeuo pipefail

LOG_DIR="${1:-/var/log/myapp}"
COMPRESS_AFTER="${COMPRESS_AFTER_DAYS:-7}"
RETAIN="${RETAIN_DAYS:-180}"   # CERT-In: keep 180 days of logs
DRY_RUN="${DRY_RUN:-0}"

[[ -d "${LOG_DIR}" ]] || { echo "no such directory: ${LOG_DIR}" >&2; exit 2; }
run() { if (( DRY_RUN )); then echo "DRY-RUN: $*"; else "$@"; fi; }

find "${LOG_DIR}" -type f -name '*.log' -mtime +"${COMPRESS_AFTER}" -print0 |
  while IFS= read -r -d '' f; do run gzip -9 -- "${f}"; done

find "${LOG_DIR}" -type f -name '*.log.gz' -mtime +"${RETAIN}" -print0 |
  while IFS= read -r -d '' f; do run rm -f -- "${f}"; done

echo "logclean: $(find "${LOG_DIR}" -type f | wc -l) files remain in ${LOG_DIR}"

We tested it on a temp directory with files dated today, 10, 100 and 200 days ago. The dry run listed exactly one gzip and one delete; the real run left three files. Note that -mtime +7 means strictly more than seven full days. Also, if you ship logs to a central store, keep the 180-day copy there and shorten local retention.

4. Secret-free environment loader

Never hard-code credentials in a script. Keep them in a .env file owned by the service user with mode 600, and refuse to load anything looser.

#!/usr/bin/env bash
# loadenv.sh - load KEY=value pairs safely; verify required vars
set -Eeuo pipefail

ENV_FILE="${1:-.env}"; shift || true
REQUIRED=("$@")   # e.g. DB_HOST DB_PASSWORD API_TOKEN

[[ -f "${ENV_FILE}" ]] || { echo "missing ${ENV_FILE}" >&2; exit 2; }
perm="$(stat -c '%a' "${ENV_FILE}")"
if [[ "${perm}" != "600" && "${perm}" != "400" ]]; then
  echo "refusing: ${ENV_FILE} has mode ${perm}; run chmod 600" >&2; exit 3
fi

while IFS= read -r line || [[ -n "${line}" ]]; do
  [[ -z "${line}" || "${line}" =~ ^[[:space:]]*# ]] && continue
  if [[ "${line}" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
    key="${BASH_REMATCH[1]}"; val="${BASH_REMATCH[2]}"
    val="${val%\"}"; val="${val#\"}"
    export "${key}=${val}"
  else
    echo "skipping malformed line: ${line%%=*}" >&2
  fi
done < "${ENV_FILE}"

missing=()
for v in "${REQUIRED[@]}"; do [[ -n "${!v:-}" ]] || missing+=("${v}"); done
(( ${#missing[@]} == 0 )) || { echo "missing required: ${missing[*]}" >&2; exit 4; }
echo "loaded ${ENV_FILE}: ${#REQUIRED[@]} required variables present"

The script parses lines with a regex rather than source, because sourcing executes anything in the file. In our test it refused a 644 file (exit 3), reported a missing API_TOKEN (exit 4), and loaded cleanly after chmod 600. For production, prefer a secrets manager or systemd LoadCredential=; the DPDP Act makes careless credential handling a compliance problem, not only a security one.

5. Docker housekeeping with a size report

Dangling images and build cache fill disks quietly. Run docker system df before and after so the report shows what you reclaimed.

#!/usr/bin/env bash
set -Eeuo pipefail
command -v docker >/dev/null || { echo "docker not installed" >&2; exit 2; }
docker info >/dev/null 2>&1 || { echo "docker daemon not reachable" >&2; exit 2; }

echo "== before =="; docker system df
docker image prune -f --filter "until=24h"
docker container prune -f --filter "until=24h"
[[ "${PRUNE_VOLUMES:-0}" == "1" ]] && docker volume prune -f
docker builder prune -f --filter "until=168h"
echo "== after =="; docker system df

Volume pruning is opt-in because docker volume prune can delete database data that a stopped container still needs. Our sandbox has the CLI but no daemon, so this script exited 2 with “daemon not reachable” as designed; the prune flags are from the official Docker CLI reference.

6. SSL certificate expiry checker

Expired certificates still cause outages in 2026. This script reads a hosts file and warns N days ahead.

#!/usr/bin/env bash
# certcheck.sh - exit 1 if any cert expires within N days, 2 on expired/error
set -Eeuo pipefail
HOSTS_FILE="${1:?usage: certcheck.sh <hosts.txt> [warn_days]}"
WARN_DAYS="${2:-21}"
now="$(date +%s)"; rc=0

while IFS= read -r host || [[ -n "${host}" ]]; do
  [[ -z "${host}" || "${host}" == \#* ]] && continue
  port="${host##*:}"; [[ "${port}" == "${host}" ]] && port=443
  name="${host%%:*}"
  enddate="$(timeout 10 openssl s_client -servername "${name}" -connect "${name}:${port}" \
             </dev/null 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)" || true
  [[ -n "${enddate}" ]] || { echo "ERROR ${name}:${port} - could not fetch certificate"; rc=2; continue; }
  days=$(( ($(date -d "${enddate}" +%s) - now) / 86400 ))
  if (( days < 0 )); then echo "EXPIRED ${name}:${port}"; rc=2
  elif (( days <= WARN_DAYS )); then echo "WARN ${name}:${port} - ${days} days left"; (( rc < 1 )) && rc=1
  else echo "OK ${name}:${port} - ${days} days left"; fi
done < "${HOSTS_FILE}"
exit "${rc}"

Our sandbox has no outbound network, so we tested against two local openssl s_server instances with 10-day and 365-day self-signed certificates. The output was WARN localhost:8443 - 9 days left and OK localhost:8444 - 364 days left, with exit code 2 for a closed port. Run it daily at 09:00 IST: 0 9 * * * /usr/local/bin/certcheck.sh /etc/certcheck/hosts.txt 21.

7. Disk and inode watchdog with webhook alerts

Running out of inodes looks like a full disk even when df -h shows space. Check both, then post to Slack or Teams.

#!/usr/bin/env bash
set -Eeuo pipefail
THRESHOLD="${THRESHOLD:-85}"
WEBHOOK_URL="${WEBHOOK_URL:?set WEBHOOK_URL in the environment, not in this file}"
HOST="$(hostname -f 2>/dev/null || hostname)"
alerts=()

while read -r _ _ _ avail pct mnt; do
  pct="${pct%\%}"
  (( pct >= THRESHOLD )) && alerts+=("disk ${mnt} at ${pct}% (${avail} KB free)")
done < <(df -P -x tmpfs -x devtmpfs -x squashfs -x overlay | tail -n +2)

while read -r _ _ _ _ ipct mnt; do
  ipct="${ipct%\%}"; [[ "${ipct}" == "-" ]] && continue
  (( ipct >= THRESHOLD )) && alerts+=("inodes ${mnt} at ${ipct}%")
done < <(df -Pi -x tmpfs -x devtmpfs -x squashfs -x overlay | tail -n +2)

(( ${#alerts[@]} )) || { echo "OK: all filesystems below ${THRESHOLD}%"; exit 0; }
msg="*${HOST}* $(printf '\n- %s' "${alerts[@]}")"
payload="$(jq -cn --arg t "${msg}" '{text: $t}')"
curl -sS --fail --max-time 10 -H 'Content-Type: application/json' -d "${payload}" "${WEBHOOK_URL}" >/dev/null
echo "ALERT sent: ${#alerts[@]} finding(s)"; exit 1

We pointed WEBHOOK_URL at a local HTTP listener and set THRESHOLD=1; it received valid JSON listing six findings. With THRESHOLD=100 it printed OK. Building the payload with jq avoids broken JSON when a mount path contains quotes. Slack incoming webhooks accept {"text": ...}; Teams workflows accept the same shape or an Adaptive Card.

Testing Bash scripts for DevOps: shellcheck, bash -n and bats

Three cheap steps catch most bugs in Bash scripts for DevOps before production:

  1. bash -n script.sh parses without executing. It finds missing fi and done, but nothing else.
  2. shellcheck -S warning script.sh catches unquoted variables, unused ones and dangerous patterns. It flagged two unused read fields in our watchdog, which we replaced with _. Install it with apt install shellcheck and run it in CI.
  3. Bats (bats-core) gives you real test cases. For example:
@test "unreachable host exits 2" {
  run ./healthcheck.sh http://10.255.255.1:9/ 1
  [ "$status" -eq 2 ]
  [[ "$output" == CRITICAL* ]]
}

This exact test is what exposed the broken-pipe bug in our first health check. In short, tests pay for themselves on the first script.

Security rules for operational scripts

  • No secrets in scripts, arguments or shell history. Use .env with mode 600, systemd credentials or a vault. Add HISTCONTROL=ignorespace for one-off commands.
  • Least privilege: run scripts as a dedicated user. If a step needs root, grant one command in /etc/sudoers.d/, for example deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload myapp.
  • Never curl | bash. Download, inspect, verify a checksum, then run.
  • Pin versions in everything you install, from apt packages to Docker base images.
  • Log what the script did and when. Those logs feed the CERT-In 6-hour incident reporting window.

Our free Linux server hardening checklist covers the host-level controls that these scripts assume. If you want an outside review, see our Linux server hardening audit.

Frequently asked questions

Should we still write Bash scripts for DevOps when Ansible and Python exist?

Yes, for glue. Bash is already on every server, starts instantly and fits cron and systemd timers well. However, once a script needs data structures, retries with backoff or API pagination, move it to Python or Ansible.

Why not just use set -e alone?

Because set -e ignores failures inside pipelines and leaves unset variables silent. set -Eeuo pipefail closes those gaps, and the ERR trap tells you which line failed.

How do we schedule these scripts in IST?

Set CRON_TZ=Asia/Kolkata at the top of the crontab, or use a systemd timer with OnCalendar=*-*-* 02:00:00 Asia/Kolkata. Check timedatectl so the server clock is NTP-synced.

Is 180 days of log retention mandatory for every company?

The CERT-In directions apply to service providers, intermediaries, data centres, body corporates and government organisations in India. Most SMBs running public services fall under it, so defaulting to 180 days is the safe choice.

Can shellcheck replace testing?

No. ShellCheck finds syntax and quoting mistakes but cannot know your business logic. Combine it with bash -n, a dry-run flag and a few Bats tests.

Sources

Running Linux servers in production? Our Linux server hardening & security audit checks configuration, patching, SSH and logging. You can also use our free Linux server hardening checklist, or contact us.

Post author avatar

Bhaskar Soni

Bhaskar Soni is a Security Engineer and the founder of Youngster Company, based in Ahmedabad, Gujarat. He works hands-on with Linux server hardening, vulnerability assessment and penetration testing (VAPT), ISO 27001 compliance and OSINT investigations. He writes practical, tested guides for IT professionals and Indian businesses, and every article on this site is written or reviewed by him.

Leave a Reply