| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash-5.2.26-6.el10.x86_64coreutils-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 |
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 example85%) into an integer. - Compares usage against a warning threshold (default
80) and a critical threshold (default90). - Prints one alert line per affected mount on stdout.
- Appends timestamped lines to a log file under
$XDG_STATE_HOME/disk-alert/; whenXDG_STATE_HOMEis 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:
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:
WARN_THRESHOLD=abc ~/disk-alert-lab/bin/disk-alert.shERROR: 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:
LC_ALL=C df -P /Sample 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:
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:
pct='85%'
echo "${pct%%%}"85Split 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:
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):
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 /:
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:
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_THRESHOLDbut belowCRIT_THRESHOLD→ warning alert. - At or above
CRIT_THRESHOLD→ critical alert.
Use arithmetic comparison on integers, not string sort:
if (( pct >= WARN_THRESHOLD )); then
if (( pct >= CRIT_THRESHOLD )); then
level=crit
else
level=warn
fi
fiExactly 80% with WARN_THRESHOLD=80 must alert. Feed a fixture at the boundary:
WARN_THRESHOLD=80 CRIT_THRESHOLD=90 TARGETS="/" \
DISK_ALERT_DF_FIXTURE=~/disk-alert-lab/fixtures/df-80.tsv \
~/disk-alert-lab/bin/disk-alert.shALERT: mount=/ pct=80 level=warnAt 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.
TARGETS="/ /boot" ~/disk-alert-lab/bin/disk-alert.shFor lab testing, aim DISK_ALERT_DF_FIXTURE at a file with two lines above threshold:
ALERT: mount=/ pct=85 level=warn
ALERT: mount=/boot pct=91 level=critEach 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:
TARGETS="/missing" DISK_ALERT_DF_FIXTURE=~/disk-alert-lab/fixtures/df-79.tsv \
~/disk-alert-lab/bin/disk-alert.shERROR: configured target missing from df output: /missingThe script exits 2. On live df, an invalid path makes df fail during collection with the same exit code:
TARGETS="/missing" ~/disk-alert-lab/bin/disk-alert.shdf: /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:
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:
2026-09-12T11:04:41+0530 ALERT mount=/ pct=85 level=warn
2026-09-12T11:04:41+0530 SUPPRESSED mount=/ pct=85 level=warnWhen 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):
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:
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:
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:
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:
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:
/mount/path<TAB>last_pct<TAB>last_levellast_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:
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:
exec 200>"${LOCK_FILE}"
flock -w 10 200 || exit 2The 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:
rm -rf ~/.local/state/disk-alert
DISK_ALERT_DF_FIXTURE=~/disk-alert-lab/fixtures/df-85.tsv ~/disk-alert-lab/bin/disk-alert.shALERT: mount=/ pct=85 level=warnRaise usage to 95% without clearing state:
DISK_ALERT_DF_FIXTURE=~/disk-alert-lab/fixtures/df-95.tsv ~/disk-alert-lab/bin/disk-alert.shALERT: mount=/ pct=95 level=critA 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:
rm -rf ~/.local/state/disk-alert
DISK_ALERT_DF_FIXTURE=~/disk-alert-lab/fixtures/df-85.tsv ~/disk-alert-lab/bin/disk-alert.shALERT: mount=/ pct=85 level=warnDrop to 75% on the next run:
DISK_ALERT_DF_FIXTURE=~/disk-alert-lab/fixtures/df-75.tsv ~/disk-alert-lab/bin/disk-alert.shRECOVERY: mount=/ pct=75 level=below-warnRecovery 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):
Filesystem 1024-blocks Used Available Capacity Mounted on
/dev/mapper/rhel-root 26148864 20919091 5230773 80% /Run the checker against the fixture:
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=$?exit=0Repeat 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:
*/5 * * * * WARN_THRESHOLD=80 CRIT_THRESHOLD=90 TARGETS="/ /var" /home/golinuxcloud/disk-alert-lab/bin/disk-alert.shReplace /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:
mkdir -p ~/disk-alert-lab/bin ~/disk-alert-lab/fixturesThe listing below is the complete tested utility (stdout, log, state, recovery, email, and webhook hooks):
#!/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 0Make the script executable:
chmod +x ~/disk-alert-lab/bin/disk-alert.shAgainst live df on the lab host (63% on /), a production run exits 0 with no stdout. Against df-80.tsv you see:
ALERT: mount=/ pct=80 level=warnWhen 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_bytesand alert with PromQL (alertmanagerhandles 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.

