Writing a Custom Nagios Plugin
Introduction¶
Nagios (and Icinga, Naemon, Shinken, and check_mk's classic mode) run plugins — small executables that check one thing and report status via an exit code and a line of text. Anything that follows the convention works: bash, Python, Go, a compiled binary.
The contract¶
Exit code = status:
| Code | Status | Meaning |
|---|---|---|
| 0 | OK | all good |
| 1 | WARNING | degraded, look soon |
| 2 | CRITICAL | broken, act now |
| 3 | UNKNOWN | the check itself failed (can't connect, bad args) |
stdout = the message, first line shown in the UI and notifications:
OK - queue depth 42 | queue=42;100;500;0;
Everything after the | is performance data for graphing:
label=value[UOM];warn;crit;min;max. Multiple space-separated metrics allowed.
Keep the text short, lead with the status word, put the number that matters in it.
Bash example: check a queue depth over HTTP¶
#!/usr/bin/env bash
set -euo pipefail
WARN=100
CRIT=500
URL="http://localhost:8080/metrics/queue"
TIMEOUT=10
usage() { echo "usage: $0 -w WARN -c CRIT -u URL"; exit 3; }
while getopts "w:c:u:t:h" opt; do
case "$opt" in
w) WARN=$OPTARG ;;
c) CRIT=$OPTARG ;;
u) URL=$OPTARG ;;
t) TIMEOUT=$OPTARG ;;
*) usage ;;
esac
done
depth=$(curl -fsS --max-time "$TIMEOUT" "$URL" 2>/dev/null) || {
echo "UNKNOWN - could not fetch $URL"
exit 3
}
if ! [[ "$depth" =~ ^[0-9]+$ ]]; then
echo "UNKNOWN - unexpected response: $depth"
exit 3
fi
perf="queue=${depth};${WARN};${CRIT};0;"
if (( depth >= CRIT )); then echo "CRITICAL - queue depth ${depth} | ${perf}"; exit 2
elif (( depth >= WARN )); then echo "WARNING - queue depth ${depth} | ${perf}"; exit 1
else echo "OK - queue depth ${depth} | ${perf}"; exit 0
fi
Python example: check certificate expiry¶
#!/usr/bin/env python3
import argparse, socket, ssl, sys
from datetime import datetime, timezone
OK, WARNING, CRITICAL, UNKNOWN = 0, 1, 2, 3
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("-H", "--host", required=True)
p.add_argument("-p", "--port", type=int, default=443)
p.add_argument("-w", "--warn", type=int, default=21, help="days")
p.add_argument("-c", "--crit", type=int, default=7, help="days")
p.add_argument("-t", "--timeout", type=float, default=10.0)
a = p.parse_args()
ctx = ssl.create_default_context()
try:
with socket.create_connection((a.host, a.port), timeout=a.timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=a.host) as ss:
not_after = ss.getpeercert()["notAfter"]
except Exception as e: # noqa: BLE001 - report anything as UNKNOWN
print(f"UNKNOWN - {a.host}:{a.port} - {e}")
return UNKNOWN
expiry = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
days = (expiry - datetime.now(timezone.utc)).days
perf = f"days={days};{a.warn};{a.crit};;"
if days <= a.crit:
print(f"CRITICAL - cert for {a.host} expires in {days}d ({not_after}) | {perf}")
return CRITICAL
if days <= a.warn:
print(f"WARNING - cert for {a.host} expires in {days}d | {perf}")
return WARNING
print(f"OK - cert for {a.host} valid {days}d | {perf}")
return OK
if __name__ == "__main__":
sys.exit(main())
Registering the check¶
# commands.cfg
define command {
command_name check_queue_depth
command_line $USER1$/check_queue_depth.sh -u $ARG1$ -w $ARG2$ -c $ARG3$
}
# service definition
define service {
use generic-service
host_name app01
service_description Job queue depth
check_command check_queue_depth!http://localhost:8080/metrics/queue!100!500
}
Drop the script in $USER1$ (usually /usr/lib/nagios/plugins/), chmod +x,
reload Nagios.
Verification and troubleshooting¶
# Run it exactly as Nagios would, as the nagios user
sudo -u nagios /usr/lib/nagios/plugins/check_queue_depth.sh -u http://localhost:8080/metrics/queue -w 100 -c 500
echo $? # 0/1/2/3
# Nagios-side
nagios -v /etc/nagios/nagios.cfg # config check before reload
- Check works by hand, UNKNOWN in Nagios — the
nagiosuser has a different PATH/env, no network policy exception, or can't read a cert/socket. Always test withsudo -u nagios. - Plugin hangs → Nagios kills it (CRITICAL "timed out") — no internal
timeout. Always pass
--max-time/timeoutto network calls; Nagios's ownservice_check_timeoutis the backstop. - Perfdata not graphing — format error. Exactly
label=value;warn;crit;min;maxafter a single|, value must have a unit or be bare number, no spaces around=. - Flapping — thresholds too close to normal operating range, or a noisy
metric. Widen the band, add
check_interval/retry_interval+max_check_attemptsso a transient blip needs N consecutive fails. - Exit 127 / "command not found" — wrong interpreter path, CRLF line endings
(
file script.sh), or not executable.
Related tools and reading¶
- On-site: SSL Certificate Checker, Port Checker.
- Related posts: Nagios active vs passive checks, Nagios notifications and escalations.
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.More in Monitoring
Prometheus Cardinality Management
Every unique label combination is a time series, and Prometheus holds them all in memory. A label with a request ID in it can OOM your Prometheus — here's how to find and fix it.
August 31, 2026Nagios Notifications and Escalations
Nagios notification logic has a lot of moving parts — states, intervals, time periods, escalations, dependencies. Here's how they combine to page the right person once.
August 28, 2026