Monitoring
The monitoring stack runs on the dedicated monitoring server — a repurposed Lenovo laptop running headless with the lid closed. Sleep is disabled via systemd-logind so it stays on regardless of lid state:
This is deployed by Ansible when disable_sleep: true is set in the host's variables. It covers metrics, logs, alerting, and endpoint availability across the entire homelab.
| Service | Purpose |
|---|---|
| Prometheus | Metrics collection and storage |
| Grafana | Dashboards and visualization |
| Alertmanager | Alert routing and notifications |
| Loki | Log aggregation |
| Grafana Alloy | Log collection from systemd journal |
| cAdvisor | Container resource metrics |
| Node Exporter | Host system metrics |
| PVE Exporter | Proxmox VE metrics |
| SNMP Exporter | Synology DS920+ metrics |
| Blackbox Exporter | HTTP, ICMP, and TCP endpoint probing |
| Pushgateway | Metrics from batch jobs (e.g. backups) |
All services run as Podman containers on a shared internal network, provisioned with Ansible. Containers are defined as Quadlets under /etc/containers/systemd/. Podman runs rootful, but each service is pinned to its own unprivileged UID (Prometheus and Alertmanager as 65534, Grafana as 472, Loki as 10001, Alloy as 473) with capabilities dropped and a read-only root filesystem where possible — so a container escape lands as a nobody-class UID, not as root. cadvisor stays root, because --privileged host-level access is its entire purpose. node-exporter was assumed to need root for the same reason until it was actually measured; what that turned up is described on the uCore page. Why all of this was chosen over a rootless runtime is explained under Architecture decisions.
The full stack
Two collection paths — metrics and logs — converge on the monitoring server, where Grafana reads both and Alertmanager fans out anything that breaches a rule:
graph LR
subgraph sources[Sources across the homelab]
ne[Node Exporter<br/>hosts + OPNsense]
cad[cAdvisor<br/>monitoring + kontti]
pve[PVE Exporter]
snmp[SNMP Exporter<br/>Synology DS920+]
bb[Blackbox Exporter<br/>HTTP / ICMP / TCP]
pg[Pushgateway<br/>backup jobs]
alloy[Grafana Alloy<br/>monitoring, kontti, HA]
end
subgraph mon[monitoring server]
prom[Prometheus]
loki[Loki]
graf[Grafana]
am[Alertmanager]
end
ne & cad & pve & snmp & bb & pg --> prom
alloy -->|logs| loki
alloy -->|metrics| prom
prom --> graf
loki --> graf
prom -->|alert rules| am
am -->|Telegram| tg[Phone] Metrics
Prometheus scrapes metrics every 15 seconds from targets across the homelab:
| Job | Source | What it covers |
|---|---|---|
node | Node Exporter on each host | CPU, memory, disk, network |
opnsense | Node Exporter on OPNsense | Router CPU, memory, network |
cadvisor | cAdvisor on monitoring & kontti | Per-container resource usage |
pve | PVE Exporter | Proxmox VMs, storage, cluster |
snmp | SNMP Exporter | Synology DS920+ health and storage |
blackbox | Blackbox Exporter | HTTP/TCP availability of internal services |
blackbox_icmp | Blackbox Exporter | ICMP ping to servers |
blackbox_ai | Blackbox Exporter on kontti | HTTP availability of Ollama and Qdrant inside ai.network |
blackbox_slow | Blackbox Exporter | One expensive probe, scraped every 2 min instead of 15 s |
pushgateway | Pushgateway | Backup job results and durations |
alertmanager | Alertmanager itself | Alertmanager health |
blackbox_ai is a second, deliberately minimal Blackbox Exporter running on kontti. When Ollama and Qdrant were taken off the LAN, the probes running from this host lost their route to them and began alerting on healthy services — no relabelling rule can cross a network boundary. Running a probe inside that network restores the coverage without republishing either backend; it exposes only its own probe_* metrics. Dropping the probes instead would have left both services covered by container-level rules alone, and "container up, application not serving" is exactly what those rules cannot see.
Designing the probes
A blackbox probe is a request you make of your own service on every scrape, and it is easy to forget that this is real load.
One service's /health endpoint launched a full headless browser per request and fetched an external page through it. At the global 15-second scrape interval that worked out to roughly 5760 browser launches a day from monitoring alone — about 20× the service's actual traffic, and ultimately what filled its PID table. It now has its own blackbox_slow job at a two-minute interval.
Two minutes is deliberate rather than round: five would sit exactly on Prometheus's five-minute staleness window, while two still gives a for: 5m rule three samples to work with, putting detection at around seven minutes.
The probed endpoint changed too, because /health was not a check that service could reliably pass — it hardcoded an external URL and waited for the browser to reach networkidle, which that page never quite does. Over six hours, 13 of 62 samples failed, about 21%, on a service that was working perfectly throughout. The probe now exercises the real endpoint against a page with no subresources: six runs out of six between 1.50 and 1.63 seconds. Two regular expressions assert both the wrapper's status field and the fetched page's status code, so a browser that returns an error page still fails the probe rather than passing it.
Three diagnostics from that work generalise:
- Blackbox uses the smaller of its module timeout and Prometheus's
scrape_timeout. This silently defeated the first attempt at the fix: the job carried a 30-secondscrape_timeout, but the module's own five-second timeout won, so every "failure" was blackbox cutting the probe off early. Keep the job's timeout just above the module's, and change them together. - Read
probe_duration_secondsbefore blaming the service. Every failed sample was exactly 5.00 s while successes sat at 2.9–3.1 s. A constant duration on a round number is a timeout; a genuinely broken backend produces scattered durations or a non-2xx status. - Moving a target between scrape jobs breaks every rule keyed to the old
joblabel — silently. After the move, a query against the old job kept returning data for about five minutes. That is staleness residue, not configuration. Confirm against/api/v1/targets, which lists only live targets, and prove the new expression really selects the target by inverting it:== 1should return it,== 0should return nothing. Same principle as the deadman rules — an expression that matches nothing looks exactly as green as one that works.
Alert rules
Prometheus evaluates alert rules every 15 seconds. Rules are organized by category:
node.rules.yml— CPU, memory and disk thresholds, plusNodeDownandExporterDowncontainer.rules.yml— container-down and crash-loop detectionsystemd.rules.yml— failed systemd units (SystemdUnitFailed)probe.rules.yml— endpoint availability failures, plusSSLCertExpiringSoon, which warns 14 days ahead and reads the expiry from the certificate served on the wire rather than from a file on disk — so it catches a renewal that succeeded but never got loadedproxmox.rules.yml— Proxmox VM and storage alertsbackup.rules.yml— missed or failed backups, and restore-test results, via Pushgatewaysynology.rules.yml— NAS health and disk statuslogging.rules.yml— log shipping stopped or entries droppedpodman.rules.yml— deadman switch forpodman-auto-updateoneshot.rules.yml— deadman switch for scheduled one-shot jobs
An excerpt from node.rules.yml shows the shape of a rule — the thresholds, the for duration that debounces flapping, and the templated annotations that land in the Telegram message:
groups:
- name: node
rules:
- alert: NodeDown
expr: up{job=~"node|opnsense|proxmox|kontti"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Node unreachable"
description: "Node Exporter on {{ $labels.instance }} has been unreachable for 2 minutes."
- alert: DiskSpaceLow
expr: >
(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs",mountpoint=~"/|/sysroot|/var"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs",mountpoint=~"/|/sysroot|/var"}) * 100 < 20
for: 5m
labels:
severity: warning
annotations:
summary: "Disk space low"
description: "{{ $labels.instance }} mountpoint {{ $labels.mountpoint }} has {{ $value | printf \"%.1f\" }}% free."
# NAS uses absolute thresholds — on a 47 TB volume, percentages are too coarse
- alert: NasDiskSpaceLow
expr: node_filesystem_avail_bytes{mountpoint="/var/mnt/nfs-data"} < 2199023255552
for: 5m
labels:
severity: warning
annotations:
summary: "NAS disk space low"
description: "{{ $labels.instance }} NAS has {{ $value | humanize1024 }}B free (under 2 TB)."
- alert: HighCPULoad
expr: >
(1 - avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]))) * 100 > 85
for: 10m
labels:
severity: warning
annotations:
summary: "High CPU load"
description: "{{ $labels.instance }} CPU usage is {{ $value | printf \"%.1f\" }}% (over 85% for 10 min)."
Closing two blind spots
Two rules exist specifically because a real outage slipped through the gaps the obvious alerts leave — the kind of "why" this site tries to capture rather than just listing what's configured:
ExporterDown(up{job=~"pve|snmp|cadvisor|pushgateway|blackbox|blackbox_ai|alertmanager|alloy"} == 0) — an exporter being down can't be caught by a rule written over the metrics it produces, because the metric is then absent rather than zero. The Proxmox VE exporter was down for three and a half days this way after an auto-update changed its image user and left its config file unreadable;ProxmoxNodeDown(pve_up == 0) never fired becausepve_uphad simply vanished from the time series.ExporterDownworks because Prometheus synthesisesupfor every scrape target whether or not the target answers — so the signal doesn't depend on the thing being monitored still functioning. Every exporter added since is added to this list too, which is whyblackbox_aiappears in it.ContainerRestarting(max by (instance, name) (changes(container_start_time_seconds{name!=""}[15m])) > 5) — a crash-looping container is invisible toContainerDown(itscontainer_last_seenkeeps advancing as the container is recreated every few seconds) and toSystemdUnitFailed(Restart=alwaysnever lets the unit settle intofailedfor the 15 minutes that rule requires). This rule counts restarts instead. The threshold isn't a guess: checked against a real crash-loop day over a three-hour window, it picked out exactly three containers — one at 364 restarts, one at 138, one at 9 — while ordinary deploy restarts stayed at one to three. Five sits in the empty gap between the two populations.
SystemdUnitFailed itself is built on Node Exporter's systemd collector rather than on container metrics, so it catches stopped or removed services that the container-level rules never see.
When the monitoring itself creates the fault
ContainerDown and ContainerRestarting are both aggregated with max by (instance, name) rather than evaluated per series, and the reason is a false positive worth recording.
cAdvisor reports several cgroups per container. The relabelling that derives a name label from the cgroup path once matched all of them — including systemd's transient /.control cgroup, which appears during a restart and then disappears. Because it disappears, its container_last_seen freezes, and a per-series rule reads that frozen timestamp as a container that has stopped being seen. On 2026-08-01 an ordinary --tags service deploy produced two immediate critical alerts, for pve-exporter and snmp-exporter, while podman ps showed both as Up (healthy).
The relabel regex has since been tightened so only the base /system.slice/<unit>.service cgroup gets a name, which removes the cause. The aggregation was deliberately kept anyway: it costs nothing, it covers the case where systemd or Podman introduces some new sub-cgroup later, and it cannot mask a genuine failure — if a container really dies, all of its series go stale, so the maximum goes stale with them.
The general point is one worth carrying: an alert firing on a healthy system is a defect in the same way a missed outage is. Both teach you that the rule was measuring something other than what you meant.
Logs
Grafana Alloy runs on monitoring, kontti, and the Home Assistant host, collecting logs from each host's systemd journal and forwarding them to Loki on the monitoring server. The agent on kontti is deployed by the kontti_monitoring role alongside cAdvisor and Node Exporter, so the application server's logs and container metrics make it into Grafana even though the storage backends live elsewhere. The Home Assistant host runs its own Alloy agent that ships both its journal logs to Loki and system metrics to Prometheus via remote write, filtered down to the Home Assistant unit. Noisy but harmless log lines are filtered out before reaching Loki to keep the log volume manageable, and logs are tagged with host, systemd unit, and container name for easy filtering.
The log-shipping blind spot
Log collection has its own version of the gap described above, and logging.rules.yml exists to close it. An Alloy agent that is running but shipping nothing is invisible: ContainerDown and ContainerRestarting catch the container dying or crash-looping, and ExporterDown catches the process disappearing, but "up and doing nothing" matches none of them. The logs would simply stop arriving in Grafana — and because an empty log view looks exactly like a quiet system, that goes unnoticed until the logs are actually needed, which is precisely during an incident.
LogShippingStopped(increase(loki_write_sent_entries_total[1h]) == 0) — the window is an hour rather than thirty minutes so a genuinely quiet night can't trip it; combined withfor: 30m, logs have to be completely stalled for an hour and a half before it fires.LogEntriesDropped(increase(loki_write_dropped_entries_total[1h]) > 0) — a different failure mode entirely. Rate limiting, an over-long line or a full queue drop lines without stopping the flow, so the first rule never sees them, but log data is being lost all the same.
Unlike the backup and auto-update deadman switches, these need no absence guard of their own: they're scraped metrics rather than pushed ones, so a metric that vanishes means Alloy isn't answering — which is ExporterDown's job.
Alertmanager
Alertmanager receives alerts from Prometheus and handles deduplication, grouping, and routing. Notifications are sent to Telegram.
The metrics-to-notification path looks like this:
graph LR
ne[Node Exporter] --> prom[Prometheus]
cad[cAdvisor] --> prom
bb[Blackbox Exporter] --> prom
prom --> graf[Grafana]
prom -->|alert rules| am[Alertmanager]
am -->|Telegram| tg[Phone] Alert policy
There's nobody else to notify, so the routing is tuned to stay useful without being noisy. Alerts group by alertname, wait 30s to batch related firings, and repeat at most once an hour while a condition persists. Critically, a yö (night) mute interval silences Telegram notifications between 20:00 and 07:00 — alerts still fire and resolve in Alertmanager, but they don't wake me for anything short of an outage I'd notice anyway. How quickly an alert actually gets looked at depends on what it is and what time there is for it — the severity split exists to inform that judgement, not to promise a response time.
Grafana dashboards
Grafana runs the Enterprise edition image (used here without a paid licence, so it behaves as open-source Grafana). It is provisioned with a fixed set of dashboards so the visualisations are reproducible from Ansible rather than clicked together by hand. Community dashboards are downloaded at a pinned revision and patched (datasource, mountpoint, variables) to fit this environment; the rest are maintained locally as JSON.
| Dashboard | Source |
|---|---|
| Node Exporter Full | grafana.com/dashboards/1860 (rev 45) |
| OPNsense | grafana.com/dashboards/19366 (rev 7) |
| cAdvisor | grafana.com/dashboards/14282 (rev 1) |
| Loki | grafana.com/dashboards/13639 (rev 2) |
| Blackbox Exporter | grafana.com/dashboards/7587 (rev 3) |
| Error Logs | Local JSON |
| Synology NAS Details | Local JSON |
| Proxmox via Prometheus | Local JSON |
My experience
I previously used Zabbix for monitoring and wanted to move to more modern tooling. Prometheus, Alertmanager and Loki are common in the Kubernetes world, and Loki is significantly lighter than the Elastic stack. I used AI to help set up the stack.
Alerts have been useful once I got them tuned to my liking — the initial configuration took some iteration. The Grafana dashboards were more of a learning project; I rarely look at them in practice because the environment is stable enough that troubleshooting is infrequent. Log collection has been similar — mostly a learning exercise so far, but it's convenient to have metrics and logs in the same system if I ever need to dig into an issue.
It's worth being honest about how most of the gaps on this page were found: not by the monitoring itself. The exporter that had been down for three and a half days, the deadman rule that could never fire, the services that turned out to have no probe at all — those came out of review sessions where I go through the configuration with an LLM and ask what doesn't hold up. The monitoring catches what it was built to catch. A review catches what I didn't think to build a rule for, and those are different problems.