Broker Testing

MQTT Broker Health Check: Scripts for Monitoring Uptime

Health-check scripts in Bash, Python and Node.js that connect, publish and receive a message, with exit codes for cron, Nagios and Kubernetes probes.

Updated · 9 min read

MQTT Broker Health Check: Scripts for Monitoring Uptime

An MQTT broker health check should prove the one thing your devices depend on: a client can connect, subscribe, publish and receive its own message within a few seconds. Anything less — a ping, an open port, a running process — can stay green while the broker silently refuses logins or stops routing.

Below are three ready-to-use scripts (Bash, Python, Node.js) that perform that round trip and return monitoring-friendly exit codes, plus examples for cron, Kubernetes probes and alerting.

What a health check must verify

CheckCatchesMisses
TCP port open (nc -z)Host down, firewall, listener not boundAuth failures, broken routing, full disks, TLS errors
Process runningCrashed brokerEverything that happens while it is "up" but unhealthy
CONNECT onlyCredentials, TLS, protocol version, connection limitsSubscribe ACLs, message routing
Connect + subscribe + publish + receiveAll of the above, plus routing and delivery latencyProblems on other listeners or other topics

The last row is the one to automate. Each run should use a unique client ID (two checks with the same ID kick each other off — see MQTT client ID best practices) and a unique topic, so a delayed message from a previous run can never make the current run look healthy.

If you want a broader one-off diagnosis rather than a recurring probe, start with how to check MQTT broker status or poke the broker interactively in the online MQTT client.

Exit codes that monitoring tools understand

All scripts in this article use the Nagios plugin convention, which Icinga, Zabbix, Checkmk, Sensu and most cron wrappers understand:

Exit codeStateMeaning in our scripts
0OKMessage round trip succeeded within the warning threshold
1WARNINGRound trip succeeded but was slower than the threshold
2CRITICALConnect, subscribe, publish or delivery failed or timed out
3UNKNOWNThe check itself could not run (missing tool, bad arguments)

Kubernetes, systemd and cron only distinguish zero from non-zero, so the same scripts work everywhere.

Bash: mosquitto_sub + mosquitto_pub

The quickest check uses the Mosquitto command-line clients (apt install mosquitto-clients or brew install mosquitto). A background subscriber waits for exactly one message with a timeout, and the publisher sends a random token to the same topic:

mqtt-health.sh
#!/usr/bin/env bash
# Round-trip health check. Exit: 0 OK, 2 CRITICAL, 3 UNKNOWN.
HOST="${MQTT_HOST:-broker.connectmq.com}"
PORT="${MQTT_PORT:-8883}"
TIMEOUT="${MQTT_TIMEOUT:-5}"
TOPIC="testmqtt/health/$(hostname)-$$"
TOKEN="ok-$(date +%s)-$RANDOM"

command -v mosquitto_pub >/dev/null || { echo "UNKNOWN - mosquitto clients not installed"; exit 3; }

OPTS=(-h "$HOST" -p "$PORT")
[ "$PORT" = "8883" ] && OPTS+=(--capath /etc/ssl/certs)
[ -n "$MQTT_USER" ] && OPTS+=(-u "$MQTT_USER" -P "$MQTT_PASS")

OUT=$(mktemp)
mosquitto_sub "${OPTS[@]}" -i "health-sub-$$" -t "$TOPIC" -q 1 -C 1 -W "$TIMEOUT" >"$OUT" 2>&1 &
SUB_PID=$!
sleep 1   # give the subscriber time to receive its SUBACK

if ! ERR=$(mosquitto_pub "${OPTS[@]}" -i "health-pub-$$" -t "$TOPIC" -q 1 -m "$TOKEN" 2>&1); then
  kill "$SUB_PID" 2>/dev/null
  echo "CRITICAL - publish failed: $ERR"; rm -f "$OUT"; exit 2
fi

wait "$SUB_PID"
RECEIVED=$(cat "$OUT"); rm -f "$OUT"

if [ "$RECEIVED" = "$TOKEN" ]; then
  echo "OK - round trip via $HOST:$PORT"; exit 0
fi
echo "CRITICAL - no message within ${TIMEOUT}s (${RECEIVED:-no output})"; exit 2

Key details:

  • -C 1 makes mosquitto_sub exit after one message; -W makes it give up after the timeout instead of hanging forever.
  • The script compares the payload to the token rather than trusting the exit status alone, so a stray message on the topic cannot produce a false OK.
  • Error text from the clients (for example Connection error: Connection Refused: not authorised.) ends up in the output line, which is exactly what you want to see in an alert. Look up any reason code in the MQTT reason code tool.
  • /etc/ssl/certs is the CA directory on Debian, Ubuntu and Alpine. On other systems pass --cafile with your CA bundle.

Python: round trip with latency threshold

The Python version (paho-mqtt 2.x) measures the round-trip time and returns WARNING when it is slower than a threshold. Every step has a deadline, so the script never blocks a monitoring agent:

mqtt_health.py
#!/usr/bin/env python3
"""MQTT round-trip health check. Exit: 0 OK, 1 WARNING, 2 CRITICAL."""
import os, sys, threading, time, uuid
import paho.mqtt.client as mqtt

HOST = os.getenv("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.getenv("MQTT_PORT", "8883"))
TIMEOUT = float(os.getenv("MQTT_TIMEOUT", "5"))
WARN_MS = float(os.getenv("MQTT_WARN_MS", "500"))

topic = f"testmqtt/health/{uuid.uuid4().hex[:12]}"
token = uuid.uuid4().hex
connected, subscribed, received = threading.Event(), threading.Event(), threading.Event()
state = {"connack": None, "suback": None, "t0": 0.0, "rtt": 0.0}


def on_connect(client, userdata, flags, reason_code, properties):
    state["connack"] = reason_code
    connected.set()
    if not reason_code.is_failure:
        client.subscribe(topic, qos=1)


def on_subscribe(client, userdata, mid, reason_codes, properties):
    state["suback"] = reason_codes[0]
    subscribed.set()
    if not reason_codes[0].is_failure:
        state["t0"] = time.perf_counter()
        client.publish(topic, token, qos=1)


def on_message(client, userdata, msg):
    if msg.payload.decode() == token:
        state["rtt"] = (time.perf_counter() - state["t0"]) * 1000
        received.set()


def finish(code, text):
    print(text)
    sys.exit(code)


client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"health-{uuid.uuid4().hex[:8]}")
client.on_connect, client.on_subscribe, client.on_message = on_connect, on_subscribe, on_message
if os.getenv("MQTT_USER"):
    client.username_pw_set(os.environ["MQTT_USER"], os.getenv("MQTT_PASS"))
if PORT == 8883:
    client.tls_set()  # system CA bundle

try:
    client.connect(HOST, PORT, keepalive=30)
except Exception as exc:  # DNS, refused, TLS errors, timeouts
    finish(2, f"CRITICAL - cannot connect to {HOST}:{PORT}: {exc}")

client.loop_start()
try:
    if not connected.wait(TIMEOUT):
        finish(2, "CRITICAL - no CONNACK")
    if state["connack"].is_failure:
        finish(2, f"CRITICAL - connection refused: {state['connack']}")
    if not subscribed.wait(TIMEOUT):
        finish(2, "CRITICAL - no SUBACK")
    if state["suback"].is_failure:
        finish(2, f"CRITICAL - subscribe refused: {state['suback']}")
    if not received.wait(TIMEOUT):
        finish(2, f"CRITICAL - message not delivered within {TIMEOUT:.0f}s")
    rtt = state["rtt"]
    level, code = ("WARNING", 1) if rtt > WARN_MS else ("OK", 0)
    finish(code, f"{level} - round trip {rtt:.0f} ms | rtt={rtt:.1f}ms;{WARN_MS:.0f}")
finally:
    client.disconnect()
    client.loop_stop()

The text after | is Nagios performance data, so tools that graph it will chart the round-trip time for free. Separating CONNACK, SUBACK and delivery failures matters at 3 a.m.: "subscribe refused" points at an ACL change, "no CONNACK" at an overloaded or half-dead broker, "not delivered" at routing or bridging. If subscriptions are refused unexpectedly, MQTT ACL denied silently explains the usual causes.

Node.js: MQTT.js 5

The same logic with MQTT.js 5 and its promise API. A single global timer guarantees the process exits even if a callback never fires:

mqtt-health.mjs
import mqtt from 'mqtt';
import { randomUUID } from 'node:crypto';

const url = process.env.MQTT_URL ?? 'mqtts://broker.connectmq.com:8883';
const timeoutMs = Number(process.env.MQTT_TIMEOUT_MS ?? 5000);
const warnMs = Number(process.env.MQTT_WARN_MS ?? 500);
const topic = `testmqtt/health/${randomUUID().slice(0, 12)}`;
const token = randomUUID();

function finish(code, text) {
  console.log(text);
  process.exit(code);
}
setTimeout(() => finish(2, `CRITICAL - no round trip within ${timeoutMs} ms`), timeoutMs);

const client = mqtt.connect(url, {
  clientId: `health-${randomUUID().slice(0, 8)}`,
  username: process.env.MQTT_USER,
  password: process.env.MQTT_PASS,
  connectTimeout: timeoutMs,
  reconnectPeriod: 0, // a health check must not retry silently
});

client.on('error', (err) => finish(2, `CRITICAL - ${err.message}`));

client.on('connect', async () => {
  try {
    const granted = await client.subscribeAsync(topic, { qos: 1 });
    if (!granted[0] || granted[0].qos > 2) finish(2, 'CRITICAL - subscribe refused');

    let t0 = 0;
    client.on('message', (_topic, payload) => {
      if (payload.toString() !== token) return;
      const ms = Math.round(performance.now() - t0);
      const [code, level] = ms > warnMs ? [1, 'WARNING'] : [0, 'OK'];
      client.end(true, () => finish(code, `${level} - round trip ${ms} ms | rtt=${ms}ms;${warnMs}`));
    });
    t0 = performance.now();
    await client.publishAsync(topic, token, { qos: 1 });
  } catch (err) {
    finish(2, `CRITICAL - ${err.message}`);
  }
});

Setting reconnectPeriod: 0 is important: by default MQTT.js reconnects forever, which is great for applications and terrible for a probe that should fail fast. Bad credentials surface as Connection refused: Not authorized or Connection refused: Bad username or password, depending on how the broker reports them.

Running the check on a schedule

Cron with alerting

crontab -e
# every minute; alert only on non-zero exit
* * * * * /opt/checks/mqtt-health.sh >/tmp/mqtt-health.log 2>&1 || /opt/checks/notify.sh "$(cat /tmp/mqtt-health.log)"

Most monitoring agents (Nagios, Icinga, Checkmk, Zabbix with external checks) can run any of the scripts directly as a plugin. Uptime services with an MQTT monitor type work too, but a script gives you control over topics, QoS and thresholds. For a full dashboard, combine the probe with broker metrics as described in MQTT uptime monitoring and Prometheus and Grafana monitoring.

Kubernetes probes

For a self-hosted Mosquitto pod, an exec probe that performs a local round trip is far more useful than a tcpSocket probe. The official eclipse-mosquitto image already contains the command-line clients:

mosquitto-deployment.yaml (excerpt)
containers:
  - name: mosquitto
    image: eclipse-mosquitto:2
    ports:
      - containerPort: 1883
    readinessProbe:
      tcpSocket:
        port: 1883
      periodSeconds: 5
    livenessProbe:
      exec:
        command:
          - sh
          - -c
          - >
            mosquitto_sub -h 127.0.0.1 -i k8s-probe-sub -t testmqtt/health/k8s -C 1 -W 5 &
            sleep 1;
            mosquitto_pub -h 127.0.0.1 -i k8s-probe-pub -t testmqtt/health/k8s -m ok;
            wait $!
      initialDelaySeconds: 10
      periodSeconds: 30
      timeoutSeconds: 8
      failureThreshold: 3

If your listener requires authentication, add -u/-P from a Secret mounted as environment variables, and allow the probe user in your ACLs. Keep timeoutSeconds larger than the script's own timeout, and set failureThreshold so a single slow check during a garbage-collection pause does not restart the broker. More deployment details are in Mosquitto on Kubernetes.

Monitoring a managed broker

With a managed broker you do not control the process, but you still own the client side of the contract: your hostname, your credentials, your ACLs and the network path from your devices. An external round-trip check catches expired credentials, accidental ACL changes and network problems on your side that the provider's own monitoring cannot see.

Testing the check itself

A health check that never fires is worse than none. Before you rely on it, force each failure mode once:

  1. Run it normally and confirm exit code 0: ./mqtt-health.sh; echo $?.
  2. Use a wrong password and confirm 2 with a "not authorised" message.
  3. Point it at a closed port (MQTT_PORT=1999) and confirm it fails within the timeout instead of hanging.
  4. Set MQTT_WARN_MS=1 on the Python or Node script and confirm the WARNING path returns 1.
  5. Watch testmqtt/health/# in the online client while the check runs to see the token arrive.

Watch health-check messages live

A good MQTT health check is small, fast and honest: one connect, one subscription, one message, a strict timeout and a clear exit code. Keep the script in your repository next to your broker configuration, run it from both inside and outside your network, and you will hear about broker problems before your devices do.

Frequently asked questions

Is checking that port 1883 is open a good MQTT health check?

No. An open TCP port only proves that something is listening. A broker can accept TCP connections and still reject every CONNECT, refuse subscriptions or stop routing messages. A real health check connects, subscribes, publishes and waits for its own message.

How often should an MQTT health check run?

Every 30 to 60 seconds is common for external monitoring, and every 10 to 30 seconds for Kubernetes probes. Use a unique client ID and topic per run, and keep the timeout shorter than the interval so checks never overlap.

Which exit codes should a health-check script return?

Follow the Nagios convention: 0 for OK, 1 for WARNING (for example a slow round trip), 2 for CRITICAL (connect, subscribe or delivery failed) and 3 for UNKNOWN (the check itself could not run). Cron, systemd and Kubernetes only care about zero versus non-zero.