The Monitoring Gap That Bites Self-Hosters
The alert that actually matters almost never fires. Instead, you find out a service is down because someone — or something — can’t reach it and tells you directly. Your “monitoring” turns out to be a Grafana dashboard you check when you remember to, and a vague sense that things are probably fine. That gap between assumption and reality is where self-hosted setups get hurt: a model server OOM-killed at 3am, a disk that filled up because log rotation wasn’t configured, a container that exited silently because a dependency socket disappeared. None of it paged you. You found out later.
What a single-node homelab or small self-hosted stack actually needs is different from what generic “monitoring” implies. You need process-level visibility — not just “is the host up” but “is ollama serve still running, and is it responding to inference requests.” You need container metrics that show per-container CPU and memory churn, not just host-level aggregates. If you’re running inference workloads, GPU and VRAM tracking is non-negotiable: a model that’s been evicted from VRAM and is quietly swapping to CPU will still respond, just 10x slower, and nothing in basic monitoring will catch that unless you’re watching nvidia-smi output or a metric derived from it. And you need alerting that fires before things break — disk at 85%, swap climbing, a container restart loop starting — not after the service is already gone.
Enterprise tools like Datadog and New Relic are designed around different assumptions entirely. Their agents are built for fleets — they expect many nodes, centralized collection infrastructure, and enough traffic to justify per-host or per-metric pricing. On a single 32GB workstation running Ollama, n8n, a Postgres instance, and a TypeScript publishing engine, the Datadog agent alone can consume a noticeable chunk of the RAM budget you were saving for a second model context. More critically, these platforms assume cloud connectivity as a baseline — their alerting pipelines, dashboards, and integrations all phone home. A local-first stack running airgapped or behind a NAT with no public endpoint doesn’t fit the model they’re optimized for. You end up paying for infrastructure assumptions that actively work against your setup.
Nagios + Grafana on Self-Hosted Hardware: A Real Monitoring Stack Without the Cloud Tax
The practical alternative is composing purpose-built open source tools: a lightweight metrics collector that exposes data locally, a time-series store you control, and alerting logic that sends to wherever you actually look — a Telegram bot, a webhook into your n8n flow, an email. The ecosystem for this has matured enough that you can get genuine process-level, container-level, and GPU-level observability running in under an hour, with no external dependencies and no per-host licensing math to do. If you are evaluating tooling across your stack, the guide on AI Coding Tools in 2026: Cloud Copilots vs Local Models covers the broader picture of what is worth running locally vs. delegating to a cloud API — the same decision framework applies here.
Tool 1: Prometheus + Node Exporter — The Baseline You Build Everything On
Most monitoring stacks get complicated fast, but Prometheus earns its place as a foundation because the mental model never changes: it polls HTTP endpoints that expose metrics in a plain-text format, timestamps them, and writes them into a local time-series database. No agent protocol to debug, no proprietary wire format, no daemon handshake. If curl http://localhost:9100/metrics returns data, Prometheus can scrape it. That simplicity is what makes it composable — Grafana, Alertmanager, and a dozen exporters all plug in without ceremony.
Getting it running takes maybe fifteen minutes. The compose file below is close to what I actually run, minus the Grafana container. The key decisions are bind-mounting your config (so you can edit it without rebuilding) and putting both containers on the same network so Prometheus can reach Node Exporter by service name:
# docker-compose.yml
services:
prometheus:
image: prom/prometheus:v2.52.0
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.retention.time=30d'
ports:
- "9090:9090"
networks:
- monitoring
node_exporter:
image: prom/node-exporter:v1.8.0
pid: "host" # needed for accurate process metrics
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--path.rootfs=/rootfs'
ports:
- "9100:9100"
networks:
- monitoring
volumes:
prometheus_data:
networks:
monitoring:
# prometheus.yml
global:
scrape_interval: 5s # override the 15s default — see note below
evaluation_interval: 5s
scrape_configs:
- job_name: 'node'
static_configs:
- targets: ['node_exporter:9100']
labels:
host: 'my-workstation' # shows up in every metric — label it now
The default 15-second scrape interval is the first thing worth changing. A process that allocates 20 GB of RAM and gets OOM-killed in under 10 seconds will leave zero evidence in your Prometheus data — the spike simply falls between samples. Dropping to 5 seconds on a single node adds negligible load; Node Exporter’s /metrics endpoint is a cheap read from procfs, and Prometheus’s own CPU overhead at that cadence on one target is unmeasurable against anything else running on the box. The tradeoff only becomes real when you’re scraping dozens of targets or using expensive custom collectors.
Grafana + Prometheus on a Raspberry Pi: Build a Telemetry Dashboard That Actually Stays Up
Resource costs are lower than most people expect. Prometheus itself runs comfortably under 400 MB RSS on a single-node setup with 30-day retention and the 5-second interval. The disk math is straightforward: Prometheus compresses time-series data well, and at default Node Exporter cardinality you should expect roughly 1–2 GB per monitored host per 30 days. The number grows if you add high-cardinality exporters (cAdvisor with many containers, for instance), but for bare-metal hardware metrics alone, even a modest SSD partition handles years of retention. Run du -sh /var/lib/docker/volumes/prometheus_data after a week to calibrate your own rate before committing to a retention window.
Tool 2: Grafana — Dashboards Worth Waking Up For
Grafana as the Visualization Layer on Top of Prometheus
Grafana does not collect metrics itself — it reads from Prometheus (or Loki, or InfluxDB, but Prometheus is the workhorse here) and renders them. That separation is actually the right design: you get best-in-class storage and querying from Prometheus, and best-in-class visualization from Grafana, without either trying to do the other’s job. The practical upside is that you can blow away your Grafana container entirely, redeploy it, reconnect it to the same Prometheus instance, and lose nothing except dashboards you forgot to back up.
Community Dashboards vs. Building Your Own
Start with dashboard ID 1860 — the “Node Exporter Full” dashboard available at grafana.com/dashboards. Import it via the UI (Dashboards → Import → enter 1860), point it at your Prometheus data source, and you immediately get CPU, memory, disk I/O, network throughput, and filesystem pressure across every host running node_exporter. That covers 80% of what you need for general server health without writing a single PromQL query. The remaining 20% — GPU VRAM on the Ollama box — requires building panels from scratch using metrics exposed by nvidia-smi through the NVIDIA DCGM exporter or the lighter nvidia_gpu_exporter. The metric you want is nvidia_smi_memory_used_bytes divided by nvidia_smi_memory_total_bytes, multiplied by 100, to get a clean VRAM utilization percentage. Wire that into a Gauge panel with thresholds at 75% (yellow) and 90% (red) and you have something actually useful for watching Ollama load a 13B model.
Docker Compose Addition
Drop this into the same docker-compose.yml that already runs your Prometheus container:
grafana:
image: grafana/grafana-oss:10.4.2
container_name: grafana
restart: unless-stopped
ports:
- "3000:3000"
environment:
# Set this BEFORE the container ever starts — Grafana writes the
# admin password into grafana.db on first boot and ignores this
# env var on subsequent starts unless you reset via CLI.
GF_SECURITY_ADMIN_PASSWORD: "changeme_before_first_run"
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- grafana_data:/var/lib/grafana # persists grafana.db, dashboards, alert rules
volumes:
grafana_data:
driver: local
The GF_SECURITY_ADMIN_PASSWORD timing matters more than the docs make clear. Grafana initializes grafana.db (a SQLite file inside the volume) on its very first startup, writing whatever password is set at that moment. If you start the container with the default password, log in, then try to change the env var and restart — nothing changes. The new value is silently ignored because the database already exists. The fix is to either set the password correctly before the first docker compose up, or run grafana-cli admin reset-admin-password inside the container after the fact. Either works; forgetting costs you time.
Alerting via Grafana Unified Alerting
Grafana v9 replaced the old alerting system with Unified Alerting, and it is meaningfully better — alert rules live in the database, contact points are first-class objects, and you can route different alert groups to different destinations without a separate Alertmanager process. For a Telegram notification when VRAM crosses 90% on the Ollama host, the setup is:
- Create a Contact Point of type Telegram. You need a bot token (from @BotFather) and your chat ID. Grafana will POST to the Telegram Bot API directly.
- Create an Alert Rule on the VRAM panel: PromQL expression
(nvidia_smi_memory_used_bytes / nvidia_smi_memory_total_bytes) * 100 > 90, evaluation interval 1m, pending period 2m (avoids false positives during model load spikes). - Assign that rule to a Notification Policy that routes to your Telegram contact point.
The webhook option works equally well if you want to fan out to multiple destinations or log alerts to n8n for further processing — set the contact point type to Webhook, point it at your n8n webhook trigger URL, and the full alert payload arrives as JSON you can route however you need.
OSS vs. Grafana Cloud
Grafana Cloud advertises a free tier and it is real — you get hosted Grafana, some Prometheus-compatible storage, and Loki ingestion up to defined limits. But for a self-contained single-workstation monitoring setup, the OSS version running locally has no meaningful missing features. Everything described above — community dashboards, custom panels, Unified Alerting, Telegram bots, webhook routing — ships in grafana/grafana-oss. Cloud starts making sense if you want metrics from machines that cannot expose a scrape endpoint to your Prometheus instance, or if you want Grafana-managed alerting SLA without running the container yourself. For a local homelab or a personal workstation running Ollama, the self-hosted OSS path is the simpler operational choice.
Tool 3: Netdata — When You Want Answers in 30 Seconds, Not 30 Minutes
Most monitoring stacks make you earn your dashboard. You provision Prometheus, write scrape configs, deploy Grafana, build panels, and somewhere around hour two you finally see a graph. Netdata skips all of that. Install it, hit port 19999, and you’re already looking at per-second CPU steal, per-container network I/O, and NVMe latency — no YAML, no PromQL, no datasource configuration. That specific property is what makes it the right tool for an active incident rather than a weekly review.
The official one-liner gets you running in under two minutes on any Debian/Ubuntu or RHEL-family host:
# The --no-updates flag prevents automatic self-updates (good for production)
# --disable-telemetry is non-negotiable if this box has no outbound internet
wget -O /tmp/netdata-kickstart.sh https://my-netdata.io/kickstart.sh && \
sh /tmp/netdata-kickstart.sh --no-updates --disable-telemetry --stable-channel
Skip --disable-telemetry and Netdata phones home with anonymous usage stats on startup. That’s fine on an internet-connected dev box, but if you’re installing on an air-gapped segment or a compliance-sensitive host, that outbound call will either block or violate policy. The Docker path is cleaner for reproducibility — mount /etc/netdata and /var/lib/netdata as volumes and you get a config-preserved, upgradeable container — but the native install gets eBPF working with less friction because the kernel headers are already present on the host.
The eBPF collector is the feature that actually separates Netdata from a Node Exporter + Grafana stack in a meaningful way. Node Exporter exposes aggregate block device and network interface stats. Netdata’s eBPF module hooks at the kernel level and gives you per-process and per-cgroup breakdowns: which exact PID is issuing the most write() syscalls, which container is saturating your NVMe’s write queue, which process is holding open the most file descriptors. On my 32GB workstation running several inference containers simultaneously, this is the fastest way to answer “why is disk latency spiking” without reaching for iotop or bpftrace manually. The collector is enabled by default on supported kernels (4.11+) — check /var/log/netdata/error.log if charts aren’t appearing, because a missing linux-headers package is the most common silent failure.
The real constraint to plan around is retention. The OSS version keeps high-resolution data in a custom database (DBENGINE) and the default tier setup retains roughly a few days of per-second metrics before it starts downsampling or dropping. If you’re doing capacity planning or want to correlate last Tuesday’s memory creep with a deployment, that history won’t be there. The two exits are: push to a Prometheus remote_write endpoint you already operate, or stream to InfluxDB. The config for remote_write is straightforward:
# /etc/netdata/exporting.conf
[prometheus_remote_write:my_prometheus]
enabled = yes
destination = prometheus-host:9090/api/v1/write
# send only what you'll actually query — cardinality adds up fast
send charts matching = system.* net.* disk.*
Without one of those backends, Netdata is a sharp diagnostic tool and a poor trend-analysis platform. That’s not a criticism — it’s the right mental model. Use it as the first thing you open during an incident and as the always-on overlay that tells you something is wrong before your alerting fires. Use Prometheus or VictoriaMetrics for the historical record. Running both on the same host costs you some RAM (Netdata sits around 150–300 MB depending on plugin count and DBENGINE tier config), but the operational split is clean: real-time clarity from Netdata, long-term context from your TSDB of choice.
Tool 4: Uptime Kuma — Endpoint Monitoring With Zero Complexity Tax
Most monitoring tools watch your server from the inside — they see CPU, memory, disk, process state. Uptime Kuma watches from the outside. It hits your endpoints the same way a user would, which means it catches a completely different failure class: your Nginx container is running, your app container is running, but the reverse proxy is returning 502s because the upstream socket path changed after a config reload. Prometheus won’t fire an alert. Uptime Kuma will, within 60 seconds.
Getting it running takes one command. The named volume is non-negotiable — skip it and you lose all your monitor config on every container update:
docker run -d \
--restart=always \
-p 3001:3001 \
-v uptime-kuma:/app/data \
--name uptime-kuma \
louislam/uptime-kuma:1
Hit http://your-server:3001, create an admin account, and you’re configuring monitors. No YAML, no config files to maintain. The monitor types that matter for a typical self-hosting stack are specific: use HTTP(s) keyword match to hit your Ollama endpoint at http://localhost:11434/api/tags and assert the response body contains "models" — that verifies not just that the port is open but that the API is actually responding with valid data. Use TCP port for PostgreSQL on port 5432, since Postgres doesn’t speak HTTP and you just need to know the socket is accepting connections. Use the Docker container monitor type to track container health status directly — it reads from the Docker socket, so a container that’s running but in an unhealthy state (failed healthcheck) will register as down rather than up.
Notification routing is where Uptime Kuma earns its place in a real stack. Telegram bot notifications and Discord webhooks are both native, zero-plugin — you paste your bot token or webhook URL into a form and it works. The more useful option for a fully self-hosted setup is ntfy.sh, or better, a self-hosted ntfy instance. Uptime Kuma supports ntfy natively. You run ntfy in Docker, subscribe to a topic on your phone via the ntfy app, and now you have end-to-end self-hosted push alerts with no dependency on a third-party service. The config inside Uptime Kuma for a self-hosted ntfy instance is just your server URL, topic name, and optional auth token — takes 30 seconds.
The ceiling is real and worth naming clearly. Uptime Kuma stores no time-series data. There is no query language, no way to ask “what was my p95 response time over the last 30 days,” no CPU or memory visibility. It keeps a response time graph per monitor but that’s purely cosmetic — you can’t alert on latency percentiles or correlate an endpoint slowdown with a memory spike. The correct mental model is: Uptime Kuma runs alongside Prometheus and your Grafana dashboards, not instead of them. Prometheus tells you what’s happening inside your systems; Uptime Kuma tells you whether those systems are reachable from the network at all. They answer different questions. Running both on my workstation adds negligible overhead — Uptime Kuma idles under 100MB RAM — and the combination closes a blind spot that neither tool covers alone.
Tool 5: Zabbix — When the Complexity Is Actually Justified
Most monitoring tools make you choose between “easy to set up” and “actually scales.” Zabbix lands in a third category: harder to set up than everything else on this list, but the architecture genuinely pays dividends once you cross the four-host threshold. A single Zabbix server can centrally manage agent configs, trigger thresholds, escalation policies, and dashboards for a NAS, a GPU workstation, a VPS, and a Raspberry Pi — from one place, with one schema. The operational model shifts from “log into each machine to check its monitoring” to “the monitoring comes to you.”
The canonical self-hosted deployment is a Docker Compose stack: Zabbix server, the PHP frontend, and a PostgreSQL backend. The single most common first-boot failure is a credential mismatch between the database container and the Zabbix server container. Both must agree on the password, and they’re set via separate env vars that are easy to set inconsistently:
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: zabbix
POSTGRES_PASSWORD: your_secret_here # set this
POSTGRES_DB: zabbix
zabbix-server:
image: zabbix/zabbix-server-pgsql:alpine-7.0-latest
environment:
DB_SERVER_HOST: postgres
POSTGRES_USER: zabbix
ZBX_DBPASSWORD: your_secret_here # must match exactly
POSTGRES_DB: zabbix
depends_on:
- postgres
zabbix-web:
image: zabbix/zabbix-web-nginx-pgsql:alpine-7.0-latest
environment:
DB_SERVER_HOST: postgres
POSTGRES_USER: zabbix
ZBX_DBPASSWORD: your_secret_here # same here
POSTGRES_DB: zabbix
PHP_TZ: America/New_York
ports:
- "8080:8080"
If the values drift, the server container exits immediately with a database connection error and the logs are sparse enough that the mismatch isn’t obvious. Set these from a single .env file and reference them with variable substitution — don’t type the password in three places manually.
The feature most people miss until they actually need it: Zabbix’s network auto-discovery. You can define a rule that scans a CIDR range (say 192.168.1.0/24) on a schedule, checks for a running Zabbix agent on port 10050, and automatically registers matching hosts with a default template applied. This matters in practice when you’re spinning up new Docker hosts or VMs — the new machine installs the agent, and within the next discovery interval it appears in your Zabbix dashboard without any manual “add host” workflow. For a homelab that sees regular churn, that compounds into real time saved. The auto-registration action is configured under Configuration → Discovery → Actions, and the default host template applied on registration is where most people spend time tuning.
The honest trade-off: the Zabbix web UI is genuinely dense. Terms like “hosts,” “host groups,” “templates,” “items,” “triggers,” and “actions” have specific meanings that relate to each other in ways that aren’t self-evident from the interface. Expect to spend real time with the docs before the mental model clicks. If your monitoring scope is a single workstation or two boxes, this overhead isn’t justified. A stack of Prometheus with Node Exporter, Grafana for dashboards, and Uptime Kuma for endpoint checks covers the same ground — CPU, memory, disk, network, service availability — with a faster setup path and a UI that most people find navigable on first contact. Zabbix earns its complexity when the host count grows, when you want centralized agent config management, or when you need the built-in alerting escalation chains that Grafana only approximates through external integrations.
Picking the Right Stack for Your Setup
The most expensive mistake in self-hosted monitoring is over-engineering it before you understand your failure modes. A single GPU workstation running Ollama does not need the same stack as a six-host homelab with mixed workloads. The table below maps each tool against the dimensions that actually matter when you are choosing what to deploy — not marketing bullets, but the one thing that will make you regret the choice at 2am.
| Tool | Deploy Complexity (1–5) | Metric Granularity | Alerting | Docker-Native | Host Resource Cost | Biggest Dealbreaker |
|---|---|---|---|---|---|---|
| Prometheus + Node Exporter | 3 | Very high | Via Alertmanager (separate config) | Yes | Low (scrape-based, idle is cheap) | No useful UI without Grafana; PromQL has a real learning curve |
| Grafana | 2 (standalone) / 4 (full stack) | Depends on data source | Built-in (Grafana Alerting) | Yes | Low (visualization layer only) | Worthless without a metrics backend; becomes a maintenance surface on its own |
| Netdata | 1 | Very high (per-second, auto-discovered) | Built-in with ML anomaly detection | Yes | Medium (constant collection has CPU overhead) | Short local retention by default; cloud dependency for multi-node dashboards |
| Uptime Kuma | 1 | Low (availability + latency only) | Excellent (multi-channel, easy config) | Yes | Negligible | Not a metrics tool — tells you something is down, not why |
| Zabbix | 5 | High (agent + agentless, SNMP, JMX) | thorough (escalations, dependencies) | Partial (agent yes, server setup is heavy) | High (Postgres or MySQL backend required) | The UI and config model will eat a weekend before you see any value |
Match your operator profile to a stack before you install anything. If you are running a single GPU workstation — Ollama, a few Docker services, maybe an n8n instance — Prometheus + Node Exporter + Uptime Kuma covers the surface area without overhead. Prometheus gives you the time-series record for CPU, memory, disk, and network; Uptime Kuma pings your endpoints and fires a notification when something stops responding. That is the entire job for a single-node setup. If your homelab spans multiple hosts with different roles, add Grafana for cross-host trending and start evaluating Zabbix only if you need host lifecycle management (auto-registration, templates across OS types, SNMP for network gear). Zabbix earns its complexity at that scale. Below it, the complexity is a tax with no refund.
If you have an incident right now and zero monitoring deployed, install Netdata first. One command, instant per-second visibility across CPU, memory, disk I/O, network, and running processes — no config file required for the base case. The migration path later is straightforward: keep Netdata for live incident debugging, layer Prometheus + Grafana on top for retention and trending, and use Uptime Kuma for availability checks. These three tools are not redundant — they occupy distinct roles. Uptime Kuma answers “is it up?”, Prometheus + Grafana answers “what has it been doing over time?”, and Netdata answers “what is happening right now and which process is the culprit?” Running all three on a single host is operationally reasonable and resource-light.
One gap none of these tools closes out of the box: VRAM utilization. If you are running Ollama, vLLM, or any inference workload, memory pressure on the GPU is your most likely OOM kill vector — and it will not show up in Node Exporter metrics without an additional exporter. The two realistic options are dcgm-exporter (NVIDIA’s own, heavier, suited for multi-GPU) and nvidia_gpu_exporter (lighter, single-binary, easier to drop into a Compose file). Both expose Prometheus-compatible metrics. On my 32GB-VRAM workstation I run nvidia_gpu_exporter as a sidecar in the same Compose stack as Prometheus, then alert when VRAM utilization crosses a threshold before the OOM killer gets involved:
# docker-compose snippet — nvidia_gpu_exporter alongside Prometheus
nvidia-exporter:
image: utkuozdemir/nvidia_gpu_exporter:1.2.0
runtime: nvidia
environment:
- NVIDIA_VISIBLE_DEVICES=all
ports:
- "9835:9835"
restart: unless-stopped
# prometheus.yml scrape target
- job_name: 'nvidia_gpu'
static_configs:
- targets: ['nvidia-exporter:9835']
# Alertmanager rule — fire before OOM, not after
- alert: GPUMemoryPressure
expr: nvidia_smi_memory_used_bytes / nvidia_smi_memory_total_bytes > 0.88
for: 2m
labels:
severity: warning
annotations:
summary: "VRAM above 88% for 2m — check Ollama model load"
The 88% threshold is deliberate — it gives you a warning window before the kernel OOM fires at full saturation. Without this exporter in the stack, a Prometheus + Grafana setup gives you no signal that your inference workload is about to crash. That is the single monitoring gap most self-hosted LLM operators are running with today, and it is a straightforward fix once you know it exists.