The MQTT publisher now tracks connection state, publish counters, discovery and availability timestamps, and the last error, exposed at /api/mqtt and on the dashboard as an MQTT card and a Home Assistant section. Connection failures, disconnects, and dropped publishes are logged; paho's on_connect_fail callback was not registered before, so failed attempts were silent. MQTT connect, disconnect, and unreachable events go to the timeline. When a connection attempt fails the service runs a probe from inside the container: system resolver, each nameserver from resolv.conf queried directly, /etc/hosts, and a TCP connect to every address found. The result is shown as a summary and raw JSON on the dashboard and can be rerun via POST /api/mqtt/probe. The image base moves from Alpine to Debian slim. musl queries all nameservers in parallel and accepts the first reply, so a fast public NXDOMAIN beats a slower local server that knows the name. glibc asks the nameservers in order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
healthcheck-container
Small Flask service for remote devices (Raspberry Pi in Docker) that answers
/alive for container monitoring and, on top of that, watches the network the
device sits on: internet reachability, DNS, public IP changes, Wi-Fi link
quality, Wi-Fi networks in range, and host reboots. Metrics go to SQLite and
are shown on a dashboard at /. Optionally the same values are published to an
MQTT broker with Home Assistant discovery, so each device shows up in Home
Assistant as a device with sensors and no manual configuration.
Endpoints
| Path | Purpose |
|---|---|
/alive |
Unchanged health check, returns {"alive": true} |
/ |
Dashboard: status, Wi-Fi, graphs, timeline |
/api/status |
Latest state of every monitor as JSON |
/api/wifi |
Interface list, link details, last scan results |
/api/mqtt |
MQTT connection state, counters, last error, last published payload, last probe |
POST /api/mqtt/probe |
Run the broker connection probe now and return its result |
/api/events?limit=200 |
Timeline events, newest first |
/api/samples?prefix=reach.&range=3600 |
Bucketed samples for graphs |
Running
The compose file pulls the published multi-arch image
hiimmilan/health-check (amd64, arm64, arm/v7):
docker compose pull && docker compose up -d
Then open http://<device>:9999/.
Wi-Fi needs two things from Docker:
network_mode: host, otherwise the container has no wireless interface at all.cap_add: NET_ADMIN, otherwise link and interface info work but scanning fails with a permission error that is shown on the dashboard.
Without WIFI_INTERFACE the dashboard lists the interfaces it can see and
nothing is scanned. Set the variable to one of the listed wireless interfaces
and restart the container.
Storage
Samples and events are stored in SQLite at DB_PATH (default
/data/healthcheck.db). The compose file mounts a named volume at /data, so
the database survives docker compose down, image rebuilds and up -d. Only
docker compose down -v or docker volume rm deletes it. A bind mount works
as well, for example - /opt/healthcheck:/data.
Samples older than SAMPLE_RETENTION_DAYS (default 7) are deleted hourly.
Events are kept. Wi-Fi scan results are held in memory only and never written.
Configuration
All settings are environment variables. Intervals are in seconds.
| Variable | Default | Meaning |
|---|---|---|
PORT |
9999 |
HTTP port |
DB_PATH |
/data/healthcheck.db |
SQLite file |
SAMPLE_RETENTION_DAYS |
7 |
How long graph samples are kept |
DEVICE_NAME |
hostname | Shown on the dashboard and in Home Assistant |
DEVICE_ID |
slug of DEVICE_NAME |
Used in MQTT topics and unique ids |
REACH_TARGETS |
1.1.1.1:443,8.8.8.8:443,9.9.9.9:443 |
TCP connect targets, host:port |
REACH_INTERVAL |
30 |
|
REACH_TIMEOUT |
3 |
Connect timeout per target |
DNS_NAME |
cloudflare.com |
Name resolved through the system resolver |
DNS_INTERVAL |
60 |
|
PUBLIC_IP_URLS |
ipify, ifconfig.me, icanhazip | Tried in order, first answer wins |
PUBLIC_IP_INTERVAL |
300 |
|
PUBLIC_IP_TIMEOUT |
5 |
|
UPTIME_INTERVAL |
60 |
Reboot detection via /proc/uptime |
WIFI_INTERFACE |
empty | Wireless interface to use, empty disables Wi-Fi |
WIFI_LINK_INTERVAL |
30 |
Signal, bitrate, retries of the current link |
WIFI_SCAN_INTERVAL |
60 |
Scan for networks in range |
MQTT_HOST |
empty | Broker host, empty disables MQTT |
MQTT_PORT |
1883 |
|
MQTT_USERNAME |
empty | |
MQTT_PASSWORD |
empty | |
MQTT_DISCOVERY_PREFIX |
homeassistant |
Must match the MQTT integration setting |
LOG_LEVEL |
INFO |
Events on the timeline
- Internet unreachable / reachable again (all targets failed, then any succeeded)
- DNS resolution failed / working again
- Public IP is X, Public IP changed from X to Y
- Wi-Fi connected, disconnected, roamed to another BSSID
- Host rebooted
- Health check service started
- MQTT connected, disconnected, cannot reach broker
Home Assistant
Set MQTT_HOST (and credentials if the broker needs them). On connect the
service publishes retained discovery messages under
homeassistant/<component>/healthcheck_<device_id>/<key>/config, all pointing
to one device, then publishes state to healthcheck/<device_id>/state and
availability to healthcheck/<device_id>/availability. When Home Assistant
restarts it announces itself on homeassistant/status and the service
re-publishes discovery.
The dashboard has an MQTT card and a Home Assistant section showing the
connection state, when it last connected or failed, how many messages were
published or dropped, when discovery and availability were last sent, and the
last state payload. The same data is at /api/mqtt. Use it to tell an app-side
problem (not connected, publishes dropped) from a Home Assistant-side one
(connected and publishing, but the device is still unavailable).
When a connection attempt fails the service runs a probe from inside the
container, at most every two minutes: it resolves the broker name with the
system resolver, queries each nameserver from /etc/resolv.conf directly,
checks /etc/hosts, and tries a TCP connect to every address it found. The
result is shown as a summary and as raw JSON in the Home Assistant section, and
can be rerun from the dashboard. If the nameservers disagree about the broker
name, for example a local server that knows it and a public one that does not,
the probe says so. The image is Debian based, so glibc asks the nameservers in
resolv.conf order and only moves on when one does not answer.
Entities per device:
- Internet, DNS, Wi-Fi (binary sensors,
connectivityclass) - Internet latency, DNS latency (ms)
- Public IP
- Host uptime
- Wi-Fi SSID, BSSID, channel, signal (dBm), TX bitrate (Mbit/s), networks in range
Development
python3 -m unittest discover -s tests
DB_PATH=./data/hc.db python3 app.py
Publish a new image for all Raspberry Pi architectures:
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
-t hiimmilan/health-check:latest -t hiimmilan/health-check:2.3.0 --push .