Short answer: inode-usage-report.sh counts inodes (files and folders) for every hosting account on a cPanel, DirectAdmin or plain Linux server, marks each account OK, WARN or CRIT against thresholds you set, and lists the directories inside each account that hold the most files. If du could not measure an account fully, the account is marked INCOMPLETE or UNKNOWN instead, and the script exits 3. It uses du --inodes at low priority, changes nothing, and can print CSV for spreadsheets or monitoring.
Version 1.1.0 (7 October 2026): An external review found that version 1.0.0 hid du errors, so an account that could not be read fully was shown with a low count and status OK. Now such an account is shown as INCOMPLETE (count marked as a lower bound, for example 12345+) or UNKNOWN (?), and the script exits 3.
We ran version 1.0.0 on our lab servers (AlmaLinux 9.8 with cPanel & WHM 11.138, and AlmaLinux 9.8 with DirectAdmin 1.712) on 6 October 2026, and version 1.1.0 on the cPanel lab on 7 October 2026. It is bash -n and ShellCheck 0.9.0 clean.
Table of Contents
What it does
Hosting plans and filesystems limit the number of files, not just the space they use. An account full of cache files, session files or a mail folder with years of messages can hit an inode limit while using little disk. When that happens, uploads, mail delivery and updates fail with “No space left on device” even though df -h shows free space. This script tells you which account is responsible and which folder inside it.
- Detects the panel and builds the account list:
/var/cpanel/userson cPanel,/usr/local/directadmin/data/userson DirectAdmin, or the folders under/homeon a plain server (skippingvirtfs,lost+found,tmp). - Runs
du --inodes -x --max-depth=Nonce per account home, underniceandionice -c3. - Prints a summary table sorted by inode count with OK, WARN or CRIT per account. Only a clean
durun with a total line can give one of those three. - Marks an account INCOMPLETE when
dufails or prints an error for it (an unreadable or vanished directory, an I/O error). The INODES column then shows the partial count as a lower bound, such as12345+, and the error is printed to stderr. An account with no total fromduis UNKNOWN and shows?, never 0 and OK. - For each account, lists the top N directories by inode count with their share of the account total.
- Exits 0 (all OK), 1 (any WARN), 2 (any CRIT) or 3 (a usage or environment error, or at least one account INCOMPLETE or UNKNOWN), so monitoring can act on it. Exit code 3 takes precedence over 1 and 2, because the report is not complete.
Requirements
- GNU coreutils 8.22 or newer for
du --inodes(any current AlmaLinux, CloudLinux, Rocky, Debian or Ubuntu). The script checks this and stops with a clear message if it is missing. - Bash 4 or newer.
- Root, to read every account. As a normal user, use
--pathon folders you can read. Without--path, a normal user usually gets INCOMPLETE accounts and exit code 3, becausedureports permission errors.
For a quick look at the filesystem first:
df -i /home
Download and first run
Save the script from this page as /root/inode-usage-report.sh, then:
chmod 700 /root/inode-usage-report.sh
/root/inode-usage-report.sh --summary # totals only
/root/inode-usage-report.sh --top 10 # totals plus top 10 folders per account
/root/inode-usage-report.sh --user bob --depth 4 --top 20
Set --warn and --crit to match your plans. The defaults (200,000 and 400,000) are only a starting point.
Options
| Option | Default | What it does |
|---|---|---|
--top N | 10 | Directories listed per account |
--depth N | 2 | How many levels below the home to rank (1-10) |
--warn N | 200000 | Account total that triggers WARN |
--crit N | 400000 | Account total that triggers CRIT (must be higher than –warn) |
--user NAME | all | Only this account (repeatable) |
--path DIR | auto | Treat DIR as one account (repeatable; skips panel detection) |
--summary | off | Only the per-account totals table |
--csv | off | CSV: account,home,total,status,directory,inodes,percent. Total and status match the table (12345+/INCOMPLETE, ?/UNKNOWN); an account with no directory rows still gets one row |
--no-nice | off | Run at normal CPU and I/O priority |
Example output
On our cPanel lab with --top 5 and version 1.0.0 (0.2 seconds for three accounts; one shown). Version 1.1.0 prints a clean run in the same format:
Inode usage report - panel: cpanel - warn: 200000 - crit: 400000 - depth: 2
ACCOUNT INODES STATUS HOME
site1 4444 OK /home/site1
site2 4408 OK /home/site2
site3 4408 OK /home/site3
== site1 (/home/site1) - 4444 inodes - OK - top 5 directories
4258 95.8% /home/site1/public_html
3118 70.2% /home/site1/public_html/wp-includes
611 13.7% /home/site1/public_html/wp-admin
501 11.3% /home/site1/public_html/wp-content
66 1.5% /home/site1/mail
Version 1.1.0 on the same cPanel lab on 7 October 2026 reported site1 with 4450 inodes, site2 with 4412 and site3 with 4412, all OK.
On our DirectAdmin lab with version 1.0.0, with thresholds set low to show the CRIT state (exit code 2):
./inode-usage-report.sh --summary --warn 3000 --crit 6000
Inode usage report - panel: directadmin - warn: 3000 - crit: 6000 - depth: 2
ACCOUNT INODES STATUS HOME
admin 12888 CRIT /home/admin
And the per-folder view for the same account at default thresholds (domains masked):
== admin (/home/admin) - 12888 inodes - OK - top 5 directories
12797 99.3% /home/admin/domains
4264 33.1% /home/admin/domains/site1.example.com
4264 33.1% /home/admin/domains/site2.example.com
4264 33.1% /home/admin/domains/site3.example.com
42 0.3% /home/admin/Maildir
CSV output (cPanel lab, --top 3 --csv, first rows):
account,home,total,status,directory,inodes,percent
site1,/home/site1,4444,OK,"/home/site1/public_html",4258,95.8
site1,/home/site1,4444,OK,"/home/site1/public_html/wp-includes",3118,70.2
site1,/home/site1,4444,OK,"/home/site1/public_html/wp-admin",611,13.7
Counts are cumulative: a folder’s number includes everything below it, so public_html always ranks above its own subfolders. Increase --depth to drill into the folder that dominates.
If du reports an error for an account, the STATUS column shows INCOMPLETE and the INODES column a lower bound such as 12345+, or UNKNOWN and ? when there is no total at all. The percent column shows ? instead of 0.0 when there is no usable total. The first error line for each such account goes to stderr, and the text report ends with a warning like this before the script exits 3:
WARNING: 1 account(s) could not be fully measured (du reported errors).
INCOMPLETE counts (shown as N+) are lower bounds; UNKNOWN means no total. Exit code 3.
Our 1.1.0 lab run on cPanel had no such errors, so no INCOMPLETE or UNKNOWN account is shown above.
Schedule it
# /etc/cron.d/inode-usage-report : daily summary, mail only when something is WARN, CRIT, INCOMPLETE or UNKNOWN
MAILTO=admin@example.com
40 5 * * * root /root/inode-usage-report.sh --summary --warn 250000 --crit 400000 > /root/inode-report.txt || cat /root/inode-report.txt
An INCOMPLETE account in the mail usually means files were deleted while du was running (“No such file or directory”) on a busy server. Re-run the script, or schedule it at a quieter time. For a disk-level alert that also covers free space, pair it with our disk and inode alert script.
How it works
- Validates options (numbers only,
--warnbelow--crit) and checks thatdusupports--inodes. - Builds the account list from the panel’s user folders and resolves each home directory with
getent passwd. - Runs one
du --inodes -xper account and records its exit status and error output;-xkeeps it on one filesystem, so bind mounts and other disks are not counted. - Takes the account total from the home directory’s own line and ranks the other lines. No total line means UNKNOWN; a total with a
duerror or non-zero exit means INCOMPLETE. - Prints aligned tables with awk (no dependency on
column) or CSV, and sets the exit code from the worst status, with 3 for any INCOMPLETE or UNKNOWN account.
What to do with the results
- Cache folders (
wp-content/cache, page-cache plugins): clear the cache in the application, then check the plugin’s expiry settings. - Mail folders (
mail/on cPanel,imap/on DirectAdmin): old messages and Trash. Ask the customer to archive, or set mailbox retention. - PHP session or temp folders inside the home: a broken cleanup cron. Fix garbage collection rather than deleting by hand.
- Backup archives left in the home: move them off the server.
Before deleting anything, confirm with the account owner and take a backup. Deleting a folder because it is large in inode terms can break a site; deleting files inside wp-includes or vendor folders will.
Limitations
- Reads the whole home of each account, so on very large servers a full run takes a while. Use
--useror--summaryfor quick checks. - Counts are filesystem inodes as
dusees them; hard-linked files count once. Panel quota tools may count slightly differently. - Accounts whose home is on another filesystem than
/homeare still counted, but nested mounts inside a home are skipped because of-x. - It does not read panel inode quotas; set
--warnand--critto match your plans. - An INCOMPLETE account stays INCOMPLETE, not WARN or CRIT, even when its partial count is already above
--crit. Read theN+value to see how large it is. Its top-directory percentages are based on the partial total. - Only the first error line of each failing
durun is shown.
Official documentation: GNU coreutils: du invocation · GNU coreutils: df invocation
Related: Disk and Inode Alert · cPanel Disk Usage Report · cPanel Disk Full: Safe Cleanup of Logs, Backups and Mail · Disk full on a production server: recovery runbook · Server Inventory Report
See also: cPanel Inode Usage: Find What Uses Inodes and Fix It
The script
#!/usr/bin/env bash
# Inode Usage Report: Top Directories by File Count per Account (v1.1.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/inode-usage-report/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
#
# inode-usage-report.sh
# Report inode (file count) usage per hosting account and list the top N
# directories by inode count inside each account, with warning and critical
# thresholds. Works on cPanel, DirectAdmin and plain Linux servers.
#
# https://srvscripts.com/scripts/inode-usage-report/
# Version: 1.1.0
# License: MIT
#
# Read-only: uses `du --inodes` (GNU coreutils 8.22+) and never changes files.
# Runs at low CPU and I/O priority (nice/ionice) unless --no-nice is given.
#
# If du fails or prints an error for an account (unreadable or vanished
# directory, I/O error), that account is reported as INCOMPLETE with a lower
# bound ("12345+") or as UNKNOWN when no total was produced - never as OK.
#
# Exit codes: 0 = all accounts below --warn, 1 = at least one WARN,
# 2 = at least one CRIT, 3 = usage or environment error, or at
# least one account INCOMPLETE/UNKNOWN (3 takes precedence,
# because the report is not complete).
set -euo pipefail
VERSION="1.1.0"
TOP=10
DEPTH=2
WARN=200000
CRIT=400000
CSV=0
NICE=1
SUMMARY_ONLY=0
declare -a USERS=()
declare -a PATHS=()
usage() {
cat <<'EOF'
Usage: inode-usage-report.sh [options]
Counts inodes per account home and shows the directories holding the most files.
If du reports an error for an account, its status is INCOMPLETE (count shown as
a lower bound, e.g. 12345+) or UNKNOWN (no total), and the script exits 3.
Options:
--top N Directories to list per account (default: 10)
--depth N Directory depth below the home to rank (default: 2)
--warn N Account total that triggers WARN (default: 200000)
--crit N Account total that triggers CRIT (default: 400000)
--user NAME Only this account (repeatable)
--path DIR Treat DIR as one account home (repeatable; skips panel detection)
--summary Only print the per-account totals table
--csv CSV output: account,home,total,status,directory,inodes,percent
--no-nice Do not lower CPU/I/O priority
-h, --help Show this help
-V, --version Show script version
Examples:
inode-usage-report.sh --summary
inode-usage-report.sh --user bob --top 20 --depth 3
inode-usage-report.sh --warn 150000 --crit 250000 --csv > inodes.csv
EOF
}
die() { echo "Error: $*" >&2; exit 3; }
# need_num OPTION VALUE MIN [MAX]
need_num() {
if ! [[ "$2" =~ ^[0-9]+$ ]] || [[ "$2" -lt "$3" ]] || { [[ -n "${4:-}" ]] && [[ "$2" -gt "$4" ]]; }; then
die "$1 needs a whole number >= $3${4:+ and <= $4}"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--top) need_num "$1" "${2:-}" 1; TOP="$2"; shift 2 ;;
--depth) need_num "$1" "${2:-}" 1 10; DEPTH="$2"; shift 2 ;;
--warn) need_num "$1" "${2:-}" 1; WARN="$2"; shift 2 ;;
--crit) need_num "$1" "${2:-}" 1; CRIT="$2"; shift 2 ;;
--user) [[ $# -ge 2 && "$2" =~ ^[a-z_][a-z0-9_.-]*$ ]] || die "--user needs a valid account name"; USERS+=("$2"); shift 2 ;;
--path) [[ $# -ge 2 && -d "$2" ]] || die "--path needs an existing directory"; PATHS+=("${2%/}"); shift 2 ;;
--summary) SUMMARY_ONLY=1; shift ;;
--csv) CSV=1; shift ;;
--no-nice) NICE=0; shift ;;
-h|--help) usage; exit 0 ;;
-V|--version) echo "inode-usage-report.sh $VERSION"; exit 0 ;;
*) usage >&2; die "unknown option: $1" ;;
esac
done
[[ "$WARN" -lt "$CRIT" ]] || die "--warn ($WARN) must be lower than --crit ($CRIT)"
du --inodes --version >/dev/null 2>&1 || die "this du does not support --inodes (need GNU coreutils 8.22 or newer)"
[[ $EUID -eq 0 || ${#PATHS[@]} -gt 0 ]] || echo "Warning: not running as root; counts for other users will be incomplete." >&2
RUN=()
if [[ $NICE -eq 1 ]]; then
command -v nice >/dev/null && RUN+=(nice -n 19)
command -v ionice >/dev/null && RUN+=(ionice -c3)
fi
# ---- build the account list: "name<TAB>home" -------------------------------
PANEL="plain"
declare -a ACCOUNTS=()
home_of() { getent passwd "$1" | cut -d: -f6 || true; }
if [[ ${#PATHS[@]} -gt 0 ]]; then
PANEL="paths"
for p in "${PATHS[@]}"; do ACCOUNTS+=("$(basename "$p")"$'\t'"$p"); done
else
if [[ -d /var/cpanel/users && -f /usr/local/cpanel/version ]]; then
PANEL="cpanel"; src=(/var/cpanel/users/*)
elif [[ -d /usr/local/directadmin/data/users ]]; then
PANEL="directadmin"; src=(/usr/local/directadmin/data/users/*)
else
src=()
for d in /home/*; do
[[ -d "$d" ]] || continue
case "$(basename "$d")" in virtfs|lost+found|tmp|cpanelsolr) continue ;; esac
src+=("$d")
done
fi
for s in "${src[@]+"${src[@]}"}"; do
u="$(basename "$s")"
[[ "$u" == "system" || "$u" == "nobody" ]] && continue
if [[ ${#USERS[@]} -gt 0 ]]; then
match=0; for w in "${USERS[@]}"; do [[ "$w" == "$u" ]] && match=1; done
[[ $match -eq 1 ]] || continue
fi
if [[ "$PANEL" == "plain" ]]; then h="$s"; else h="$(home_of "$u")"; fi
[[ -n "$h" && -d "$h" ]] && ACCOUNTS+=("$u"$'\t'"$h")
done
fi
[[ ${#ACCOUNTS[@]} -gt 0 ]] || die "no accounts found (check --user, or use --path DIR)"
status_of() {
if [[ "$1" -ge "$CRIT" ]]; then echo "CRIT"
elif [[ "$1" -ge "$WARN" ]]; then echo "WARN"
else echo "OK"; fi
}
# ---- measure ----------------------------------------------------------------
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
declare -a SUMMARY=()
worst=0
incomplete=0
for a in "${ACCOUNTS[@]}"; do
IFS=$'\t' read -r name home <<< "$a"
out="$TMP/$name.du"
# -x: stay on one filesystem; any du error (unreadable or vanished
# directory, I/O error) means the count is not complete
rc=0
"${RUN[@]+"${RUN[@]}"}" du --inodes -x --max-depth="$DEPTH" "$home" 2> "$TMP/$name.err" > "$out" || rc=$?
total="$(awk -v h="$home" -F'\t' '$2==h && $1 ~ /^[0-9]+$/ {print $1}' "$out" | tail -n1)"
if [[ -z "$total" ]]; then
total="?"; st="UNKNOWN"
elif [[ $rc -ne 0 || -s "$TMP/$name.err" ]]; then
total="$total+"; st="INCOMPLETE"
else
st="$(status_of "$total")"
fi
case "$st" in
CRIT) worst=2 ;; WARN) [[ $worst -lt 1 ]] && worst=1 ;;
UNKNOWN|INCOMPLETE)
incomplete=$((incomplete + 1))
msg="$(head -n1 "$TMP/$name.err" 2>/dev/null || true)"
echo "Warning: $name: $st (du exit $rc): ${msg:-no total line in du output}" >&2 ;;
esac
SUMMARY+=("$total"$'\t'"$name"$'\t'"$home"$'\t'"$st")
done
pct() { awk -v a="$1" -v b="$2" 'BEGIN{ b += 0; if (b>0) printf "%.1f", a*100/b; else print "?" }'; }
# align: tab-separated input -> padded columns (no dependency on column(1))
align() {
awk -F'\t' '{ for (i = 1; i <= NF; i++) { c[NR, i] = $i; if (length($i) > w[i]) w[i] = length($i) } if (NF > n) n = NF }
END { for (r = 1; r <= NR; r++) { line = ""; for (i = 1; i <= n; i++) line = line sprintf(i < n ? "%-" w[i] "s " : "%s", c[r, i]); print line } }'
}
# ---- output -----------------------------------------------------------------
sorted="$(printf '%s\n' "${SUMMARY[@]}" | sort -t$'\t' -k1,1nr)"
if [[ $CSV -eq 1 ]]; then
echo "account,home,total,status,directory,inodes,percent"
while IFS=$'\t' read -r total name home st; do
if [[ $SUMMARY_ONLY -eq 1 ]]; then
printf '%s,%s,%s,%s,,,\n' "$name" "$home" "$total" "$st"; continue
fi
# an account with no directory rows (empty, or UNKNOWN) still gets one row
awk -v h="$home" -F'\t' '$2!=h {f=1} END {exit !f}' "$TMP/$name.du" ||
printf '%s,%s,%s,%s,,,\n' "$name" "$home" "$total" "$st"
awk -v h="$home" -F'\t' '$2!=h' "$TMP/$name.du" | sort -t$'\t' -k1,1nr | head -n "$TOP" |
while IFS=$'\t' read -r n d; do
printf '%s,%s,%s,%s,"%s",%s,%s\n' "$name" "$home" "$total" "$st" "${d//\"/\"\"}" "$n" "$(pct "$n" "$total")"
done || true # head closing the pipe early is expected
done <<< "$sorted"
[[ $incomplete -eq 0 ]] || exit 3
exit "$worst"
fi
echo "Inode usage report - panel: $PANEL - warn: $WARN - crit: $CRIT - depth: $DEPTH"
echo
{
printf 'ACCOUNT\tINODES\tSTATUS\tHOME\n'
while IFS=$'\t' read -r total name home st; do printf '%s\t%s\t%s\t%s\n' "$name" "$total" "$st" "$home"; done <<< "$sorted"
} | align
if [[ $SUMMARY_ONLY -eq 0 ]]; then
while IFS=$'\t' read -r total name home st; do
echo
echo "== $name ($home) - $total inodes - $st - top $TOP directories"
awk -v h="$home" -F'\t' '$2!=h' "$TMP/$name.du" | sort -t$'\t' -k1,1nr | head -n "$TOP" |
while IFS=$'\t' read -r n d; do printf '%10s %5s%% %s\n' "$n" "$(pct "$n" "$total")" "$d"; done || true
done <<< "$sorted"
fi
if [[ $incomplete -gt 0 ]]; then
echo
echo "WARNING: $incomplete account(s) could not be fully measured (du reported errors)."
echo "INCOMPLETE counts (shown as N+) are lower bounds; UNKNOWN means no total. Exit code 3."
exit 3
fi
exit "$worst"
8178f4529199aa9b79019f23ff7afd96a15a70373df3bff3c1810ac85a774562curl -fsSL -o inode-usage-report.sh https://scr.srvscripts.com/inode-usage-report/inode-usage-report.sh && curl -fsSL https://scr.srvscripts.com/inode-usage-report/inode-usage-report.sh.sha256 | sha256sum -cInvoke-WebRequest -Uri 'https://scr.srvscripts.com/inode-usage-report/inode-usage-report.sh' -OutFile 'inode-usage-report.sh'; if ((Get-FileHash 'inode-usage-report.sh' -Algorithm SHA256).Hash -eq '8178F4529199AA9B79019F23FF7AFD96A15A70373DF3BFF3C1810AC85A774562') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.Also on GitHub: github.com/srvscripts/scripts
Frequently asked questions
What is an inode?
A filesystem record for one file or directory. Each file uses one inode no matter its size, so millions of small files can exhaust inodes while disk space is free.
How do I find what uses the most inodes in a folder?
Run du –inodes -x –max-depth=2 on the folder and sort by the first column, or use this script with –path and –depth.
Does the script delete anything?
No. It only runs du and prints results.
Why does public_html always show near 100%?
du counts are cumulative, so a folder includes all files below it. Raise –depth to see which subfolder holds them.
Will it slow down the server?
It runs du under nice and ionice idle class by default, so other work gets priority. Use –no-nice only for quick checks on quiet servers.
What do INCOMPLETE, UNKNOWN and exit code 3 mean?
du reported an error for that account, so the count is not complete. INCOMPLETE shows the partial count as a lower bound (N+); UNKNOWN means there was no total. The script exits 3 so monitoring does not treat the report as clean. Check the error on stderr and re-run.