Bash Disk Usage Alert Script

Tested on RHEL 10.2 (Coughlan)
Package bash-5.2.26-6.el10.x86_64
coreutils-9.5-8.el10_2.x86_64 (df)
util-linux-2.40.2-18.el10.x86_64 (flock)
curl-8.12.1-4.el10.x86_64 (webhook POST)
jq-1.7.1-11.el10_2.2.x86_64 (webhook JSON only)
Applies to Linux distributions with Bash installed
Privilege Normal user
Scope A small Bash disk monitoring utility: predictable df -P parsing, explicit mount points without whitespace (for example /, /var, /home, /boot), warning and critical thresholds with warn→crit escalation, custom exit codes, persistent state and logs under $XDG_STATE_HOME, stdout/email/webhook notification hooks, recovery events, synthetic fixture testing, cron scheduling, and internal flock overlap protection. Monitors block capacity only — not inode exhaustion. This is a script you can drop on one or two hosts — not a replacement for Prometheus, Nagios, or a full observability stack.
Related guides Shell scripting course hub · Schedule Bash scripts · df and du commands · Bash exit codes · Parse JSON with jq in Bash
Short on time?Beta Get the gist of this 21-minute read in seconds, then ask follow-up questions

A useful disk alert does more than print df when usage crosses 80%. Scheduled checks need predictable parsing, duplicate-alert suppression, warn→crit escalation, recovery notifications, and a clear exit status for cron or monitoring wrappers.

The script keeps enough state to suppress duplicate alerts, notify again when usage escalates from warning to critical, and report recovery. Synthetic df fixtures let you test the important threshold and state transitions without filling a real filesystem.


What the Disk Alert Script Will Do

The script watches only the mount points you name — typically /, /var, or /home — instead of every pseudo-filesystem df lists. For each target it:

  • Parses the capacity column from df -P (for example 85%) into an integer.
  • Compares usage against a warning threshold (default 80) and a critical threshold (default 90).
  • Prints one alert line per affected mount on stdout.
  • Appends timestamped lines to a log file under $XDG_STATE_HOME/disk-alert/; when XDG_STATE_HOME is unset, the script uses ~/.local/state/disk-alert/.
  • Tracks alert state (ok, warn, crit) so repeated cron runs do not re-send the same warning, but warn→crit escalation still notifies.
  • Emits a recovery notification when usage falls back below the warning level.
  • Returns an exit code your scheduler or wrapper script can act on.

This is intended for lightweight host-level monitoring, not as a replacement for a metrics and alerting platform.


Define Inputs, Thresholds and Exit Codes

Configure the checker with environment variables so cron and systemd units can override defaults without editing the script.

Variable Default Purpose
TARGETS / Space-separated absolute mount paths without whitespace (for example / /var /home)
WARN_THRESHOLD 80 Warning level (0–100, inclusive)
CRIT_THRESHOLD 90 Critical level (0–100, inclusive)
DISK_ALERT_DF_FIXTURE (unset) Path to synthetic df -P output for testing
ALERT_EMAIL_TO (unset) Recipient for mail -s when the command exists
DISK_ALERT_WEBHOOK_URL (unset) HTTPS endpoint for JSON POST notifications

Both thresholds must be whole numbers from 0 through 100, and warning must not exceed critical. Invalid values make the script exit immediately with code 2.

The Bash exit codes guide explains how $? reaches callers. This checker defines the following exit contract:

Exit code Meaning for this script
0 All monitored targets are below the warning threshold, or only a recovery event fired
1 At least one target is at or above the warning threshold (new alert, escalation, or suppressed duplicate)
2 Check could not complete: invalid configuration, parsing/collection failure, missing target, or lock-acquisition timeout

Validate thresholds at startup:

bash
validate_threshold() {
  local name=$1 val=$2
  if ! [[ "$val" =~ ^[0-9]+$ ]] || (( val < 0 || val > 100 )); then
    echo "ERROR: ${name} must be a numeric value between 0 and 100 (got: ${val})" >&2
    exit 2
  fi
}

Pass a nonsense warning value to confirm the guard fires:

bash
WARN_THRESHOLD=abc ~/disk-alert-lab/bin/disk-alert.sh
output
ERROR: WARN_THRESHOLD must be a numeric value between 0 and 100 (got: abc)

Validation fails before df runs, so a bad threshold cannot produce a misleading disk-status result.


Why Use df -P

Use df -P for script-friendly output: GNU df keeps each filesystem record on one line and uses the POSIX-style headings. This script only reads the capacity percentage and mount-point fields, so the displayed block size does not affect the calculation. Avoid -h here because human-readable block sizes add formatting the parser does not need.

The df and du commands cheat sheet covers display options; for automation, -P is the safe default. Reserve -h for manual troubleshooting when you are reading output yourself. Long device names can still make columns look misaligned visually; the parser relies on the last two fields, not fixed column widths.

Set LC_ALL=C when you call df so locale settings do not alter number formatting or column alignment:

bash
LC_ALL=C df -P /

Sample output:

output
Filesystem            1024-blocks     Used Available Capacity Mounted on
/dev/mapper/rhel-root    26148864 16424620   9724244      63% /

On our lab host / was at 63%, well below the default warning level. The capacity column is the second-to-last field; the mount point is the last field. Both are safe to read with awk when mount paths contain no whitespace — the constraint this script uses for TARGETS.


Parse One Filesystem Safely

Strip the trailing % with Bash parameter expansion instead of spawning sed or tr:

bash
parse_pct() {
  local capacity=$1
  local pct="${capacity%%%}"
  [[ "$pct" =~ ^[0-9]+$ ]] || return 1
  printf '%s\n' "$pct"
}

The ${capacity%%%} form removes a % suffix only when it is present. Test the expansion interactively:

bash
pct='85%'
echo "${pct%%%}"
output
85

Split each df -P line without a whitespace-unsafe for mount in $(df …) loop. Read line by line and take the last two fields with awk:

bash
mount=$(awk '{print $NF}' <<<"$line")
capacity=$(awk '{print $(NF-1)}' <<<"$line")

That works for standard mount paths like /, /var, and /boot. It does not support mount names that contain spaces — TARGETS must list absolute paths without whitespace, and df -P ${TARGETS} cannot pass a spaced path as one operand anyway.

Feed df through a while read loop, not a pipeline into while (which runs the loop in a subshell and loses state updates):

bash
while IFS= read -r line; do
  [[ "$line" == Filesystem* ]] && continue
  [[ -z "$line" ]] && continue
  mount=$(awk '{print $NF}' <<<"$line")
  # ...
done <<< "$df_output"

Validate each configured target at startup — reject paths with spaces or without a leading /:

bash
validate_target() {
  local target=$1
  [[ "$target" == *" "* || "$target" == *$'\t'* ]] && exit 2
  [[ "$target" == /* ]] || exit 2
}

Collect real or fixture output in one function so tests and production share the same parser. Parse TARGETS once into a Bash array — a whitespace-separated list of mount paths — and pass it to df without unquoted expansion:

bash
read -r -a targets <<< "$TARGETS"
for t in "${targets[@]}"; do
  validate_target "$t"
  target_want["$t"]=1
done

collect_df_lines() {
  if [[ -n "${DISK_ALERT_DF_FIXTURE:-}" ]]; then
    cat "${DISK_ALERT_DF_FIXTURE}"
  else
    LC_ALL=C df -P "${targets[@]}"
  fi
}

When Capacity is not numeric (for example abc%), treat it as a parse error for monitored targets and exit 2.


Add Warning and Critical Thresholds

After parsing, classify each mount:

  • Below WARN_THRESHOLD → OK (possibly recovery if an alert was active).
  • At or above WARN_THRESHOLD but below CRIT_THRESHOLD → warning alert.
  • At or above CRIT_THRESHOLD → critical alert.

Use arithmetic comparison on integers, not string sort:

bash
if (( pct >= WARN_THRESHOLD )); then
  if (( pct >= CRIT_THRESHOLD )); then
    level=crit
  else
    level=warn
  fi
fi

Exactly 80% with WARN_THRESHOLD=80 must alert. Feed a fixture at the boundary:

bash
WARN_THRESHOLD=80 CRIT_THRESHOLD=90 TARGETS="/" \
  DISK_ALERT_DF_FIXTURE=~/disk-alert-lab/fixtures/df-80.tsv \
  ~/disk-alert-lab/bin/disk-alert.sh
output
ALERT: mount=/ pct=80 level=warn

At 79% the same script exits silently with code 0. At 95% you get level=crit and exit 1.

This script monitors block capacity from df -P, not inode exhaustion. A filesystem can still fail with “No space left on device” when inode usage hits 100% while block usage looks healthy. If the host creates large numbers of small files, monitor inode usage separately with df -i.


Check Multiple Targets

Pass several mount points through TARGETS as a space-separated list. The script builds an associative array of requested paths and ignores df lines for filesystems you did not ask for — useful when /dev/shm, container overlays, or tmpfs would otherwise create noise.

TARGETS lists mount points, not arbitrary directories. Configure /var only when /var is mounted as its own filesystem; if /var is a directory on /, then df -P /var reports the filesystem mounted on /, and exact mount-name matching will not find a separate /var row. In that case monitor / instead.

bash
TARGETS="/ /boot" ~/disk-alert-lab/bin/disk-alert.sh

For lab testing, aim DISK_ALERT_DF_FIXTURE at a file with two lines above threshold:

output
ALERT: mount=/ pct=85 level=warn
ALERT: mount=/boot pct=91 level=crit

Each mount gets its own alert line and its own row in the state file. If a configured target is missing from df output — typo, unmounted filesystem, or invalid operand — treat it as a monitoring error and exit 2. Saying “OK” because /var is absent would hide a real outage.

Against a fixture that omits /missing:

bash
TARGETS="/missing" DISK_ALERT_DF_FIXTURE=~/disk-alert-lab/fixtures/df-79.tsv \
  ~/disk-alert-lab/bin/disk-alert.sh
output
ERROR: configured target missing from df output: /missing

The script exits 2. On live df, an invalid path makes df fail during collection with the same exit code:

bash
TARGETS="/missing" ~/disk-alert-lab/bin/disk-alert.sh
output
df: /missing: No such file or directory
ERROR: failed to collect df output (check TARGETS paths)

Add Logging

Append every alert, escalation, suppression, recovery, and delivery failure to ${XDG_STATE_HOME:-$HOME/.local/state}/disk-alert/alerts.log:

bash
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/disk-alert"
LOG_FILE="${STATE_DIR}/alerts.log"

log_line() {
  local msg=$1
  mkdir -p "${STATE_DIR}"
  printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$msg" >> "${LOG_FILE}"
}

After a warning and a duplicate cron run, the log shows both the original alert and a suppression entry:

output
2026-09-12T11:04:41+0530 ALERT mount=/ pct=85 level=warn
2026-09-12T11:04:41+0530 SUPPRESSED mount=/ pct=85 level=warn

When you schedule the checker with cron or systemd, redirecting stdout is optional because the log file holds a persistent audit trail under $XDG_STATE_HOME. Keep stdout lines machine-readable (ALERT: / RECOVERY:) so a wrapper can forward them to email or a webhook.


Add Email or Webhook Notification

Keep each notification method in its own function. Never hard-code webhook URLs, API tokens, or recipient addresses in the script — use environment variables your scheduler injects.

Print alerts to stdout (always available):

bash
notify_stdout() {
  printf '%s: mount=%s pct=%s level=%s\n' "$1" "$2" "$3" "$4"
}

Send alerts by email (optional — requires a mail-compatible command and working mail delivery configuration). Return a non-zero status when delivery fails:

bash
notify_email() {
  local subject=$1 body=$2
  [[ -n "${ALERT_EMAIL_TO:-}" ]] || return 0
  if ! command -v mail >/dev/null 2>&1; then
    log_line "EMAIL delivery failed: mail command not installed"
    return 1
  fi
  if ! printf '%s\n' "$body" | mail -s "$subject" "$ALERT_EMAIL_TO"; then
    log_line "EMAIL delivery command failed"
    return 1
  fi
  return 0
}

Send alerts to a webhook (optional — set DISK_ALERT_WEBHOOK_URL in the unit or crontab environment). Build JSON with jq in Bash so mount paths are escaped safely; add curl timeouts and treat HTTP errors as delivery failures:

bash
notify_webhook() {
  local kind=$1 mount=$2 pct=$3 level=$4 payload
  [[ -n "${DISK_ALERT_WEBHOOK_URL:-}" ]] || return 0
  if ! command -v jq >/dev/null 2>&1; then
    log_line "WEBHOOK delivery failed: jq not installed"
    return 1
  fi
  payload=$(jq -nc \
    --arg kind "$kind" --arg mount "$mount" --argjson pct "$pct" --arg level "$level" \
    '{kind:$kind,mount:$mount,pct:$pct,level:$level}')
  if ! curl --fail --silent --show-error \
    --connect-timeout 5 --max-time 15 \
    -H 'Content-Type: application/json' --data "$payload" \
    "$DISK_ALERT_WEBHOOK_URL" >/dev/null; then
    log_line "WEBHOOK delivery failed"
    return 1
  fi
  return 0
}

Wire all three through one dispatcher. Each channel logs its own delivery failure; state progression depends on disk condition only:

bash
dispatch_notify() {
  local kind=$1 mount=$2 pct=$3 level=$4 msg="${kind}: mount=${mount} pct=${pct} level=${level}"
  notify_stdout "$kind" "$mount" "$pct" "$level"
  notify_email "disk-alert ${kind} ${mount}" "$msg" || true
  notify_webhook "$kind" "$mount" "$pct" "$level" || true
}

Alert state tracks the disk condition, not successful notification delivery. A failed email or webhook is logged but is not retried until another state transition occurs — for example warn→crit escalation or recovery. A webhook failure does not change whether the disk itself is over the threshold.

On the lab host, no mail-compatible client was installed. With ALERT_EMAIL_TO set, the script logs a delivery failure and still advances last_level so duplicate suppression works on the next cron run. The webhook path uses the same curl command patterns as API scripts — curl diagnostics go to stderr while the durable log records a generic failure line. Point DISK_ALERT_WEBHOOK_URL at your chat gateway or incident tool; store sensitive notification settings in a permission-restricted environment file readable only by the account that runs the checker, rather than embedding them in the script.


Avoid Repeating the Same Alert

Without state, cron sends the same email every five minutes while /var stays at 92%. Store persistent state under $XDG_STATE_HOME — not ~/.cache, which is for disposable cached data and may be cleared independently:

bash
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/disk-alert"
STATE_FILE="${STATE_DIR}/state.tsv"
LOG_FILE="${STATE_DIR}/alerts.log"
LOCK_FILE="${STATE_DIR}/state.lock"

Track one row per mount in state.tsv:

text
/mount/path<TAB>last_pct<TAB>last_level

last_level is the current disk condition (ok, warn, or crit). Notify only when the state changes in a way the operator needs to know about:

Previous Current Action
ok warn Send warning alert
ok crit Send critical alert
warn warn Suppress (log only)
warn crit Send critical escalation
crit crit Suppress
crit warn Suppress (no downgrade notification)
warn or crit ok (below warn) Send recovery

Match the mount path against the first tab-separated field with awk. Avoid a simple grep, which could match part of another path:

bash
state_get() {
  local mount=$1 row
  row=$(awk -F '\t' -v m="$mount" '$1 == m { print $2 "\t" $3; exit }' "$STATE_FILE")
  [[ -n "$row" ]] || row=$'0\tok'
  printf '%s\n' "$row"
}

Update the state file atomically: write a temp file, then mv over the original. Concurrent cron overlap makes a plain redirect append risky.

The script acquires an internal flock on ${STATE_DIR}/state.lock before reading or updating state, so overlapping cron runs do not need a second wrapper:

bash
exec 200>"${LOCK_FILE}"
flock -w 10 200 || exit 2

The lock stays held until the script exits — including during email or webhook calls. A second run waits up to 10 seconds; if the first run is still posting a webhook (timeout up to 15 seconds), the waiter exits 2. That usually means the previous check is still delivering an alert, not that configuration is wrong.

Run the script twice at 85%, then escalate to 95% on the next run:

bash
rm -rf ~/.local/state/disk-alert
DISK_ALERT_DF_FIXTURE=~/disk-alert-lab/fixtures/df-85.tsv ~/disk-alert-lab/bin/disk-alert.sh
output
ALERT: mount=/ pct=85 level=warn

Raise usage to 95% without clearing state:

bash
DISK_ALERT_DF_FIXTURE=~/disk-alert-lab/fixtures/df-95.tsv ~/disk-alert-lab/bin/disk-alert.sh
output
ALERT: mount=/ pct=95 level=crit

A third run at 95% exits 1 with no new stdout line — only a SUPPRESSED log entry.


Send a Recovery Notification

When usage drops below WARN_THRESHOLD after a prior warn or crit state, emit one recovery event and set last_level to ok. The recovery event confirms that usage has returned below the warning threshold instead of leaving the previous alert unresolved.

Sequence on fixtures:

bash
rm -rf ~/.local/state/disk-alert
DISK_ALERT_DF_FIXTURE=~/disk-alert-lab/fixtures/df-85.tsv ~/disk-alert-lab/bin/disk-alert.sh
output
ALERT: mount=/ pct=85 level=warn

Drop to 75% on the next run:

bash
DISK_ALERT_DF_FIXTURE=~/disk-alert-lab/fixtures/df-75.tsv ~/disk-alert-lab/bin/disk-alert.sh
output
RECOVERY: mount=/ pct=75 level=below-warn

Recovery runs exit 0 even though an operator still cares about the event — send recovery notifications through the same email or webhook path as alerts.


Test with Synthetic df Output

Synthetic df -P fixtures exercise threshold and recovery behavior without consuming real disk space. Store POSIX df samples under ~/disk-alert-lab/fixtures/ and point DISK_ALERT_DF_FIXTURE at them.

Example fixture (df-80.tsv):

text
Filesystem            1024-blocks     Used Available Capacity Mounted on
/dev/mapper/rhel-root    26148864 20919091   5230773      80% /

Run the checker against the fixture:

bash
WARN_THRESHOLD=80 CRIT_THRESHOLD=90 TARGETS="/" \
  DISK_ALERT_DF_FIXTURE=~/disk-alert-lab/fixtures/df-79.tsv \
  ~/disk-alert-lab/bin/disk-alert.sh
echo exit=$?
output
exit=0

Repeat with df-80.tsv, df-85.tsv, a malformed abc% line, two-mount files, /missing in TARGETS, duplicate 85% runs, and an 85%→75% transition. The table below matches what we saw on the lab host:

Scenario Expected exit stdout
79% on / 0 (silent)
Exactly 80% 1 ALERT: … level=warn
95% critical 1 ALERT: … level=crit
Malformed abc% 2 (error to stderr)
/ 85% and /boot 91% 1 two ALERT: lines
Configured target /missing 2 ERROR: configured target missing…
Second identical 85% run 1 (no new ALERT)
85% then 95% 1 on escalation second ALERT: … level=crit
85% then 75% 0 on recovery RECOVERY: …

Schedule with Cron or systemd

Install the finished script at a fixed path (for example ~/disk-alert-lab/bin/disk-alert.sh) and call it from the crontab command every few minutes:

text
*/5 * * * * WARN_THRESHOLD=80 CRIT_THRESHOLD=90 TARGETS="/ /var" /home/golinuxcloud/disk-alert-lab/bin/disk-alert.sh

Replace /home/golinuxcloud with your home directory. For email, add ALERT_EMAIL_TO=ops@example.com in the crontab line or in a small wrapper that sources /etc/disk-alert.env.

The schedule Bash scripts guide compares cron and systemd timers, PATH pitfalls, and journald logging. Either scheduler works; cron is enough for a single host check. The checker acquires its own lock before touching state, so the crontab line does not need an external flock wrapper.


Complete Script and Sample Output

Create the lab directory, then save the full script as ~/disk-alert-lab/bin/disk-alert.sh:

bash
mkdir -p ~/disk-alert-lab/bin ~/disk-alert-lab/fixtures

The listing below is the complete tested utility (stdout, log, state, recovery, email, and webhook hooks):

bash
#!/usr/bin/env bash
# disk-alert.sh — warn/crit disk usage alerts with state and recovery
# Exit contract (this script only):
#   0 = all targets below warn, or recovery-only run
#   1 = threshold active (alert, escalation, or suppressed duplicate)
#   2 = check could not complete (config / parse / target / lock timeout)
set -euo pipefail

WARN_THRESHOLD="${WARN_THRESHOLD:-80}"
CRIT_THRESHOLD="${CRIT_THRESHOLD:-90}"
TARGETS="${TARGETS:-/}"

STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/disk-alert"
STATE_FILE="${STATE_DIR}/state.tsv"
LOG_FILE="${STATE_DIR}/alerts.log"
LOCK_FILE="${STATE_DIR}/state.lock"

validate_threshold() {
  local name=$1
  local val=$2
  if ! [[ "$val" =~ ^[0-9]+$ ]] || (( val < 0 || val > 100 )); then
    echo "ERROR: ${name} must be a numeric value between 0 and 100 (got: ${val})" >&2
    exit 2
  fi
}

validate_target() {
  local target=$1
  if [[ "$target" == *$'\t'* || "$target" == *$'\n'* || "$target" == *" "* ]]; then
    echo "ERROR: TARGETS must be mount paths without whitespace (got: ${target})" >&2
    exit 2
  fi
  if [[ "$target" != /* ]]; then
    echo "ERROR: TARGETS must be absolute mount paths (got: ${target})" >&2
    exit 2
  fi
}

classify_level() {
  local pct=$1
  if (( pct >= CRIT_THRESHOLD )); then
    printf 'crit\n'
  elif (( pct >= WARN_THRESHOLD )); then
    printf 'warn\n'
  else
    printf 'ok\n'
  fi
}

collect_df_lines() {
  if [[ -n "${DISK_ALERT_DF_FIXTURE:-}" ]]; then
    cat "${DISK_ALERT_DF_FIXTURE}"
  else
    LC_ALL=C df -P "${targets[@]}"
  fi
}

parse_pct() {
  local capacity=$1
  local pct="${capacity%%%}"
  if ! [[ "$pct" =~ ^[0-9]+$ ]]; then
    return 1
  fi
  printf '%s\n' "$pct"
}

log_line() {
  local msg=$1
  mkdir -p "${STATE_DIR}"
  printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$msg" >> "${LOG_FILE}"
}

notify_stdout() {
  local kind=$1 mount=$2 pct=$3 level=$4
  printf '%s: mount=%s pct=%s level=%s\n' "$kind" "$mount" "$pct" "$level"
}

notify_email() {
  local subject=$1 body=$2
  if [[ -z "${ALERT_EMAIL_TO:-}" ]]; then
    return 0
  fi
  if ! command -v mail >/dev/null 2>&1; then
    log_line "EMAIL delivery failed: mail command not installed"
    return 1
  fi
  if ! printf '%s\n' "$body" | mail -s "$subject" "$ALERT_EMAIL_TO"; then
    log_line "EMAIL delivery command failed"
    return 1
  fi
  return 0
}

notify_webhook() {
  local kind=$1 mount=$2 pct=$3 level=$4
  if [[ -z "${DISK_ALERT_WEBHOOK_URL:-}" ]]; then
    return 0
  fi
  if ! command -v jq >/dev/null 2>&1; then
    log_line "WEBHOOK delivery failed: jq not installed"
    return 1
  fi
  local payload
  payload=$(jq -nc \
    --arg kind "$kind" \
    --arg mount "$mount" \
    --argjson pct "$pct" \
    --arg level "$level" \
    '{kind:$kind,mount:$mount,pct:$pct,level:$level}')
  if ! curl --fail --silent --show-error \
    --connect-timeout 5 \
    --max-time 15 \
    -H 'Content-Type: application/json' \
    --data "$payload" \
    "$DISK_ALERT_WEBHOOK_URL" >/dev/null; then
    log_line "WEBHOOK delivery failed"
    return 1
  fi
  return 0
}

dispatch_notify() {
  local kind=$1 mount=$2 pct=$3 level=$4
  local msg="${kind}: mount=${mount} pct=${pct} level=${level}"
  notify_stdout "$kind" "$mount" "$pct" "$level"
  notify_email "disk-alert ${kind} ${mount}" "$msg" || true
  notify_webhook "$kind" "$mount" "$pct" "$level" || true
}

state_get() {
  local mount=$1 row
  if [[ ! -f "${STATE_FILE}" ]]; then
    printf '0\tok\n'
    return 0
  fi
  row=$(awk -F '\t' -v m="$mount" '$1 == m { print $2 "\t" $3; exit }' "${STATE_FILE}")
  if [[ -z "$row" ]]; then
    printf '0\tok\n'
  else
    printf '%s\n' "$row"
  fi
}

state_set() {
  local mount=$1 pct=$2 level=$3 tmp
  tmp=$(mktemp "${STATE_DIR}/state.XXXXXX")
  if [[ -f "${STATE_FILE}" ]]; then
    awk -F '\t' -v m="$mount" '$1 != m' "${STATE_FILE}" > "$tmp" 2>/dev/null || true
  fi
  printf '%s\t%s\t%s\n' "$mount" "$pct" "$level" >> "$tmp"
  mv "$tmp" "${STATE_FILE}"
}

validate_threshold WARN_THRESHOLD "$WARN_THRESHOLD"
validate_threshold CRIT_THRESHOLD "$CRIT_THRESHOLD"
if (( WARN_THRESHOLD > CRIT_THRESHOLD )); then
  echo "ERROR: WARN_THRESHOLD (${WARN_THRESHOLD}) must be <= CRIT_THRESHOLD (${CRIT_THRESHOLD})" >&2
  exit 2
fi

read -r -a targets <<< "$TARGETS"
declare -A target_want=()
for t in "${targets[@]}"; do
  [[ -z "$t" ]] && continue
  validate_target "$t"
  target_want["$t"]=1
done

mkdir -p "${STATE_DIR}"
touch "${STATE_FILE}" "${LOCK_FILE}"
exec 200>"${LOCK_FILE}"
if ! flock -w 10 200; then
  echo "ERROR: could not acquire lock on ${LOCK_FILE}" >&2
  exit 2
fi

had_alert=0
parse_error=0
missing_target=0
found_targets=()
parse_failed_targets=()

df_output=$(collect_df_lines) || {
  echo "ERROR: failed to collect df output (check TARGETS paths)" >&2
  exit 2
}

while IFS= read -r line; do
  [[ "$line" == Filesystem* ]] && continue
  [[ -z "$line" ]] && continue

  mount=$(awk '{print $NF}' <<<"$line")
  capacity=$(awk '{print $(NF-1)}' <<<"$line")

  if ! pct=$(parse_pct "$capacity"); then
    if [[ -n "${target_want[$mount]:-}" ]]; then
      parse_error=1
      parse_failed_targets+=("$mount")
    fi
    continue
  fi

  if [[ -z "${target_want[$mount]:-}" ]]; then
    continue
  fi

  found_targets+=("$mount")

  IFS=$'\t' read -r _last_pct prev_level <<< "$(state_get "$mount")"
  prev_level=${prev_level:-ok}
  new_level=$(classify_level "$pct")

  if [[ "$new_level" == ok ]]; then
    if [[ "$prev_level" != ok ]]; then
      log_line "RECOVERY mount=${mount} pct=${pct}"
      dispatch_notify RECOVERY "$mount" "$pct" below-warn
    fi
    state_set "$mount" "$pct" ok
    continue
  fi

  had_alert=1
  notify=false
  if [[ "$prev_level" == ok ]]; then
    notify=true
  elif [[ "$prev_level" == warn && "$new_level" == crit ]]; then
    notify=true
  else
    log_line "SUPPRESSED mount=${mount} pct=${pct} level=${new_level}"
  fi

  if [[ "$notify" == true ]]; then
    log_line "ALERT mount=${mount} pct=${pct} level=${new_level}"
    dispatch_notify ALERT "$mount" "$pct" "$new_level"
  fi
  state_set "$mount" "$pct" "$new_level"
done <<< "$df_output"

for mount in "${!target_want[@]}"; do
  found=0
  for m in "${found_targets[@]:-}"; do
    [[ "$m" == "$mount" ]] && found=1 && break
  done
  if (( found == 0 )); then
    for m in "${parse_failed_targets[@]:-}"; do
      [[ "$m" == "$mount" ]] && found=1 && break
    done
  fi
  if (( found == 0 )); then
    missing_target=1
    log_line "ERROR configured target missing from df output: ${mount}"
    echo "ERROR: configured target missing from df output: ${mount}" >&2
  fi
done

(( parse_error == 1 )) && exit 2
(( missing_target == 1 )) && exit 2
(( had_alert == 1 )) && exit 1
exit 0

Make the script executable:

bash
chmod +x ~/disk-alert-lab/bin/disk-alert.sh

Against live df on the lab host (63% on /), a production run exits 0 with no stdout. Against df-80.tsv you see:

output
ALERT: mount=/ pct=80 level=warn

When Bash Monitoring Is No Longer Enough

This Bash script is suitable when you only need to monitor a small number of Linux hosts and a fixed set of mount points. Move to a metrics stack when you need historical graphs, SLO dashboards, alert routing, or fleet-wide consistency.

Stay with Bash when Step up to observability tooling when
A small number of hosts are managed by one team Fleet-wide rules must stay consistent across many hosts or teams
Threshold alerts on named mounts are enough You need trend data, prediction, or capacity planning
Cron every five minutes is acceptable latency Sub-minute detection and on-call paging matter
stdout/email/webhook is fine You already run Prometheus, Nagios, or a cloud monitor

Common next steps:

  • Prometheus + node_exporter — scrape node_filesystem_avail_bytes and alert with PromQL (alertmanager handles deduplication).
  • Nagios / Icinga / check_mk — packaged disk plugins with built-in flap detection and escalation.
  • Cloud agent — AWS CloudWatch, Azure Monitor, or GCP ops agents when instances live entirely in a vendor console.

Keep the Bash script on edge cases (air-gapped hosts, jump boxes, or CI smoke tests). For everything else, export metrics once and alert in one place.


Summary

The disk checker uses df -P with explicit mount points (no whitespace in paths), warning and critical states, warn→crit escalation, duplicate suppression, recovery notifications, and a documented exit contract. Capacity parsing reads the last two awk fields; block-size columns do not affect the percentage check.

Persistent state and logs live under $XDG_STATE_HOME/disk-alert/; when XDG_STATE_HOME is unset, the script uses ~/.local/state/disk-alert/. That location is not ~/.cache, which is for disposable cached data. Field-aware state suppresses duplicate alerts on unchanged disk levels; delivery failures are logged separately.

Schedule the checker with cron or systemd — the script holds its own state lock — and reach for Prometheus or Nagios when you outgrow host-local scripts. The fixture tests cover 79%, the 80% boundary, critical escalation, duplicate alerts, recovery, and missing targets without filling a real filesystem.


References


Frequently Asked Questions

Why use df -P instead of df -h?

df -P keeps each filesystem record on one line and uses a predictable POSIX-style field layout. This script reads only the capacity percentage and mount point; it does not need human-readable block sizes from -h.

How can I test without filling my disk?

Save sample df -P transcripts to a fixture file and set DISK_ALERT_DF_FIXTURE to that path. The script reads the fixture through the same parser used in production, so you can exercise 80%, critical, malformed, and recovery cases safely.

How do I stop an alert every cron run?

Store last_level per mount in a state file under $XDG_STATE_HOME. Notify on transitions — first breach, warn→crit escalation, and recovery below the warning threshold — and log SUPPRESSED when the level stays unchanged. Delivery failures are logged but do not trigger another notification until the disk state changes again.

How do I monitor only one mount?

Set TARGETS to a single absolute path without spaces, for example TARGETS="/var". The script ignores every other line in df output. For multiple paths, use a space-separated list: TARGETS="/ /var /home".

What exit code should the checker return?

There is no universal standard — define one for your script and document it. In this article the checker uses 0 for OK or recovery-only runs, 1 when a threshold is active (including suppressed repeats), and 2 when the check could not complete — invalid configuration, parsing or collection failure, a configured mount missing from df output, or a lock-acquisition timeout.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)