Emergency server help: get in touch

Inode Usage Report: Top Directories by File Count per Account

Free bash script that reports inode usage per cPanel or DirectAdmin account, flags accounts over thresholds and lists the folders holding the most files.

Version
1.1.0
Last updated
October 7, 2026
Language
Bash
Tested on
1.1.0 on AlmaLinux 9.8 with cPanel & WHM 11.138 (lab test, 7 Oct 2026); 14 fixtures (unreadable subtree, vanished directory, I/O error, missing total); Run on 6 Oct 2026 on AlmaLinux 9.8 with cPanel & WHM 11.138 (3 accounts) and AlmaLinux 9.8 with DirectAdmin 1.712 (1 account, 3 domains), including summary, threshold and CSV modes; bash -n and ShellCheck 0.9.0 clean.
License
MIT
Pricing
Free

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.

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/users on cPanel, /usr/local/directadmin/data/users on DirectAdmin, or the folders under /home on a plain server (skipping virtfs, lost+found, tmp).
  • Runs du --inodes -x --max-depth=N once per account home, under nice and ionice -c3.
  • Prints a summary table sorted by inode count with OK, WARN or CRIT per account. Only a clean du run with a total line can give one of those three.
  • Marks an account INCOMPLETE when du fails 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 as 12345+, and the error is printed to stderr. An account with no total from du is 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 --path on folders you can read. Without --path, a normal user usually gets INCOMPLETE accounts and exit code 3, because du reports 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

OptionDefaultWhat it does
--top N10Directories listed per account
--depth N2How many levels below the home to rank (1-10)
--warn N200000Account total that triggers WARN
--crit N400000Account total that triggers CRIT (must be higher than –warn)
--user NAMEallOnly this account (repeatable)
--path DIRautoTreat DIR as one account (repeatable; skips panel detection)
--summaryoffOnly the per-account totals table
--csvoffCSV: 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-niceoffRun 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

  1. Validates options (numbers only, --warn below --crit) and checks that du supports --inodes.
  2. Builds the account list from the panel’s user folders and resolves each home directory with getent passwd.
  3. Runs one du --inodes -x per account and records its exit status and error output; -x keeps it on one filesystem, so bind mounts and other disks are not counted.
  4. 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 du error or non-zero exit means INCOMPLETE.
  5. 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 --user or --summary for quick checks.
  • Counts are filesystem inodes as du sees them; hard-linked files count once. Panel quota tools may count slightly differently.
  • Accounts whose home is on another filesystem than /home are still counted, but nested mounts inside a home are skipped because of -x.
  • It does not read panel inode quotas; set --warn and --crit to match your plans.
  • An INCOMPLETE account stays INCOMPLETE, not WARN or CRIT, even when its partial count is already above --crit. Read the N+ 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 du run 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

inode-usage-report.shDownload
#!/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"
Version 1.1.0 · SHA-256 8178f4529199aa9b79019f23ff7afd96a15a70373df3bff3c1810ac85a774562
Download and verify on Linux or macOS
curl -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 -c
Download and verify in Windows PowerShell
Invoke-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.

Changelog

  • 1.1.0 — If du fails or reports errors, the account is shown as INCOMPLETE (with the partial count, e.g. 9+) or UNKNOWN instead of a clean 0/OK, and the script exits 3. Empty and unknown accounts now also get a CSV row. Found in an external review (PROD7-05).
  • 1.0.0 — First release.

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.