<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 우병수</title>
    <description>The latest articles on DEV Community by 우병수 (@ericwoooo_kr).</description>
    <link>https://dev.to/ericwoooo_kr</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3893397%2Fcc10e5dc-580b-44d5-b2e3-d0b9b7b4f547.png</url>
      <title>DEV Community: 우병수</title>
      <link>https://dev.to/ericwoooo_kr</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ericwoooo_kr"/>
    <language>en</language>
    <item>
      <title>5 Monitoring Tools That Actually Work for Self-Hosted Servers (Tested on Real Hardware)</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Mon, 03 Aug 2026 08:11:10 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/5-monitoring-tools-that-actually-work-for-self-hosted-servers-tested-on-real-hardware-21oi</link>
      <guid>https://dev.to/ericwoooo_kr/5-monitoring-tools-that-actually-work-for-self-hosted-servers-tested-on-real-hardware-21oi</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~20 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Monitoring Gap That Bites Self-Hosters&lt;/li&gt;
&lt;li&gt;Tool 1: Prometheus + Node Exporter — The Baseline You Build Everything On&lt;/li&gt;
&lt;li&gt;Tool 2: Grafana — Dashboards Worth Waking Up For&lt;/li&gt;
&lt;li&gt;Tool 3: Netdata — When You Want Answers in 30 Seconds, Not 30 Minutes&lt;/li&gt;
&lt;li&gt;Tool 4: Uptime Kuma — Endpoint Monitoring With Zero Complexity Tax&lt;/li&gt;
&lt;li&gt;Tool 5: Zabbix — When the Complexity Is Actually Justified&lt;/li&gt;
&lt;li&gt;Picking the Right Stack for Your Setup&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Monitoring Gap That Bites Self-Hosters
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;ollama serve&lt;/code&gt; 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 &lt;code&gt;nvidia-smi&lt;/code&gt; output or a metric derived from it. And you need alerting that fires &lt;em&gt;before&lt;/em&gt; things break — disk at 85%, swap climbing, a container restart loop starting — not after the service is already gone.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://techdigestor.com/best-ai-coding-tools-2026/" rel="noopener noreferrer"&gt;AI Coding Tools in 2026: Cloud Copilots vs Local Models&lt;/a&gt; covers the broader picture of what is worth running locally vs. delegating to a cloud API — the same decision framework applies here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool 1: Prometheus + Node Exporter — The Baseline You Build Everything On
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;curl http://localhost:9100/metrics&lt;/code&gt; returns data, Prometheus can scrape it. That simplicity is what makes it composable — Grafana, Alertmanager, and a dozen exporters all plug in without ceremony.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/prometheus:v2.52.0&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./prometheus.yml:/etc/prometheus/prometheus.yml:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;prometheus_data:/prometheus&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--config.file=/etc/prometheus/prometheus.yml'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.retention.time=30d'&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9090:9090"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;

  &lt;span class="na"&gt;node_exporter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/node-exporter:v1.8.0&lt;/span&gt;
    &lt;span class="na"&gt;pid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;host"&lt;/span&gt;           &lt;span class="c1"&gt;# needed for accurate process metrics&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/proc:/host/proc:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/sys:/host/sys:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/:/rootfs:ro&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--path.procfs=/host/proc'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--path.sysfs=/host/sys'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--path.rootfs=/rootfs'&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9100:9100"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheus_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;monitoring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# prometheus.yml&lt;/span&gt;
&lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scrape_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;        &lt;span class="c1"&gt;# override the 15s default — see note below&lt;/span&gt;
  &lt;span class="na"&gt;evaluation_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;

&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;node'&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;node_exporter:9100'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;my-workstation'&lt;/span&gt;   &lt;span class="c1"&gt;# shows up in every metric — label it now&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;/metrics&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;du -sh /var/lib/docker/volumes/prometheus_data&lt;/code&gt; after a week to calibrate your own rate before committing to a retention window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool 2: Grafana — Dashboards Worth Waking Up For
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Grafana as the Visualization Layer on Top of Prometheus
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Community Dashboards vs. Building Your Own
&lt;/h3&gt;

&lt;p&gt;Start with dashboard ID &lt;strong&gt;1860&lt;/strong&gt; — 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 &lt;code&gt;node_exporter&lt;/code&gt;. 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 &lt;code&gt;nvidia-smi&lt;/code&gt; through the NVIDIA DCGM exporter or the lighter &lt;code&gt;nvidia_gpu_exporter&lt;/code&gt;. The metric you want is &lt;code&gt;nvidia_smi_memory_used_bytes&lt;/code&gt; divided by &lt;code&gt;nvidia_smi_memory_total_bytes&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker Compose Addition
&lt;/h3&gt;

&lt;p&gt;Drop this into the same &lt;code&gt;docker-compose.yml&lt;/code&gt; that already runs your Prometheus container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/grafana-oss:10.4.2&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# Set this BEFORE the container ever starts — Grafana writes the&lt;/span&gt;
      &lt;span class="c1"&gt;# admin password into grafana.db on first boot and ignores this&lt;/span&gt;
      &lt;span class="c1"&gt;# env var on subsequent starts unless you reset via CLI.&lt;/span&gt;
      &lt;span class="na"&gt;GF_SECURITY_ADMIN_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;changeme_before_first_run"&lt;/span&gt;
      &lt;span class="na"&gt;GF_USERS_ALLOW_SIGN_UP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;grafana_data:/var/lib/grafana&lt;/span&gt;   &lt;span class="c1"&gt;# persists grafana.db, dashboards, alert rules&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;grafana_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;GF_SECURITY_ADMIN_PASSWORD&lt;/code&gt; timing matters more than the docs make clear. Grafana initializes &lt;code&gt;grafana.db&lt;/code&gt; (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 &lt;code&gt;docker compose up&lt;/code&gt;, or run &lt;code&gt;grafana-cli admin reset-admin-password&lt;/code&gt; inside the container after the fact. Either works; forgetting costs you time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alerting via Grafana Unified Alerting
&lt;/h3&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Create a Contact Point of type &lt;strong&gt;Telegram&lt;/strong&gt;. You need a bot token (from &lt;a class="mentioned-user" href="https://dev.to/botfather"&gt;@botfather&lt;/a&gt;) and your chat ID. Grafana will POST to the Telegram Bot API directly.&lt;/li&gt;
&lt;li&gt; Create an Alert Rule on the VRAM panel: PromQL expression &lt;code&gt;(nvidia_smi_memory_used_bytes / nvidia_smi_memory_total_bytes) * 100 &amp;gt; 90&lt;/code&gt;, evaluation interval 1m, pending period 2m (avoids false positives during model load spikes).&lt;/li&gt;
&lt;li&gt; Assign that rule to a Notification Policy that routes to your Telegram contact point.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  OSS vs. Grafana Cloud
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;grafana/grafana-oss&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool 3: Netdata — When You Want Answers in 30 Seconds, Not 30 Minutes
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The official one-liner gets you running in under two minutes on any Debian/Ubuntu or RHEL-family host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The --no-updates flag prevents automatic self-updates (good for production)&lt;/span&gt;
&lt;span class="c"&gt;# --disable-telemetry is non-negotiable if this box has no outbound internet&lt;/span&gt;
wget &lt;span class="nt"&gt;-O&lt;/span&gt; /tmp/netdata-kickstart.sh https://my-netdata.io/kickstart.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  sh /tmp/netdata-kickstart.sh &lt;span class="nt"&gt;--no-updates&lt;/span&gt; &lt;span class="nt"&gt;--disable-telemetry&lt;/span&gt; &lt;span class="nt"&gt;--stable-channel&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip &lt;code&gt;--disable-telemetry&lt;/code&gt; 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 &lt;code&gt;/etc/netdata&lt;/code&gt; and &lt;code&gt;/var/lib/netdata&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;write()&lt;/code&gt; 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 &lt;code&gt;iotop&lt;/code&gt; or &lt;code&gt;bpftrace&lt;/code&gt; manually. The collector is enabled by default on supported kernels (4.11+) — check &lt;code&gt;/var/log/netdata/error.log&lt;/code&gt; if charts aren't appearing, because a missing &lt;code&gt;linux-headers&lt;/code&gt; package is the most common silent failure.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/netdata/exporting.conf
&lt;/span&gt;[&lt;span class="n"&gt;prometheus_remote_write&lt;/span&gt;:&lt;span class="n"&gt;my_prometheus&lt;/span&gt;]
    &lt;span class="n"&gt;enabled&lt;/span&gt; = &lt;span class="n"&gt;yes&lt;/span&gt;
    &lt;span class="n"&gt;destination&lt;/span&gt; = &lt;span class="n"&gt;prometheus&lt;/span&gt;-&lt;span class="n"&gt;host&lt;/span&gt;:&lt;span class="m"&gt;9090&lt;/span&gt;/&lt;span class="n"&gt;api&lt;/span&gt;/&lt;span class="n"&gt;v1&lt;/span&gt;/&lt;span class="n"&gt;write&lt;/span&gt;
    &lt;span class="c"&gt;# send only what you'll actually query — cardinality adds up fast
&lt;/span&gt;    &lt;span class="n"&gt;send&lt;/span&gt; &lt;span class="n"&gt;charts&lt;/span&gt; &lt;span class="n"&gt;matching&lt;/span&gt; = &lt;span class="n"&gt;system&lt;/span&gt;.* &lt;span class="n"&gt;net&lt;/span&gt;.* &lt;span class="n"&gt;disk&lt;/span&gt;.*
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool 4: Uptime Kuma — Endpoint Monitoring With Zero Complexity Tax
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;always &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 3001:3001 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; uptime-kuma:/app/data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; uptime-kuma &lt;span class="se"&gt;\&lt;/span&gt;
  louislam/uptime-kuma:1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hit &lt;code&gt;http://your-server:3001&lt;/code&gt;, 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 &lt;strong&gt;HTTP(s) keyword match&lt;/strong&gt; to hit your Ollama endpoint at &lt;code&gt;http://localhost:11434/api/tags&lt;/code&gt; and assert the response body contains &lt;code&gt;"models"&lt;/code&gt; — that verifies not just that the port is open but that the API is actually responding with valid data. Use &lt;strong&gt;TCP port&lt;/strong&gt; for PostgreSQL on port 5432, since Postgres doesn't speak HTTP and you just need to know the socket is accepting connections. Use the &lt;strong&gt;Docker container&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;ntfy.sh&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;alongside&lt;/em&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool 5: Zabbix — When the Complexity Is Actually Justified
&lt;/h2&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zabbix&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your_secret_here&lt;/span&gt;   &lt;span class="c1"&gt;# set this&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zabbix&lt;/span&gt;

  &lt;span class="na"&gt;zabbix-server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zabbix/zabbix-server-pgsql:alpine-7.0-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DB_SERVER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zabbix&lt;/span&gt;
      &lt;span class="na"&gt;ZBX_DBPASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your_secret_here&lt;/span&gt;      &lt;span class="c1"&gt;# must match exactly&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zabbix&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;

  &lt;span class="na"&gt;zabbix-web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zabbix/zabbix-web-nginx-pgsql:alpine-7.0-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DB_SERVER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zabbix&lt;/span&gt;
      &lt;span class="na"&gt;ZBX_DBPASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your_secret_here&lt;/span&gt;      &lt;span class="c1"&gt;# same here&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zabbix&lt;/span&gt;
      &lt;span class="na"&gt;PHP_TZ&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;America/New_York&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;.env&lt;/code&gt; file and reference them with variable substitution — don't type the password in three places manually.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;192.168.1.0/24&lt;/code&gt;) 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 &lt;em&gt;Configuration → Discovery → Actions&lt;/em&gt;, and the default host template applied on registration is where most people spend time tuning.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking the Right Stack for Your Setup
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Tool&lt;/p&gt;

&lt;p&gt;Deploy Complexity (1–5)&lt;/p&gt;

&lt;p&gt;Metric Granularity&lt;/p&gt;

&lt;p&gt;Alerting&lt;/p&gt;

&lt;p&gt;Docker-Native&lt;/p&gt;

&lt;p&gt;Host Resource Cost&lt;/p&gt;

&lt;p&gt;Biggest Dealbreaker&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prometheus + Node Exporter&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;3&lt;/p&gt;

&lt;p&gt;Very high&lt;/p&gt;

&lt;p&gt;Via Alertmanager (separate config)&lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;Low (scrape-based, idle is cheap)&lt;/p&gt;

&lt;p&gt;No useful UI without Grafana; PromQL has a real learning curve&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grafana&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;2 (standalone) / 4 (full stack)&lt;/p&gt;

&lt;p&gt;Depends on data source&lt;/p&gt;

&lt;p&gt;Built-in (Grafana Alerting)&lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;Low (visualization layer only)&lt;/p&gt;

&lt;p&gt;Worthless without a metrics backend; becomes a maintenance surface on its own&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Netdata&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;1&lt;/p&gt;

&lt;p&gt;Very high (per-second, auto-discovered)&lt;/p&gt;

&lt;p&gt;Built-in with ML anomaly detection&lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;Medium (constant collection has CPU overhead)&lt;/p&gt;

&lt;p&gt;Short local retention by default; cloud dependency for multi-node dashboards&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uptime Kuma&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;1&lt;/p&gt;

&lt;p&gt;Low (availability + latency only)&lt;/p&gt;

&lt;p&gt;Excellent (multi-channel, easy config)&lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;Negligible&lt;/p&gt;

&lt;p&gt;Not a metrics tool — tells you something is down, not why&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zabbix&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;5&lt;/p&gt;

&lt;p&gt;High (agent + agentless, SNMP, JMX)&lt;/p&gt;

&lt;p&gt;thorough (escalations, dependencies)&lt;/p&gt;

&lt;p&gt;Partial (agent yes, server setup is heavy)&lt;/p&gt;

&lt;p&gt;High (Postgres or MySQL backend required)&lt;/p&gt;

&lt;p&gt;The UI and config model will eat a weekend before you see any value&lt;/p&gt;

&lt;p&gt;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 — &lt;strong&gt;Prometheus + Node Exporter + Uptime Kuma&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;One gap none of these tools closes out of the box: &lt;strong&gt;VRAM utilization&lt;/strong&gt;. 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 &lt;a href="https://github.com/NVIDIA/dcgm-exporter" rel="noopener noreferrer"&gt;&lt;code&gt;dcgm-exporter&lt;/code&gt;&lt;/a&gt; (NVIDIA's own, heavier, suited for multi-GPU) and &lt;a href="https://github.com/utkuozdemir/nvidia_gpu_exporter" rel="noopener noreferrer"&gt;&lt;code&gt;nvidia_gpu_exporter&lt;/code&gt;&lt;/a&gt; (lighter, single-binary, easier to drop into a Compose file). Both expose Prometheus-compatible metrics. On my 32GB-VRAM workstation I run &lt;code&gt;nvidia_gpu_exporter&lt;/code&gt; as a sidecar in the same Compose stack as Prometheus, then alert when VRAM utilization crosses a threshold before the OOM killer gets involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose snippet — nvidia_gpu_exporter alongside Prometheus&lt;/span&gt;
  &lt;span class="na"&gt;nvidia-exporter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;utkuozdemir/nvidia_gpu_exporter:1.2.0&lt;/span&gt;
    &lt;span class="na"&gt;runtime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NVIDIA_VISIBLE_DEVICES=all&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9835:9835"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="c1"&gt;# prometheus.yml scrape target&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;nvidia_gpu'&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;nvidia-exporter:9835'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Alertmanager rule — fire before OOM, not after&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GPUMemoryPressure&lt;/span&gt;
    &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia_smi_memory_used_bytes / nvidia_smi_memory_total_bytes &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;0.88&lt;/span&gt;
    &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
    &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VRAM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;above&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;88%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2m&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;check&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Ollama&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;load"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/5-monitoring-tools-that-actually-work-for-self-hosted-servers-tested-on-real-hardware/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>5 Self-Hosted Analytics Tools Worth Running on Your Own Hardware in 2026</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Fri, 31 Jul 2026 08:11:12 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/5-self-hosted-analytics-tools-worth-running-on-your-own-hardware-in-2026-4fp9</link>
      <guid>https://dev.to/ericwoooo_kr/5-self-hosted-analytics-tools-worth-running-on-your-own-hardware-in-2026-4fp9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; GA4's event model wasn't designed for people who want to understand their data — it was designed for people who want to feed Google's ad graph.  The sampling kicks in the moment your traffic exceeds the free tier thresholds, which means the numbers you see in your dashboard are e&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~21 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Real Problem: You're Flying Blind or Paying Google to Watch You&lt;/li&gt;
&lt;li&gt;How to Read This Comparison: Constraints That Force a Choice&lt;/li&gt;
&lt;li&gt;Plausible Analytics: Minimum Footprint, Maximum Readability&lt;/li&gt;
&lt;li&gt;Umami: When You Need Multi-Site Tracking Without the ClickHouse Tax&lt;/li&gt;
&lt;li&gt;Matomo: Full-Featured but Operationally Expensive&lt;/li&gt;
&lt;li&gt;PostHog: Product Analytics With a Real Event Pipeline&lt;/li&gt;
&lt;li&gt;Grafana + a Data Source: When You Already Have the Metrics&lt;/li&gt;
&lt;li&gt;When to Pick What: A Decision Matrix for Operators&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Real Problem: You're Flying Blind or Paying Google to Watch You
&lt;/h2&gt;

&lt;p&gt;GA4's event model wasn't designed for people who want to understand their data — it was designed for people who want to feed Google's ad graph. The sampling kicks in the moment your traffic exceeds the free tier thresholds, which means the numbers you see in your dashboard are estimates dressed up as facts. Export to BigQuery is the official escape hatch, but it's throttled, requires a Google Cloud billing account, and you're still querying data that lives on infrastructure you don't control. For a small business operator, that's a fragile foundation: Google can deprecate the export format, change the schema, or restructure pricing, and your entire analytics workflow breaks overnight.&lt;/p&gt;

&lt;p&gt;The self-hosted case isn't primarily a privacy argument, though that matters too. The real use is schema ownership. When you run your own analytics stack, you decide what an "event" means, how long raw data is retained, and whether you can run a &lt;code&gt;GROUP BY&lt;/code&gt; at 2am without hitting a quota. You also control query latency — the difference between a ClickHouse instance on the same LAN as your dashboard and a round-trip to a managed SaaS API is measurable in seconds per page load when your queries get complex. That latency difference becomes load-bearing when you want to pipe analytics signals into automation workflows rather than just stare at charts.&lt;/p&gt;

&lt;p&gt;The five tools covered here all have one thing in common: they run on commodity hardware without requiring a dedicated database cluster or a DevOps team. A single VPS with 2 vCPUs and 4GB RAM is enough to start with most of them. A home-lab Docker host is fine for lower-traffic sites. Each tool gets an honest resource cost — disk growth rate, RAM floor, CPU spikes during ingestion — and one dealbreaker that the README won't tell you upfront. No tool on this list is universally the right choice, and the honest answer is that the right pick depends on whether you need SQL access, funnel visualization, session replay, or lightweight script-tag deployment.&lt;/p&gt;

&lt;p&gt;One angle that rarely gets covered in analytics tool comparisons: if you're already running automation pipelines, your analytics data shouldn't just sit in a dashboard — it should be a trigger source. A spike in 404 events, a drop in checkout completions below a threshold, a new referrer domain hitting your site — all of these are signals that can fire n8n workflows, send Slack alerts, or update a CRM record without a human checking a dashboard first. The infrastructure overlap between a self-hosted analytics backend and an event-driven automation stack is significant, and it's worth thinking about before you pick your schema. For the pipeline side of that equation, the &lt;a href="https://techdigestor.com/ultimate-productivity-guide-2026/" rel="noopener noreferrer"&gt;Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines&lt;/a&gt; guide covers how to wire those event sources into real workflows.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Read This Comparison: Constraints That Force a Choice
&lt;/h2&gt;

&lt;p&gt;Most comparisons of analytics tools sort by feature count. That's the wrong axis. The real question is how much &lt;em&gt;schema complexity and operational surface area&lt;/em&gt; you're willing to own. A page-view counter needs one table and a cron job. A full event pipeline needs a queue, a schema migration strategy, and someone to watch it at 2 AM when the ingestion worker silently backs up. Those are not the same class of problem, and picking the wrong tier will cost you more time than any missing feature ever would.&lt;/p&gt;

&lt;p&gt;The hardware baseline throughout this comparison is a single Docker host: 2 vCPUs, 4 GB RAM, 20 GB SSD. That maps to a $6–12/month VPS (Hetzner CX22, DigitalOcean Basic, Vultr Regular) or a repurposed home-lab node running Debian or Ubuntu 22.04. If a tool routinely exceeds that envelope at idle — not under load, &lt;em&gt;at idle&lt;/em&gt; — that will be flagged explicitly with numbers, not vague warnings. Some tools in this list are fine on that spec. One or two will push you toward 8 GB RAM before you've ingested a single real event.&lt;/p&gt;

&lt;p&gt;The columns that actually drive the decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Minimum RAM at idle&lt;/strong&gt; — what the process tree consumes after startup with no traffic. This is where most comparisons lie by omission.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Database backend&lt;/strong&gt; — SQLite vs. Postgres vs. ClickHouse is not a detail, it's a maintenance contract. SQLite means zero ops and zero horizontal scale. ClickHouse means real performance on large datasets and real complexity on backup/restore.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Data export format&lt;/strong&gt; — can you get your raw events out as CSV, JSON, or SQL dump without paying for a higher tier? Vendor lock-in in self-hosted tools usually lives here, not in the UI.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Self-hosted feature parity&lt;/strong&gt; — some tools gate feature flags, funnels, or session replay behind their cloud plan even if you're running their Docker image. This is more common than the README implies.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Single biggest operational gotcha&lt;/strong&gt; — the thing that doesn't appear until you've run it for a week: a migration that requires downtime, a default retention setting that fills your disk, a background job that pegs CPU on every page load.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three tiers covered here map to real operational profiles. Lightweight page-view counters — Plausible and Umami — are single-binary or two-container deployments where the biggest decision is whether to use SQLite or Postgres. Full event pipelines — PostHog and Matomo — bring plugin ecosystems, session capture, and funnel analysis, but they also bring background workers, cache layers, and schema migrations you didn't ask for. Grafana sitting in front of a data source is a different category entirely: it doesn't collect events, it visualizes metrics you're already emitting, so its "analytics" story depends entirely on what's feeding it. Comparing those three tiers on the same feature grid produces misleading output. The sections below keep them in their lanes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plausible Analytics: Minimum Footprint, Maximum Readability
&lt;/h2&gt;

&lt;p&gt;Plausible's Docker Compose stack is genuinely small — the official repo gives you a working &lt;code&gt;docker-compose.yml&lt;/code&gt; on day one, and unlike most "self-hosted" analytics setups, you don't spend the first afternoon debugging missing env vars. The ClickHouse backend is pre-configured, the Elixir app boots cleanly, and you're ingesting hits within minutes. The catch that the README doesn't emphasize: that default ClickHouse config is a single-shard setup. Under roughly 10M monthly events it behaves fine. Above that, you're not dealing with a gradual slowdown — query times start spiking and you'll need to rethink the ClickHouse config before you hit the ceiling, not after.&lt;/p&gt;

&lt;p&gt;Memory footprint at idle is honest for what it does. The Elixir app sits around 350 MB RSS, ClickHouse cold-starts near 600 MB. Both numbers are tolerable on a 2 GB VPS — until you get a traffic spike. ClickHouse will happily consume whatever RAM is available during a burst of aggregation queries. If you haven't dropped a &lt;code&gt;max_memory_usage&lt;/code&gt; cap into your &lt;code&gt;config.d/&lt;/code&gt; override, expect it to balloon past 1.5 GB with no warning. The fix is straightforward but underdocumented:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- /etc/clickhouse-server/config.d/memory.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;yandex&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;max_memory_usage&amp;gt;&lt;/span&gt;1073741824&lt;span class="nt"&gt;&amp;lt;/max_memory_usage&amp;gt;&lt;/span&gt;       &lt;span class="c"&gt;&amp;lt;!-- 1 GB hard cap per query --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;max_memory_usage_for_all_queries&amp;gt;&lt;/span&gt;1610612736&lt;span class="nt"&gt;&amp;lt;/max_memory_usage_for_all_queries&amp;gt;&lt;/span&gt;  &lt;span class="c"&gt;&amp;lt;!-- 1.5 GB total --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/yandex&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mount that file into the ClickHouse container via your compose override and restart. Without it, on a small VPS, you're one dashboard refresh from an OOM kill.&lt;/p&gt;

&lt;p&gt;The comparison with Plausible's paid cloud is frequently misrepresented. Funnels, revenue goals, and the Sites API are all present in CE — the real difference is operational: you own the ingestion pipeline end-to-end. One botched deploy, one misconfigured reverse proxy dropping the &lt;code&gt;X-Forwarded-For&lt;/code&gt; header, and you're silently losing events with no alerting from Plausible itself. The cloud version has buffer layers and retries you never see. On CE, you build your own monitoring or you fly blind. A basic healthcheck hitting the &lt;code&gt;/api/health&lt;/code&gt; endpoint on a 60-second cron is the minimum viable safety net:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# quick health probe — alerts if the Elixir app stops accepting requests&lt;/span&gt;
curl &lt;span class="nt"&gt;-sf&lt;/span&gt; https://your-plausible-domain.com/api/health &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  notify-send &lt;span class="s2"&gt;"Plausible ingestion down"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hardest limit for non-engineering users is raw event access. There is no "Export to CSV" button covering raw hits — the UI gives you aggregated views only. If you or anyone else needs row-level data, you go directly to ClickHouse via &lt;code&gt;clickhouse-client&lt;/code&gt; or the HTTP interface. For an engineer that's fine; you can pull session-level data in seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;clickhouse&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="c1"&gt;--query \&lt;/span&gt;
  &lt;span class="nv"&gt;"SELECT session_id, pathname, country_code, timestamp
   FROM plausible_events_db.events
   WHERE domain = 'yoursite.com'
     AND toDate(timestamp) = today()
   LIMIT 500"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a marketing hire or a business owner who just wants a spreadsheet, this becomes your support burden immediately. If anyone outside engineering needs to self-serve on raw data, factor in either building a thin query UI on top of ClickHouse or routing aggregated exports through something like Metabase pointed at the same database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Umami: When You Need Multi-Site Tracking Without the ClickHouse Tax
&lt;/h2&gt;

&lt;p&gt;Most self-hosted analytics tools that want to be taken seriously these days ship with ClickHouse as a hard dependency. That's a reasonable call for high-volume traffic, but it also means you're suddenly maintaining a columnar database engine alongside everything else, your backup story involves unfamiliar tooling, and your RAM budget just jumped by 2–4 GB minimum. Umami skips all of that. It writes directly to PostgreSQL or MySQL, which means if you're already running Postgres for anything else, Umami is just another schema in a database you already know how to operate. Backup is literally &lt;code&gt;pg_dump&lt;/code&gt;. Restore is &lt;code&gt;psql&lt;/code&gt;. There's nothing novel to learn.&lt;/p&gt;

&lt;p&gt;The Docker Compose setup reflects that simplicity. Three services: the Next.js frontend/API app, a Postgres container, and nothing else. No Redis, no queue workers, no separate ingest service. Idle RAM on my box lands around 250 MB for the whole stack — that's lighter than a single Plausible container on a busy day. If you're running this on a $6 VPS or a shared homelab node, it won't crowd out your other workloads.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# minimal docker-compose.yml — production-ready starting point&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;umami&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/umami-software/umami:postgresql-latest&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql://umami:${DB_PASSWORD}@db:5432/umami&lt;/span&gt;
      &lt;span class="na"&gt;APP_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${APP_SECRET}&lt;/span&gt;   &lt;span class="c1"&gt;# random string, not optional — sessions break without it&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;umami&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;umami&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;umami_db:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;umami"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;umami_db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The v2 REST API is where Umami earns its place in an automation stack. Pulling stats into n8n or a cron job is a single authenticated GET call once you have a bearer token from &lt;code&gt;POST /api/auth/login&lt;/code&gt;. From there, &lt;code&gt;GET /api/websites/:id/stats?startAt=&amp;amp;endAt=&lt;/code&gt; returns pageviews, sessions, bounce rate, and visit duration as a flat JSON object — no pagination, no cursors, nothing exotic. In my n8n flows I use an HTTP Request node with the bearer token stored as a credential, call that endpoint on a schedule, and pipe the numbers directly into a Postgres insert or a Slack summary. The whole flow is under 10 nodes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# grab a bearer token, then pull 7-day stats&lt;/span&gt;
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://your-umami-host/api/auth/login &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"username":"admin","password":"'&lt;/span&gt;&lt;span class="nv"&gt;$UMAMI_PASS&lt;/span&gt;&lt;span class="s1"&gt;'"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.token'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;NOW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s%3N&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;WEEK_AGO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; NOW &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;604800000&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://your-umami-host/api/websites/&lt;/span&gt;&lt;span class="nv"&gt;$SITE_ID&lt;/span&gt;&lt;span class="s2"&gt;/stats?startAt=&lt;/span&gt;&lt;span class="nv"&gt;$WEEK_AGO&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;endAt=&lt;/span&gt;&lt;span class="nv"&gt;$NOW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="c"&gt;# returns: {"pageviews":{"value":4821},"sessions":{"value":1203},...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The performance ceiling is real and worth planning around before you commit. Once &lt;code&gt;website_event&lt;/code&gt; crosses roughly 5 million rows, queries on the default schema start to drag — the dashboard date-range filters hit that table hard. The fix is a partial index on &lt;code&gt;created_at&lt;/code&gt; scoped to your active website IDs, and ideally a monthly partitioning strategy if you're ingesting more than a few thousand events per day. Umami's migrations don't set this up for you, so it's manual work. And the dealbreaker is firm: there are no funnel views, no cohort analysis, no event sequencing. If your question is "how many users completed checkout within a session that started on the pricing page," Umami cannot answer that. It counts pageviews and custom events — cleanly, reliably, with low overhead — and that's the full scope of what it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Matomo: Full-Featured but Operationally Expensive
&lt;/h2&gt;

&lt;p&gt;Matomo is the only self-hosted analytics tool that can walk into a GA4 feature comparison and not immediately lose. Goals, funnel reports, heatmaps, A/B testing, GDPR consent management — it's all there in the self-hosted version. That's a genuinely rare combination, and for a small business that migrated off Google Analytics and doesn't want to rebuild an analytics stack from scratch, Matomo is the obvious first stop.&lt;/p&gt;

&lt;p&gt;The operational cost, though, is not small. Matomo runs on PHP-FPM + MySQL/MariaDB, and its database schema is wide. The two tables that will eventually cause you pain are &lt;code&gt;log_visit&lt;/code&gt; and &lt;code&gt;log_link_visit_action&lt;/code&gt; — they grow faster than you'd expect and don't self-prune without explicit configuration. Beyond storage, the bigger trap is the archiving cron. Matomo doesn't compute reports on the fly for historical data; it relies on a scheduled job to pre-aggregate everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# this must run reliably — add to crontab or a supervisor job
# if it times out silently, your reports will just show stale data with no error
&lt;/span&gt;*/&lt;span class="m"&gt;5&lt;/span&gt; * * * * /&lt;span class="n"&gt;usr&lt;/span&gt;/&lt;span class="n"&gt;bin&lt;/span&gt;/&lt;span class="n"&gt;php&lt;/span&gt; /&lt;span class="n"&gt;var&lt;/span&gt;/&lt;span class="n"&gt;www&lt;/span&gt;/&lt;span class="n"&gt;matomo&lt;/span&gt;/&lt;span class="n"&gt;console&lt;/span&gt; &lt;span class="n"&gt;core&lt;/span&gt;:&lt;span class="n"&gt;archive&lt;/span&gt; --&lt;span class="n"&gt;url&lt;/span&gt;=&lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;your&lt;/span&gt;-&lt;span class="n"&gt;matomo&lt;/span&gt;-&lt;span class="n"&gt;instance&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The silent timeout is the failure mode that bites people. If your site has accumulated a large date range and the archive job hits PHP's &lt;code&gt;max_execution_time&lt;/code&gt; or MySQL's &lt;code&gt;wait_timeout&lt;/code&gt; before finishing, it exits without completing — and your dashboard just quietly shows yesterday's numbers forever. Fix this by setting &lt;code&gt;max_execution_time = 0&lt;/code&gt; in the PHP CLI config (not the web config), and tuning &lt;code&gt;interactive_timeout&lt;/code&gt; and &lt;code&gt;wait_timeout&lt;/code&gt; in MySQL to something above your expected archive duration. Also worth setting in &lt;code&gt;config/config.ini.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[General]&lt;/span&gt;
&lt;span class="c"&gt;# prevent archiving from triggering on browser requests — force cron-only
&lt;/span&gt;&lt;span class="py"&gt;enable_browser_archiving_triggering&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;

&lt;span class="c"&gt;# archive segments with data older than this many seconds
&lt;/span&gt;&lt;span class="py"&gt;time_before_today_archive_considered_outdated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;900&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RAM footprint at idle lands between 500 MB and 1 GB depending on your PHP-FPM pool size and MySQL buffer pool configuration. Once you cross roughly one million monthly pageviews, plan to dedicate 2 GB to this stack — and that's before you factor in the archiving job competing with live ingestion during peak hours. That's a non-trivial ask on a small VPS.&lt;/p&gt;

&lt;p&gt;The dealbreaker for many operators is the plugin licensing model. Matomo's core is genuinely solid and open source, but the features most commonly cited as differentiators — heatmaps and session recording especially — require a paid license even on the self-hosted version. The pricing isn't obscene, but it fractures the "free GA4 replacement" argument immediately. If you need heatmaps on self-hosted Matomo, you're paying. That's a legitimate business model, but be clear-eyed about it before you migrate 30 properties onto this stack and then discover the plugin wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  PostHog: Product Analytics With a Real Event Pipeline
&lt;/h2&gt;

&lt;p&gt;PostHog occupies a different tier from Plausible or Umami — comparing them is like comparing a server access log analyzer to a full product intelligence platform. The self-hosted version ships with feature flags, session replay, heatmaps, funnel analysis, retention curves, path analysis, and an A/B testing engine, all wired into the same event pipeline. You're not stitching together four tools; it's one data model feeding every view. For a small product team that wants to run controlled rollouts and watch session replays without paying per-seat SaaS pricing, that consolidation is the entire argument.&lt;/p&gt;

&lt;p&gt;The deployment story is where most people hit friction. PostHog is Kubernetes-first by architecture, and the official Helm chart makes that obvious. What saves smaller operators is the &lt;code&gt;hobby&lt;/code&gt; deploy — a Docker Compose path that's actually maintained and documented, not an afterthought. The minimum spec is 4 GB RAM, but treat 8 GB as the real floor. ClickHouse is the hungry component here, same as in every other self-hosted analytics stack that prioritizes query speed. The &lt;code&gt;hobby&lt;/code&gt; installer is a single curl-pipe-bash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/bin/bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/PostHog/posthog/HEAD/bin/deploy-hobby&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That script prompts for a domain, writes a &lt;code&gt;.env&lt;/code&gt;, and brings up the compose stack. What it won't tell you upfront is that you're launching roughly 10 containers: ClickHouse, Kafka, Zookeeper, Redis, the Django app server, Celery beat, Celery worker, the plugin server (Node.js), an Nginx proxy, and a periodic worker. At idle, expect the stack to consume 3–4 GB RAM on the host — measured, not estimated. This is not a workload for a $6 VPS. A $24–$40/month dedicated instance with a real SSD is closer to the minimum viable host if you want headroom for actual event ingestion.&lt;/p&gt;

&lt;p&gt;The self-hosted changelog is required reading before you commit. PostHog is honest in its docs about the feature gap: the cloud version ships AI-powered analysis, some newer product analytics views, and LLM observability tooling that the self-hosted version lags on, sometimes by months, sometimes indefinitely. The &lt;code&gt;CHANGELOG.md&lt;/code&gt; in the repo and the self-hosted-specific release notes at &lt;code&gt;posthog.com/docs/self-host/runbook&lt;/code&gt; will tell you exactly which features are missing. The gap isn't fatal for most small business use cases — funnel analysis, retention, and session replay are all present and maintained — but if you're evaluating PostHog specifically for its newer AI query features, test the cloud trial first and verify those features exist in the current self-hosted release before building a deployment around them.&lt;/p&gt;

&lt;p&gt;Where PostHog makes sense over the lighter tools: you're building a product (SaaS, app, internal tool) and you need behavioral cohorts, not just page view counts. The JavaScript snippet and the &lt;code&gt;posthog-js&lt;/code&gt; SDK capture custom events with properties, which means you can track &lt;code&gt;button_clicked&lt;/code&gt; with &lt;code&gt;{ plan: "pro", screen: "checkout" }&lt;/code&gt; and then filter session replays to only users who hit that event before churning. That's the capability gap. If your analytics need is "how many people visited the pricing page," Umami costs you nothing and runs in 256 MB. If your need is "show me session replays of users who started checkout, enabled the coupon field, and didn't convert," PostHog is the only self-hosted option in this list that actually answers that question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grafana + a Data Source: When You Already Have the Metrics
&lt;/h2&gt;

&lt;p&gt;Most analytics tool roundups slot Grafana in as if it competes with Plausible or Umami. It doesn't. Grafana visualizes data that already exists somewhere else — it has zero event ingestion capability of its own. No tracking pixel, no SDK, no JS snippet you embed on your site. If you don't already have a time-series store running, Grafana on its own is a dashboard with nothing to display. That's the first thing to establish, because it determines exactly who should use it.&lt;/p&gt;

&lt;p&gt;The case for including it here is specific: if you're already running Plausible (which uses ClickHouse) or Umami (PostgreSQL), both databases can be wired directly into Grafana as data sources. What that unlocks is correlation dashboards that no SaaS analytics product touches — overlay your Plausible pageview counts against Prometheus node CPU metrics, then drop in Loki annotations for deployment events. A traffic spike that coincides with a deploys and a CPU peg tells you something a standalone analytics UI never would. On my own setup I pull Umami's Postgres data alongside &lt;code&gt;node_exporter&lt;/code&gt; metrics in the same Grafana instance, and seeing both on one timeline is genuinely useful for distinguishing "the site got traffic" from "the site got slow."&lt;/p&gt;

&lt;p&gt;The resource story is split in two. Grafana itself is light — around 150 MB RSS at idle, negligible CPU unless you're rendering a lot of panels simultaneously. The real cost is the stack behind it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Grafana + Prometheus + Loki&lt;/strong&gt; together run roughly 1.5–2 GB before you add any exporters. That's meaningful on a small VPS.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Grafana + existing Plausible ClickHouse&lt;/strong&gt; adds almost nothing — you're querying a store you already pay for.&lt;/li&gt;
&lt;li&gt;  Alerting via Grafana Alertmanager adds another moving part; if you only want dashboards, disable it and save the complexity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Docker Compose addition to an existing Plausible stack is minimal. You expose the ClickHouse port internally and point Grafana at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# add to your existing plausible docker-compose.yml&lt;/span&gt;
  &lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/grafana-oss:10.4.2&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3001:3000"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;grafana_data:/var/lib/grafana&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# disable public signup — non-negotiable on a public host&lt;/span&gt;
      &lt;span class="na"&gt;GF_AUTH_DISABLE_LOGIN_FORM&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
      &lt;span class="na"&gt;GF_USERS_ALLOW_SIGN_UP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;plausible_db&lt;/span&gt;  &lt;span class="c1"&gt;# ensures ClickHouse is up first&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;grafana_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in the Grafana UI, add a ClickHouse data source (requires the &lt;code&gt;grafana-clickhouse-datasource&lt;/code&gt; plugin) pointed at &lt;code&gt;plausible_events_db&lt;/code&gt; on the internal Docker network. You can query Plausible's &lt;code&gt;events&lt;/code&gt; table directly with raw SQL panels — no intermediate ETL needed. The dealbreaker remains firm though: if you're starting from scratch with no existing metrics infrastructure and just need to know how many people visited your site, skip Grafana entirely and start with Umami or Plausible. Grafana earns its spot only when you're layering business observability on top of operational monitoring that's already running.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Pick What: A Decision Matrix for Operators
&lt;/h2&gt;

&lt;p&gt;The mistake most operators make is picking analytics software based on feature lists rather than operational fit. The question isn't which tool has the most capabilities — it's which one you'll still be running cleanly six months from now without having touched the config. These five tools solve genuinely different problems, and using the wrong one costs you either maintenance overhead or missing data.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Pick Plausible CE if&lt;/strong&gt; you want a production-ready, low-maintenance page analytics stack on a single VPS and you're comfortable dropping into ClickHouse when you need raw data. Plausible's Docker Compose setup is stable, the resource footprint is small, and the default dashboard covers 90% of what a content operator actually needs day-to-day. The trade-off: the API surface is narrow, and when you want something it doesn't expose in the UI, you're writing ClickHouse SQL directly — which is powerful but not a casual afternoon task.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Pick Umami if&lt;/strong&gt; you need multi-site tracking under a single install, your infrastructure already runs PostgreSQL, and you want a clean REST API for pulling stats into automation pipelines. Of every tool in this list, Umami is the easiest to integrate with n8n or a TypeScript/Node publishing engine running on PM2 — the &lt;code&gt;/api/websites/:id/stats&lt;/code&gt; endpoint returns structured JSON with no ceremony. Token auth works cleanly, response times are fast on even modest hardware, and the schema is simple enough to query directly if the API doesn't expose what you need.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Pick Matomo if&lt;/strong&gt; you have a real compliance requirement — GDPR audit trail, configurable consent management, or a data processing agreement you need to document — or if you have non-technical stakeholders who need GA-equivalent reports without learning a new interface. Matomo is the heaviest of the five operationally, but it's the only one where a marketing person can sit down and feel at home immediately. The plugin ecosystem also means you can add heatmaps, A/B testing, and form analytics without bolting on separate tools.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Pick PostHog if&lt;/strong&gt; you're operating a product rather than a content site and you need funnel analysis, feature flags, and session replay in one place. This is not a casual self-host — budget at least 8 GB RAM for the host, expect ClickHouse to be the memory floor, and read the resource requirements before committing. The payoff is that PostHog gives you product analytics depth that none of the others touch: cohort retention, event-level autocapture, and feature flag targeting that actually closes the loop between deploy and behavior data.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Layer Grafana on top of any of the above if&lt;/strong&gt; you're already running a home-lab or server monitoring stack and want business metrics in the same dashboard as node exporter, Caddy access logs, or container health. Grafana doesn't replace any of these tools — it reads from their underlying databases (Postgres, ClickHouse, MySQL) via datasource plugins and lets you build unified panels. On my workstation setup, having a single Grafana board showing site traffic alongside GPU utilization and n8n workflow success rates is genuinely more useful than toggling between four separate UIs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One cross-cutting signal worth flagging: if your stack is already PostgreSQL-heavy, Umami and Matomo both run on it natively, which means one fewer moving part in your backup and restore procedures. If you're already committed to ClickHouse for something else — or willing to learn it — Plausible and PostHog both use it as their event store, and the query power you get in return is significant. Don't introduce a second database engine just because a tool looks appealing; the operational cost compounds quietly until a disk fills or a migration breaks.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/5-self-hosted-analytics-tools-worth-running-on-your-own-hardware-in-2026/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Nagios + Grafana on Self-Hosted Hardware: A Real Monitoring Stack Without the Cloud Tax</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Wed, 29 Jul 2026 08:11:29 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/nagios-grafana-on-self-hosted-hardware-a-real-monitoring-stack-without-the-cloud-tax-4b2p</link>
      <guid>https://dev.to/ericwoooo_kr/nagios-grafana-on-self-hosted-hardware-a-real-monitoring-stack-without-the-cloud-tax-4b2p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The failure mode that actually bites self-hosted operators isn't a dramatic crash with error messages everywhere — it's a silent service exit at 2am that nobody notices until the automation pipeline produces zero output at 9am.  A container stops responding, a cron job silently s&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~24 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem: Flying Blind on Your Own Hardware&lt;/li&gt;
&lt;li&gt;Architecture Decision: Why Both Tools Instead of One&lt;/li&gt;
&lt;li&gt;Deploying Nagios Core in Docker&lt;/li&gt;
&lt;li&gt;Deploying Prometheus and Grafana in Docker&lt;/li&gt;
&lt;li&gt;Service Checks That Actually Matter for a Self-Hosted AI Stack&lt;/li&gt;
&lt;li&gt;Notification Routing and Avoiding Alert Fatigue&lt;/li&gt;
&lt;li&gt;Gotchas That Cost Real Time&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem: Flying Blind on Your Own Hardware
&lt;/h2&gt;

&lt;p&gt;The failure mode that actually bites self-hosted operators isn't a dramatic crash with error messages everywhere — it's a silent service exit at 2am that nobody notices until the automation pipeline produces zero output at 9am. A container stops responding, a cron job silently skips, a disk fills to 100% and writes start failing quietly. No alert fires because there's no alerting. No timeline exists because there's no metrics history. You're left doing forensics on stale logs trying to reconstruct what happened and when. That's the specific problem this guide solves.&lt;/p&gt;

&lt;p&gt;Cloud monitoring tools don't fit this situation for two compounding reasons. First, cost: Datadog, New Relic, and similar platforms price per host or per metric volume. A homelab or small self-hosted stack with eight to twelve machines gets expensive fast, and the pricing tiers are designed for teams with budgets, not solo operators paying out of pocket. Second, and more important for the threat model: these tools work by shipping your internal metrics — service names, CPU patterns, network topology, error strings — to someone else's infrastructure. If you're running services on your own hardware specifically to avoid that kind of data exposure, sending detailed telemetry about your internal systems to a SaaS vendor undermines the whole premise.&lt;/p&gt;

&lt;p&gt;A minimal but real monitoring stack needs three layers that most incomplete setups are missing at least one of. Host-level checks cover the basics that will kill you if ignored: CPU sustained above threshold, RAM pressure causing swap, disk partitions approaching full, load average spiking. Service-level checks go further — they verify that the thing you care about is actually &lt;em&gt;responding&lt;/em&gt;, not just that the process is listed in &lt;code&gt;ps&lt;/code&gt;. A Docker container can show as &lt;code&gt;Up 3 days&lt;/code&gt; while the application inside it has deadlocked and stopped accepting connections. Visual dashboards handle the third failure mode: slow degradation. Disk growing at 2GB/day, RAM usage climbing 5% weekly — these don't trigger threshold alerts until it's already an emergency, but they're obvious on a time-series graph if you look.&lt;/p&gt;

&lt;p&gt;The stack covered here is Nagios Core for check execution and alerting, Prometheus for metrics collection, and Grafana for dashboards — all running in Docker on the same LAN segment as the workloads they monitor. That last point matters: the monitoring stack has no dependency on external connectivity. If your internet goes down, you still get alerts on the local network. Nagios handles the "is this service up right now?" question with active checks and notification routing. Prometheus handles the "what has this looked like over the past 30 days?" question with time-series storage. Grafana sits in front of Prometheus and makes that history readable without writing PromQL from scratch every time. These tools have been around long enough that their rough edges are documented, their failure modes are known, and their Docker images are stable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Decision: Why Both Tools Instead of One
&lt;/h2&gt;

&lt;p&gt;The tempting shortcut is picking one tool and bending it to cover both jobs. Grafana has alerting. Prometheus has threshold rules. Nagios has a built-in web dashboard. Every one of those "just use this one thing" paths leads to a system that half-works in two directions instead of working well in one. The clean split is this: Nagios owns the pager, Grafana owns the screen. Once you commit to that boundary, the entire stack stops fighting itself.&lt;/p&gt;

&lt;p&gt;Nagios Core does threshold-based alerting with a notification pipeline that's been production-hardened for decades. You define a check, set warning and critical thresholds, wire up a contact group, and when a disk hits 90% you get paged — via email, a PagerDuty webhook, or a Slack incoming hook. That pipeline is synchronous, stateful, and has built-in escalation logic. What Nagios &lt;em&gt;cannot&lt;/em&gt; do is show you what CPU utilization looked like at 3am last Tuesday, or whether your container memory has been creeping up 200MB/week for the last month. It has no time-series store. Asking it to draw trends is like asking a smoke detector to show you a temperature graph.&lt;/p&gt;

&lt;p&gt;That gap is exactly what the Prometheus + Grafana layer fills. On my 32GB workstation running several Ollama model-serving processes, the GPU utilization dashboard in Grafana is the only thing that's caught a slow VRAM leak before it hard-crashed the inference process. The symptom wasn't a threshold breach — VRAM never hit 100% in a single scrape — it was a 48-hour upward slope that only showed up when you looked at the sparkline. Nagios would have slept through that entirely. Grafana + &lt;code&gt;nvidia_smi_exporter&lt;/code&gt; scraped every 30 seconds made it visible in under a day.&lt;/p&gt;

&lt;p&gt;The overlap trap catches most people at the Grafana alerting step. Grafana can absolutely fire alerts. Prometheus has &lt;code&gt;ALERT&lt;/code&gt; rules. If you turn both on alongside Nagios, you end up with three systems that can all page you for the same event — and worse, you'll find yourself tuning suppression rules across three different UIs to stop the duplication. The maintenance cost compounds fast. The rule is: &lt;strong&gt;one pager, one dashboard&lt;/strong&gt;. Silence Grafana's alerting entirely. Do not configure Alertmanager to send pages. Let Nagios own all notification routing, period. This sounds like a constraint but it's actually a relief.&lt;/p&gt;

&lt;p&gt;On resource cost: Nagios Core is almost free at runtime. Expect under 100MB RSS even with a few hundred active checks. Prometheus is a different story. With a 15-day retention window, scraping 5 hosts every 15 seconds at moderate metric cardinality (node_exporter default is around 800 metrics per host), you'll accumulate roughly 2–4GB of TSDB data per day depending on how many containers and custom exporters you add. Before you deploy, size your &lt;code&gt;--storage.tsdb.retention.time&lt;/code&gt; and make sure the target volume can absorb it. On a home lab with a spinning disk, that's manageable. On a VPS with expensive block storage, it becomes a cost decision. The flag to control it in your compose file looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--config.file=/etc/prometheus/prometheus.yml'&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.path=/prometheus'&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.retention.time=15d'&lt;/span&gt;   &lt;span class="c1"&gt;# tune before first deploy&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.retention.size=20GB'&lt;/span&gt;  &lt;span class="c1"&gt;# hard cap as a safety net&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set both flags. The time-based retention is the primary control; the size cap is the circuit breaker if your cardinality explodes after adding a new exporter. Without the size cap, a misconfigured exporter emitting high-cardinality labels (per-request URLs are the classic offender) can fill a disk inside 48 hours on an otherwise healthy setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying Nagios Core in Docker
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;jasonrivers/nagios&lt;/code&gt; image is the community-maintained option that's actually kept up — the official Nagios dockerfiles have gone stale. Pin to a specific tag. &lt;code&gt;4.4.14&lt;/code&gt; is stable as of this writing. The reason to pin isn't pedantry: Nagios config syntax has genuinely shifted between minor versions, particularly around the &lt;code&gt;use&lt;/code&gt; inheritance directive and host template parsing. Running &lt;code&gt;latest&lt;/code&gt; means a surprise rebuild can silently break your config validation, and you won't know until you restart the container and nagios refuses to start.&lt;/p&gt;

&lt;p&gt;Here's a minimal &lt;code&gt;docker-compose.yml&lt;/code&gt; that mounts your config and state directories separately — this matters because you want to edit config files outside the container lifecycle, and you don't want a container rebuild wiping your status history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nagios&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jasonrivers/nagios:4.4.14&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nagios&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# Bind only to your LAN interface — never 0.0.0.0 on a monitoring host&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.168.1.10:8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nagios/etc:/opt/nagios/etc&lt;/span&gt;      &lt;span class="c1"&gt;# all config lives here&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nagios/var:/opt/nagios/var&lt;/span&gt;      &lt;span class="c1"&gt;# status.dat, logs, retention&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NAGIOSADMIN_USER=nagiosadmin&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NAGIOSADMIN_PASS=changeme_before_first_run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The port binding &lt;code&gt;192.168.1.10:8080:80&lt;/code&gt; is doing real work. If you bind to &lt;code&gt;0.0.0.0&lt;/code&gt;, the Nagios web UI is reachable from anywhere the host is reachable, which on a typical home lab includes WAN if you've got port forwarding open for other services. Bind to your actual LAN IP and that attack surface disappears. Your first run goal is simple: get the UI green on the default &lt;code&gt;localhost&lt;/code&gt; host before touching anything else.&lt;/p&gt;

&lt;p&gt;Once the container is up, add your first real host. Drop a file at &lt;code&gt;./nagios/etc/objects/hosts.cfg&lt;/code&gt; — the container's default &lt;code&gt;nagios.cfg&lt;/code&gt; includes &lt;code&gt;cfg_dir=/opt/nagios/etc/objects&lt;/code&gt; so any &lt;code&gt;.cfg&lt;/code&gt; file there gets picked up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;define&lt;/span&gt; &lt;span class="k"&gt;host&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;host_name&lt;/span&gt;               &lt;span class="n"&gt;fileserver&lt;/span&gt;
    &lt;span class="k"&gt;alias&lt;/span&gt;                   &lt;span class="n"&gt;Main&lt;/span&gt; &lt;span class="n"&gt;File&lt;/span&gt; &lt;span class="n"&gt;Server&lt;/span&gt;
    &lt;span class="n"&gt;address&lt;/span&gt;                 &lt;span class="mi"&gt;192&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;168&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;
    &lt;span class="n"&gt;check_command&lt;/span&gt;           &lt;span class="k"&gt;check&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;host&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;alive&lt;/span&gt;
    &lt;span class="n"&gt;max_check_attempts&lt;/span&gt;      &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="n"&gt;check_period&lt;/span&gt;            &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="n"&gt;x7&lt;/span&gt;
    &lt;span class="n"&gt;notification_period&lt;/span&gt;     &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="n"&gt;x7&lt;/span&gt;
    &lt;span class="n"&gt;contacts&lt;/span&gt;                &lt;span class="n"&gt;nagiosadmin&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validate before restarting — this catches syntax errors without a full restart cycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;nagios /opt/nagios/bin/nagios &lt;span class="nt"&gt;-v&lt;/span&gt; /opt/nagios/etc/nagios.cfg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If validation passes, restart the container and confirm the host shows green in the web UI. Don't pile on service checks until you've confirmed ICMP reach works. A host that shows as DOWN before you've added any services tells you immediately whether it's a network or config problem.&lt;/p&gt;

&lt;p&gt;For real host metrics — disk, CPU, running processes — you need NRPE on each monitored machine. The &lt;code&gt;nagios-nrpe-server&lt;/code&gt; package on Ubuntu/Debian installs the daemon and a default config at &lt;code&gt;/etc/nagios/nrpe.cfg&lt;/code&gt;. The critical setting that most setups get wrong initially is &lt;code&gt;allowed_hosts&lt;/code&gt;. It must contain the &lt;em&gt;IP your Nagios container uses when making outbound connections&lt;/em&gt; — which is the Docker host's LAN IP if you're using bridge networking, not the container's internal IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/nagios/nrpe.cfg on the monitored host
&lt;/span&gt;&lt;span class="py"&gt;allowed_hosts&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1,192.168.1.10   # 192.168.1.10 = your Nagios Docker host&lt;/span&gt;

&lt;span class="c"&gt;# These ship by default — verify they're present and paths are correct for your distro
&lt;/span&gt;&lt;span class="err"&gt;command&lt;/span&gt;&lt;span class="nn"&gt;[check_users]&lt;/span&gt;&lt;span class="err"&gt;=/usr/lib/nagios/plugins/check_users&lt;/span&gt; &lt;span class="err"&gt;-w&lt;/span&gt; &lt;span class="err"&gt;5&lt;/span&gt; &lt;span class="err"&gt;-c&lt;/span&gt; &lt;span class="err"&gt;10&lt;/span&gt;
&lt;span class="err"&gt;command&lt;/span&gt;&lt;span class="nn"&gt;[check_load]&lt;/span&gt;&lt;span class="err"&gt;=/usr/lib/nagios/plugins/check_load&lt;/span&gt; &lt;span class="err"&gt;-w&lt;/span&gt; &lt;span class="err"&gt;15,10,5&lt;/span&gt; &lt;span class="err"&gt;-c&lt;/span&gt; &lt;span class="err"&gt;30,25,20&lt;/span&gt;
&lt;span class="err"&gt;command&lt;/span&gt;&lt;span class="nn"&gt;[check_disk]&lt;/span&gt;&lt;span class="err"&gt;=/usr/lib/nagios/plugins/check_disk&lt;/span&gt; &lt;span class="err"&gt;-w&lt;/span&gt; &lt;span class="err"&gt;20%&lt;/span&gt; &lt;span class="err"&gt;-c&lt;/span&gt; &lt;span class="err"&gt;10%&lt;/span&gt; &lt;span class="err"&gt;-p&lt;/span&gt; &lt;span class="err"&gt;/&lt;/span&gt;
&lt;span class="err"&gt;command&lt;/span&gt;&lt;span class="nn"&gt;[check_zombie_procs]&lt;/span&gt;&lt;span class="err"&gt;=/usr/lib/nagios/plugins/check_procs&lt;/span&gt; &lt;span class="err"&gt;-w&lt;/span&gt; &lt;span class="err"&gt;5&lt;/span&gt; &lt;span class="err"&gt;-c&lt;/span&gt; &lt;span class="err"&gt;10&lt;/span&gt; &lt;span class="err"&gt;-s&lt;/span&gt; &lt;span class="err"&gt;Z&lt;/span&gt;
&lt;span class="err"&gt;command&lt;/span&gt;&lt;span class="nn"&gt;[check_total_procs]&lt;/span&gt;&lt;span class="err"&gt;=/usr/lib/nagios/plugins/check_procs&lt;/span&gt; &lt;span class="err"&gt;-w&lt;/span&gt; &lt;span class="err"&gt;150&lt;/span&gt; &lt;span class="err"&gt;-c&lt;/span&gt; &lt;span class="err"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the Nagios container side, you need a &lt;code&gt;check_nrpe&lt;/code&gt; command definition. The &lt;code&gt;jasonrivers/nagios&lt;/code&gt; image ships with the NRPE plugin already installed, so you only need the command object in your config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="o"&gt;#&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;nagios&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;etc&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;commands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;append&lt;/span&gt; &lt;span class="n"&gt;this&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;
&lt;span class="n"&gt;define&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;command_name&lt;/span&gt;    &lt;span class="n"&gt;check_nrpe&lt;/span&gt;
    &lt;span class="o"&gt;#&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="k"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;defined&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;nrpe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;
    &lt;span class="n"&gt;command_line&lt;/span&gt;    &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;nagios&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;libexec&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;check_nrpe&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;HOSTADDRESS&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;ARG1&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then wire it into a service definition for your host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;define&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;host_name&lt;/span&gt;               &lt;span class="n"&gt;fileserver&lt;/span&gt;
    &lt;span class="n"&gt;service_description&lt;/span&gt;     &lt;span class="n"&gt;Disk&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;
    &lt;span class="n"&gt;check_command&lt;/span&gt;           &lt;span class="n"&gt;check_nrpe&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;check_disk&lt;/span&gt;
    &lt;span class="n"&gt;check_interval&lt;/span&gt;          &lt;span class="mi"&gt;5&lt;/span&gt;
    &lt;span class="n"&gt;max_check_attempts&lt;/span&gt;      &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="n"&gt;check_period&lt;/span&gt;            &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="n"&gt;x7&lt;/span&gt;
    &lt;span class="n"&gt;notification_period&lt;/span&gt;     &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="n"&gt;x7&lt;/span&gt;
    &lt;span class="n"&gt;contacts&lt;/span&gt;                &lt;span class="n"&gt;nagiosadmin&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A common failure mode here: NRPE returns "Connection refused" even with correct &lt;code&gt;allowed_hosts&lt;/code&gt;. Check whether &lt;code&gt;nagios-nrpe-server&lt;/code&gt; is actually listening — &lt;code&gt;ss -tlnp | grep 5666&lt;/code&gt; on the monitored host. Some Ubuntu installs require &lt;code&gt;systemctl enable --now nagios-nrpe-server&lt;/code&gt; explicitly. The other gotcha is firewall rules: if &lt;code&gt;ufw&lt;/code&gt; is active, you need &lt;code&gt;ufw allow from 192.168.1.10 to any port 5666&lt;/code&gt; on the monitored host before NRPE will respond.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying Prometheus and Grafana in Docker
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Scrape Chain Before You Touch Compose
&lt;/h3&gt;

&lt;p&gt;The mental model that makes this whole stack click: Prometheus pulls, it never receives pushes. Every host you want to monitor needs something listening on a port that Prometheus can scrape. For bare-metal or VM system metrics, that's &lt;code&gt;node_exporter&lt;/code&gt; on port 9100. For per-container metrics on your Docker host, that's &lt;code&gt;cadvisor&lt;/code&gt; on port 8080 — except if Nagios is already bound to 8080 (a common default for its web UI), you'll hit a silent conflict. Run cadvisor on 8081 instead and adjust your prometheus.yml accordingly. The conflict won't crash anything; Docker will just refuse to start cadvisor, and you'll have a gap in your metrics you won't notice until you wonder why all your container panels are empty.&lt;/p&gt;

&lt;p&gt;Node exporter runs best as a systemd service on bare-metal hosts rather than a Docker container, because getting accurate host-level metrics from inside a container requires &lt;code&gt;--pid=host --net=host --privileged&lt;/code&gt; — at which point you've basically given it host access anyway. The systemd approach is cleaner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Download the current release (check github.com/prometheus/node_exporter/releases for latest)&lt;/span&gt;
wget https://github.com/prometheus/node_exporter/releases/download/v1.8.2/node_exporter-1.8.2.linux-amd64.tar.gz
&lt;span class="nb"&gt;tar &lt;/span&gt;xvf node_exporter-1.8.2.linux-amd64.tar.gz
&lt;span class="nb"&gt;sudo cp &lt;/span&gt;node_exporter-1.8.2.linux-amd64/node_exporter /usr/local/bin/

&lt;span class="c"&gt;# /etc/systemd/system/node_exporter.service&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;Unit]
&lt;span class="nv"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Prometheus Node Exporter
&lt;span class="nv"&gt;After&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;network.target

&lt;span class="o"&gt;[&lt;/span&gt;Service]
&lt;span class="nv"&gt;User&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;node_exporter
&lt;span class="nv"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/local/bin/node_exporter
&lt;span class="nv"&gt;Restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on-failure

&lt;span class="o"&gt;[&lt;/span&gt;Install]
&lt;span class="nv"&gt;WantedBy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;multi-user.target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cadvisor on the Docker host is a different story — it needs access to the Docker socket and cgroup filesystem, so running it as a container with the right mounts is the standard approach. Pin the version; the &lt;code&gt;latest&lt;/code&gt; tag on cadvisor has historically had breaking changes between minor versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;cadvisor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gcr.io/cadvisor/cadvisor:v0.49.1&lt;/span&gt;
  &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cadvisor&lt;/span&gt;
  &lt;span class="na"&gt;privileged&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8081:8080"&lt;/span&gt;   &lt;span class="c1"&gt;# left side changed to avoid Nagios conflict&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/:/rootfs:ro&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run:/var/run:ro&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/sys:/sys:ro&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/lib/docker/:/var/lib/docker:ro&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/disk/:/dev/disk:ro&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  prometheus.yml and the Retention Flag You Shouldn't Skip
&lt;/h3&gt;

&lt;p&gt;The default retention in Prometheus is 15 days, which sounds fine until you realize the default storage path (&lt;code&gt;/prometheus&lt;/code&gt; inside the container) will eat through disk silently. On a host with a small root partition, this shows up as a full disk with no obvious culprit. Always pass &lt;code&gt;--storage.tsdb.retention.time=15d&lt;/code&gt; explicitly in your Docker command or compose file — not because it changes the default, but because it documents your intent and makes the behavior predictable when you later tune it. A minimal but complete prometheus.yml:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# prometheus.yml&lt;/span&gt;
&lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scrape_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;
  &lt;span class="na"&gt;evaluation_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;

&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;node'&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;host1.lan:9100'&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;host2.lan:9100'&lt;/span&gt;
          &lt;span class="c1"&gt;# add every host running node_exporter here&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cadvisor'&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;localhost:8081'&lt;/span&gt;   &lt;span class="c1"&gt;# matches the remapped port above&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Prometheus container block in compose, with the retention flag explicit and storage on a named volume so data survives container rebuilds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/prometheus:v2.53.0&lt;/span&gt;
  &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--config.file=/etc/prometheus/prometheus.yml'&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.path=/prometheus'&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.retention.time=15d'&lt;/span&gt;   &lt;span class="c1"&gt;# explicit, not assumed&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--web.enable-lifecycle'&lt;/span&gt;               &lt;span class="c1"&gt;# lets you reload config via POST /-/reload&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./prometheus.yml:/etc/prometheus/prometheus.yml:ro&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;prometheus_data:/prometheus&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9090:9090"&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Grafana: Provisioning Over Manual Config
&lt;/h3&gt;

&lt;p&gt;If you configure the Prometheus data source manually through the Grafana UI, the next time you rebuild the container it's gone. Provisioning via YAML solves this — Grafana reads datasource definitions from a mounted directory at startup. The setup requires two pieces: the compose block and a provisioning file on disk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/grafana:11.1.0&lt;/span&gt;
  &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}&lt;/span&gt;  &lt;span class="c1"&gt;# set in .env, never hardcode&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GF_USERS_ALLOW_SIGN_UP=false&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;grafana_data:/var/lib/grafana&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./grafana/provisioning:/etc/grafana/provisioning:ro&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# grafana/provisioning/datasources/prometheus.yml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;

&lt;span class="na"&gt;datasources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prometheus&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
    &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://prometheus:9090&lt;/span&gt;   &lt;span class="c1"&gt;# container name resolves via Docker DNS&lt;/span&gt;
    &lt;span class="na"&gt;isDefault&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;editable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;               &lt;span class="c1"&gt;# prevents accidental UI changes from persisting&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;editable: false&lt;/code&gt; flag is worth setting if you want the provisioned config to be the source of truth. Without it, someone can modify the datasource in the UI, and the next restart will silently overwrite their changes with the provisioned values — which is confusing to debug. Make the behavior explicit either way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dashboards 1860 and 14282: Useful Starting Points, Not Blind Trusts
&lt;/h3&gt;

&lt;p&gt;Dashboard ID &lt;strong&gt;1860&lt;/strong&gt; (Node Exporter Full) and &lt;strong&gt;14282&lt;/strong&gt; (Docker and system monitoring via cadvisor) are real, published dashboards on grafana.com. Both are worth importing as a starting point — they save hours of panel configuration. Import them via Grafana's UI at &lt;code&gt;Dashboards → Import → Enter ID&lt;/code&gt;, or provision them as JSON files. The honest caveat: both dashboards include panels that query metrics your specific exporter version or configuration may not expose. A panel showing "No data" isn't broken Grafana — it means the metric the panel queries (&lt;code&gt;node_filesystem_avail_bytes&lt;/code&gt; with a specific label, for instance) doesn't exist in your scrape. Audit the panel queries before you put these dashboards on a wall display or alert on them. Delete panels that consistently return no data rather than leaving a dashboard full of empty graphs, which trains you to ignore gaps.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;1860 common gap:&lt;/strong&gt; Some network interface panels use label matchers that don't match non-standard interface names (like &lt;code&gt;enp3s0&lt;/code&gt; vs &lt;code&gt;eth0&lt;/code&gt;). Edit the panel query's &lt;code&gt;device&lt;/code&gt; label filter to match your actual interface names.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;14282 common gap:&lt;/strong&gt; Container name labels in cadvisor changed format across versions. If you're on cadvisor v0.47+, the &lt;code&gt;name&lt;/code&gt; label on container metrics dropped the leading slash — old dashboard queries using &lt;code&gt;=~"/.*"&lt;/code&gt; may not match.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Both dashboards:&lt;/strong&gt; Check which Grafana version they target. A dashboard built for Grafana 9 using the old Prometheus query editor may render differently in Grafana 11, particularly around panel transformations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Service Checks That Actually Matter for a Self-Hosted AI Stack
&lt;/h2&gt;

&lt;p&gt;Most Nagios setups default to checking whether a port is open and calling it done. For a self-hosted AI stack, that misses every failure mode that actually bites you — the Ollama process that's listening but has unloaded all models due to memory pressure, the n8n container that restarts every 90 seconds but always passes a "is it running?" check, the model storage volume that hits 95% because you pulled a 70B weight without thinking.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking Ollama Properly: /api/tags, Not Just Port 11434
&lt;/h3&gt;

&lt;p&gt;A TCP port check against 11434 will return OK even if Ollama is mid-crash or stuck in an unresponsive state after an OOM kill. The &lt;code&gt;/api/tags&lt;/code&gt; endpoint is the right target — it forces a real HTTP round-trip through the Ollama HTTP layer and returns a 200 with a JSON body listing loaded models. If that returns a 200, the server is actually alive. Use &lt;code&gt;check_http&lt;/code&gt; with the &lt;code&gt;-u&lt;/code&gt; and &lt;code&gt;-e&lt;/code&gt; flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="o"&gt;#&lt;/span&gt; &lt;span class="k"&gt;In&lt;/span&gt; &lt;span class="n"&gt;your&lt;/span&gt; &lt;span class="n"&gt;Nagios&lt;/span&gt; &lt;span class="n"&gt;commands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;
&lt;span class="n"&gt;define&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;command_name&lt;/span&gt;  &lt;span class="n"&gt;check_ollama_api&lt;/span&gt;
    &lt;span class="n"&gt;command_line&lt;/span&gt;  &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;USER1&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;check_http&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;HOSTADDRESS&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="mi"&gt;11434&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
                  &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="nv"&gt;"200 OK"&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
                  &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
    &lt;span class="o"&gt;#&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;/-&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;are&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="n"&gt;thresholds&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;
    &lt;span class="o"&gt;#&lt;/span&gt; &lt;span class="n"&gt;Ollama&lt;/span&gt; &lt;span class="n"&gt;can&lt;/span&gt; &lt;span class="n"&gt;be&lt;/span&gt; &lt;span class="n"&gt;slow&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;respond&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;load&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;define&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;use&lt;/span&gt;                   &lt;span class="n"&gt;generic&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;
    &lt;span class="n"&gt;host_name&lt;/span&gt;             &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;workstation&lt;/span&gt;
    &lt;span class="n"&gt;service_description&lt;/span&gt;   &lt;span class="n"&gt;Ollama&lt;/span&gt; &lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="n"&gt;Health&lt;/span&gt;
    &lt;span class="n"&gt;check_command&lt;/span&gt;         &lt;span class="n"&gt;check_ollama_api&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set your warning threshold at 5 seconds and critical at 10. A healthy Ollama responding to &lt;code&gt;/api/tags&lt;/code&gt; with no models loading takes under 200ms. If it's crossing 5s, something is wrong with the model server even if the port is technically open. On my 32GB box, slow responses to that endpoint have reliably preceded an OOM event by a few minutes — the server was thrashing trying to keep a model resident.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disk Checks Against the Model Storage Mount Specifically
&lt;/h3&gt;

&lt;p&gt;Ollama model weights live in &lt;code&gt;~/.ollama/models&lt;/code&gt; by default, and a single model file can run anywhere from 4GB (a quantized 7B) to 70GB+ for a full-precision 70B. The root partition check that ships with most Nagios templates is useless here — model storage is almost always on a separate mount or at minimum a different logical volume. Check the actual mount point where model files land:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="o"&gt;#&lt;/span&gt; &lt;span class="k"&gt;Using&lt;/span&gt; &lt;span class="n"&gt;check_disk&lt;/span&gt; &lt;span class="n"&gt;against&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;dedicated&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt; &lt;span class="n"&gt;mount&lt;/span&gt;
&lt;span class="n"&gt;define&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;command_name&lt;/span&gt;  &lt;span class="n"&gt;check_model_storage&lt;/span&gt;
    &lt;span class="n"&gt;command_line&lt;/span&gt;  &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;USER1&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;check_disk&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
                  &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;mnt&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;
    &lt;span class="o"&gt;#&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;means&lt;/span&gt; &lt;span class="n"&gt;warn&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="k"&gt;FREE&lt;/span&gt; &lt;span class="n"&gt;remains&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;#&lt;/span&gt; &lt;span class="n"&gt;Nagios&lt;/span&gt; &lt;span class="n"&gt;check_disk&lt;/span&gt; &lt;span class="n"&gt;uses&lt;/span&gt; &lt;span class="k"&gt;free&lt;/span&gt; &lt;span class="k"&gt;space&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;easy&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt; &lt;span class="n"&gt;backwards&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;define&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;use&lt;/span&gt;                   &lt;span class="n"&gt;generic&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;
    &lt;span class="n"&gt;host_name&lt;/span&gt;             &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;workstation&lt;/span&gt;
    &lt;span class="n"&gt;service_description&lt;/span&gt;   &lt;span class="n"&gt;Model&lt;/span&gt; &lt;span class="k"&gt;Storage&lt;/span&gt; &lt;span class="n"&gt;Disk&lt;/span&gt;
    &lt;span class="n"&gt;check_command&lt;/span&gt;         &lt;span class="n"&gt;check_model_storage&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the inverted logic: &lt;code&gt;check_disk&lt;/code&gt; warns when &lt;em&gt;free space&lt;/em&gt; drops below the threshold, so &lt;code&gt;-w 20%&lt;/code&gt; triggers the warning when you're at 80% used, and &lt;code&gt;-c 10%&lt;/code&gt; fires critical at 90% used. If you write it as &lt;code&gt;-w 80%&lt;/code&gt; thinking "warn at 80%", you'll fire warnings constantly because 80% free space almost never exists on a volume full of model weights. Get this backwards once and you'll spend 20 minutes debugging why your disk check is always critical.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker Container State via NRPE: Catching Silent Exits
&lt;/h3&gt;

&lt;p&gt;Docker's built-in healthcheck and Nagios's &lt;code&gt;check_procs&lt;/code&gt; both miss the same failure: a container that exited cleanly with code 0 and had no restart policy. The Docker daemon reports the engine as healthy, the process table shows no zombie, and everything looks fine until you notice n8n hasn't processed a workflow in 3 hours. A custom NRPE script catches this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# /usr/local/lib/nagios/plugins/check_docker_container&lt;/span&gt;
&lt;span class="c"&gt;# Run via NRPE on the host — requires docker group membership for the nagios user&lt;/span&gt;

&lt;span class="nv"&gt;CONTAINER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"UNKNOWN: No container name specified"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;3
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nv"&gt;RUNNING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{{.State.Running}}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;EXIT_CODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{{.State.ExitCode}}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CRITICAL: Container &lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt; not found"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;span class="k"&gt;fi

if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUNNING&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OK: &lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt; is running"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CRITICAL: &lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt; is not running (exit code: &lt;/span&gt;&lt;span class="nv"&gt;$EXIT_CODE&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# In nrpe.cfg on the monitored host
&lt;/span&gt;&lt;span class="err"&gt;command&lt;/span&gt;&lt;span class="nn"&gt;[check_n8n_container]&lt;/span&gt;&lt;span class="err"&gt;=/usr/local/lib/nagios/plugins/check_docker_container&lt;/span&gt; &lt;span class="err"&gt;n8n&lt;/span&gt;
&lt;span class="err"&gt;command&lt;/span&gt;&lt;span class="nn"&gt;[check_ollama_container]&lt;/span&gt;&lt;span class="err"&gt;=/usr/local/lib/nagios/plugins/check_docker_container&lt;/span&gt; &lt;span class="err"&gt;ollama&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the &lt;code&gt;nagios&lt;/code&gt; user to the &lt;code&gt;docker&lt;/code&gt; group on the host, or this script will fail silently with a permission error that &lt;code&gt;$?&lt;/code&gt; returns as non-zero but the output is empty. That produces &lt;code&gt;CRITICAL: Container n8n not found&lt;/code&gt; even when the container is fine — a confusing false positive that wastes time.&lt;/p&gt;

&lt;h3&gt;
  
  
  n8n Crash-Loop Detection: Uptime Window Check
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;/healthz&lt;/code&gt; endpoint on n8n (exposed on port 5678 by default) returns a 200 if the process is responsive. That's necessary but not sufficient. A container that crash-loops every 90 seconds with a &lt;code&gt;--restart=always&lt;/code&gt; policy will pass the healthcheck right after each restart — it's technically healthy for 60 of those 90 seconds. A separate check against container uptime catches this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# /usr/local/lib/nagios/plugins/check_container_uptime&lt;/span&gt;
&lt;span class="c"&gt;# Warns if a container has been running for less than N seconds — &lt;/span&gt;
&lt;span class="c"&gt;# designed to catch restart loops, not replace the running-state check&lt;/span&gt;

&lt;span class="nv"&gt;CONTAINER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;
&lt;span class="nv"&gt;MIN_UPTIME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;300&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;# default: warn if running less than 5 minutes&lt;/span&gt;

&lt;span class="nv"&gt;STARTED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{{.State.StartedAt}}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"UNKNOWN: Cannot inspect &lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;3
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Convert ISO8601 timestamp to epoch — requires GNU date&lt;/span&gt;
&lt;span class="nv"&gt;STARTED_EPOCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STARTED&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;NOW_EPOCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;UPTIME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;NOW_EPOCH &lt;span class="o"&gt;-&lt;/span&gt; STARTED_EPOCH&lt;span class="k"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UPTIME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MIN_UPTIME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WARNING: &lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt; only up for &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;UPTIME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s (threshold: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MIN_UPTIME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s) — possible restart loop"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OK: &lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt; up for &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;UPTIME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# NRPE command entry
&lt;/span&gt;&lt;span class="err"&gt;command&lt;/span&gt;&lt;span class="nn"&gt;[check_n8n_uptime]&lt;/span&gt;&lt;span class="err"&gt;=/usr/local/lib/nagios/plugins/check_container_uptime&lt;/span&gt; &lt;span class="err"&gt;n8n&lt;/span&gt; &lt;span class="err"&gt;300&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair this with a 2-minute Nagios check interval and a 1-occurrence alert threshold. A single restart isn't always alarming — Docker will restart a container after an update or a host reboot. The signal you want is the check firing on consecutive runs: if n8n's uptime resets between each 2-minute polling cycle, something is actively wrong. Set &lt;code&gt;max_check_attempts 3&lt;/code&gt; and &lt;code&gt;notification_interval 0&lt;/code&gt; in the service definition so you get one alert per event, not a flood every 2 minutes during a crash loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notification Routing and Avoiding Alert Fatigue
&lt;/h2&gt;

&lt;p&gt;The most common failure mode in self-hosted monitoring isn't missing an alert — it's generating so many that you start unconsciously filtering them yourself. Once your brain learns to skim past Nagios emails, the whole system is broken. The fix isn't smarter alerts; it's routing the right severity to the right channel before you ever hit that point.&lt;/p&gt;

&lt;p&gt;Set up two contacts: one for email via Postfix or an SMTP relay container, one for Slack via a shell script wrapping &lt;code&gt;curl&lt;/code&gt;. The Slack contact should receive &lt;strong&gt;CRITICAL only&lt;/strong&gt;. Email handles WARNING. This isn't just aesthetics — a Slack ping at 2am demands attention, an email at 6am gets triaged with coffee. Here's the shell script pattern for the Slack contact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# /usr/local/bin/notify-slack.sh&lt;/span&gt;
&lt;span class="c"&gt;# Called by Nagios with env vars set by the contact definition&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s2"&gt;"{
    &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;*[&lt;/span&gt;&lt;span class="nv"&gt;$NAGIOS_NOTIFICATIONTYPE&lt;/span&gt;&lt;span class="s2"&gt;]* &lt;/span&gt;&lt;span class="nv"&gt;$NAGIOS_HOSTNAME&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="nv"&gt;$NAGIOS_SERVICESTATE&lt;/span&gt;&lt;span class="s2"&gt; — &lt;/span&gt;&lt;span class="nv"&gt;$NAGIOS_SERVICEOUTPUT&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;
  }"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire it into the contact definition like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;define&lt;/span&gt; &lt;span class="n"&gt;contact&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;contact_name&lt;/span&gt;                    &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;slack&lt;/span&gt;
    &lt;span class="n"&gt;service_notification_commands&lt;/span&gt;   &lt;span class="k"&gt;notify&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;by&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;slack&lt;/span&gt;
    &lt;span class="n"&gt;service_notification_options&lt;/span&gt;    &lt;span class="k"&gt;c&lt;/span&gt;          &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;CRITICAL&lt;/span&gt; &lt;span class="k"&gt;only&lt;/span&gt;
    &lt;span class="n"&gt;host_notification_options&lt;/span&gt;       &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;        &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;DOWN&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;UNREACHABLE&lt;/span&gt; &lt;span class="k"&gt;only&lt;/span&gt;
    &lt;span class="n"&gt;service_notification_period&lt;/span&gt;     &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="n"&gt;x7&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;define&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;command_name&lt;/span&gt;    &lt;span class="k"&gt;notify&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;by&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;slack&lt;/span&gt;
    &lt;span class="n"&gt;command_line&lt;/span&gt;    &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;usr&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;local&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;bin&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;notify&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;slack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sh&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;notification_interval&lt;/code&gt; trap catches nearly every solo operator eventually. Nagios defaults to re-notifying every 30 minutes on a still-failing check. If you're running overnight model jobs on a GPU box and something goes sideways at 11pm, you will wake up to a dozen Slack pings by morning and start associating the channel with noise. For a single-operator setup, push that to 60 or 120 minutes on non-critical services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;define&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;host_name&lt;/span&gt;                       &lt;span class="n"&gt;gpu&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;workstation&lt;/span&gt;
    &lt;span class="n"&gt;service_description&lt;/span&gt;             &lt;span class="n"&gt;VRAM&lt;/span&gt; &lt;span class="k"&gt;Usage&lt;/span&gt;
    &lt;span class="n"&gt;check_command&lt;/span&gt;                   &lt;span class="n"&gt;check_vram_util&lt;/span&gt;
    &lt;span class="n"&gt;notification_interval&lt;/span&gt;           &lt;span class="mi"&gt;120&lt;/span&gt;   &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;notify&lt;/span&gt; &lt;span class="k"&gt;every&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="k"&gt;min&lt;/span&gt;
    &lt;span class="n"&gt;first_notification_delay&lt;/span&gt;        &lt;span class="mi"&gt;5&lt;/span&gt;
    &lt;span class="n"&gt;notification_options&lt;/span&gt;            &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;
    &lt;span class="n"&gt;contacts&lt;/span&gt;                        &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scheduled downtime is the other lever people skip until they regret it. Patching a box at midnight without setting downtime first means Nagios fires host-down and service-down notifications for every check on that host — easily 30-40 emails depending on how many services you're monitoring. The Nagios web UI handles this under &lt;em&gt;System → Schedule Downtime&lt;/em&gt;, but for anything you're automating — cron-triggered reboots, maintenance scripts — hit the CGI endpoint directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Schedule 90 minutes of downtime for host "gpu-workstation" starting now&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; nagiosadmin:yourpassword &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://localhost/nagios/cgi-bin/cmd.cgi"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s2"&gt;"cmd_typ=55&amp;amp;cmd_mod=2&amp;amp;host=gpu-workstation&amp;amp;com_author=cron&amp;amp;com_data=planned+reboot&amp;amp;trigger=0&amp;amp;start_time=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +&lt;span class="s1"&gt;'%m-%d-%Y+%H:%M:%S'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;end_time=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'+90 minutes'&lt;/span&gt; +&lt;span class="s1"&gt;'%m-%d-%Y+%H:%M:%S'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;fixed=1&amp;amp;hours=1&amp;amp;minutes=30&amp;amp;childoptions=0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing worth keeping straight: silence via downtime is not the same as filtering via notification options. Downtime suppresses alerts while still running checks and recording state. A contact with &lt;code&gt;service_notification_options w&lt;/code&gt; stripped out simply never routes WARNING notifications to that contact — the check still fires, the state still changes, Grafana still graphs it. You want both mechanisms. Downtime for planned maintenance, filtered routing for permanent severity separation. Conflating them leads to gaps where you think you're monitoring something but no channel is actually wired to receive it. For operators building broader pipelines where this notification layer feeds into webhook triggers or self-healing automation, the architecture overlaps with general workflow tooling — the &lt;a href="https://techdigestor.com/ultimate-productivity-guide-2026/" rel="noopener noreferrer"&gt;Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines&lt;/a&gt; guide covers where those systems connect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas That Cost Real Time
&lt;/h2&gt;

&lt;p&gt;The most expensive one: Nagios silently stops checking things when you reload a bad config, and the container keeps running like nothing happened. Before you ever run &lt;code&gt;docker exec nagios kill -HUP 1&lt;/code&gt;, verify the config file first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Run this inside the container or via exec — never skip it&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;nagios /usr/local/nagios/bin/nagios &lt;span class="nt"&gt;-v&lt;/span&gt; /opt/nagios/etc/nagios.cfg

&lt;span class="c"&gt;# Expected tail of good output:&lt;/span&gt;
&lt;span class="c"&gt;# Total Warnings: 0&lt;/span&gt;
&lt;span class="c"&gt;# Total Errors:   0&lt;/span&gt;
&lt;span class="c"&gt;# Things look okay - No serious problems were detected during the pre-flight check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A typo in a host definition, a missing &lt;code&gt;use&lt;/code&gt; template reference, or a circular dependency in service groups will cause the reload to fail. Nagios won't crash — it keeps running the last known good config. But if you made that config change because a host was added, that host gets zero checks, zero alerts, and you won't notice until something on it actually breaks and nobody pages you. Make &lt;code&gt;nagios -v&lt;/code&gt; a pre-commit hook or at minimum a manual step before every reload.&lt;/p&gt;

&lt;p&gt;Prometheus cardinality is the slower-burning problem. cAdvisor emits labels like &lt;code&gt;container_label_com_docker_compose_service&lt;/code&gt;, &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;interface&lt;/code&gt;, and several others per time series. On a stable set of long-running containers that's manageable. The moment you start running ephemeral containers — n8n's Execute Command nodes, one-shot ETL jobs, anything that spins up and tears down per workflow run — you accumulate unique label value combinations that never get cleaned up within the retention window. Query performance on &lt;code&gt;container_cpu_usage_seconds_total&lt;/code&gt; starts to drag. The fix is &lt;code&gt;metric_relabel_configs&lt;/code&gt; in your Prometheus scrape config, applied before the data lands in TSDB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cadvisor&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cadvisor:8080'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;metric_relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# Drop per-interface network metrics you're not dashboarding&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__name__&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;container_network_(receive|transmit)_(packets|errors|dropped)_total'&lt;/span&gt;
        &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;drop&lt;/span&gt;
      &lt;span class="c1"&gt;# Strip high-cardinality compose labels from all metrics&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;container_label_com_docker_(compose_config_hash|compose_version|swarm.*)'&lt;/span&gt;
        &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;labeldrop&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop rules are evaluated before storage, so they reduce active series immediately. Don't wait until Prometheus is already slow to add these — by then you're also dealing with a bloated WAL.&lt;/p&gt;

&lt;p&gt;Grafana's provisioning behavior bites almost everyone once. If you mount a dashboard JSON into &lt;code&gt;/etc/grafana/provisioning/dashboards/&lt;/code&gt; and Grafana picks it up on startup, that dashboard is now managed by the provisioning system. Edit it in the UI, save it, restart the container — your edits are gone. Grafana overwrites the dashboard from the file on startup. The options are: keep the JSON file as the single source of truth and make all edits there (export from UI, update the file, restart), or after the initial import remove the provisioning config entry so Grafana owns it going forward. What you cannot do is treat both as writeable simultaneously. Pick a lane.&lt;/p&gt;

&lt;p&gt;NRPE firewall rules through Docker are genuinely tricky and the standard advice fails here. Running &lt;code&gt;ufw allow from 192.168.1.50 to any port 5666&lt;/code&gt; on the monitored host feels correct, but Docker's iptables manipulation means the Nagios container's traffic arrives on the host with the bridge network's IP, and your ufw rule may never match it. The correct test is always from inside the Nagios container, not from the host itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Test from inside the Nagios container&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; nagios nc &lt;span class="nt"&gt;-zv&lt;/span&gt; 192.168.1.200 5666

&lt;span class="c"&gt;# If that fails but the host-level nc works, you have a Docker FORWARD chain issue&lt;/span&gt;
&lt;span class="c"&gt;# Check what Docker inserted:&lt;/span&gt;
iptables &lt;span class="nt"&gt;-L&lt;/span&gt; DOCKER &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--line-numbers&lt;/span&gt;
iptables &lt;span class="nt"&gt;-L&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--line-numbers&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;5666
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're using &lt;code&gt;ufw&lt;/code&gt;, the reliable path is adding the NRPE allow rule directly via &lt;code&gt;iptables&lt;/code&gt; targeting the Docker bridge subnet, or switching the monitored host's NRPE binding to a specific interface and exposing the port in the compose file with explicit host binding. Testing from the Docker network namespace rather than from the bare host catches this class of bug before you spend an hour wondering why Nagios reports all NRPE checks as unreachable.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/nagios-grafana-on-self-hosted-hardware-a-real-monitoring-stack-without-the-cloud-tax/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
    </item>
    <item>
      <title>5 Lightweight Self-Hosted Error Trackers That Actually Replace Sentry on Your Own Hardware</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Mon, 27 Jul 2026 08:09:56 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/5-lightweight-self-hosted-error-trackers-that-actually-replace-sentry-on-your-own-hardware-kac</link>
      <guid>https://dev.to/ericwoooo_kr/5-lightweight-self-hosted-error-trackers-that-actually-replace-sentry-on-your-own-hardware-kac</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The free tier cap isn't an inconvenience — it's a trap.  A mid-traffic side project running into a bad deploy can exhaust Sentry's hosted event quota within the first hour of the incident.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~16 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Why Sentry's Hosted Version Becomes a Problem at Scale&lt;/li&gt;
&lt;li&gt;The 5 Tools: Quick Comparison Before the Detail&lt;/li&gt;
&lt;li&gt;GlitchTip: The Closest Drop-In for Sentry SDK Users&lt;/li&gt;
&lt;li&gt;Errbit: For Teams Running Ruby or Wanting Airbrake Compatibility&lt;/li&gt;
&lt;li&gt;Signoz: When You Need Traces and Metrics Alongside Errors&lt;/li&gt;
&lt;li&gt;Highlight.io: Session Replay Plus Errors in One Self-Hosted Stack&lt;/li&gt;
&lt;li&gt;Picking the Right Tool for Your Setup&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why Sentry's Hosted Version Becomes a Problem at Scale
&lt;/h2&gt;

&lt;p&gt;The free tier cap isn't an inconvenience — it's a trap. A mid-traffic side project running into a bad deploy can exhaust Sentry's hosted event quota within the first hour of the incident. That's the exact window where you need error visibility most, and instead you're watching the dashboard show zeros while your users are hitting 500s. Upgrading mid-incident isn't a workflow. Paying for headroom you don't normally need just to survive the occasional spike is a recurring tax that compounds across multiple projects.&lt;/p&gt;

&lt;p&gt;Self-hosting the official Sentry distribution solves the quota problem but trades it for infrastructure complexity that's wildly out of proportion to the task. The &lt;code&gt;docker-compose&lt;/code&gt; manifest ships with over 20 services — Kafka, ClickHouse, Redis, Celery workers, Snuba, the Relay ingestion pipeline, and more. The install script refuses to proceed with less than 3 GB of RAM allocated, and that's before you've ingested a single event. Running this on a shared VPS or home-lab machine that already hosts other workloads means you're either constantly fighting OOM kills or dedicating a node exclusively to error tracking. That's a hard justification to make when the application generating the errors is itself a small project.&lt;/p&gt;

&lt;p&gt;The core mismatch is what developers actually want from a self-hosted error tracker versus what Sentry's architecture was built to deliver. Source maps, readable stack traces, release tagging, environment filters, basic alerting — that's the real list. Kafka exists in the official stack because Sentry processes billions of events per month across their SaaS offering. That throughput requirement drives architectural decisions that then get inherited by anyone self-hosting, regardless of whether they're handling a hundred events a day or a million. You end up running a small data platform to power what should be a single-purpose tool.&lt;/p&gt;

&lt;p&gt;For this comparison, "lightweight" means something specific and testable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Deployable as a single Docker container or a Compose stack with no more than three or four services&lt;/li&gt;
&lt;li&gt;  Under 1 GB RAM at idle — low enough to share a 2 GB VPS with a running application&lt;/li&gt;
&lt;li&gt;  Postgres or SQLite as the &lt;strong&gt;only&lt;/strong&gt; stateful dependency — no Kafka, no ClickHouse, no separate queue infrastructure&lt;/li&gt;
&lt;li&gt;  Functional source map support and structured stack traces — not just raw JSON event dumps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That constraint set cuts out a lot of candidates. Several "Sentry alternatives" in blog posts still pull in Redis plus a task queue plus a separate ingestion service the moment you enable any real feature. The ones worth running on a single box are the ones that made deliberate architectural decisions to stay small — and those decisions show up immediately in the Compose file line count and the &lt;code&gt;docker stats&lt;/code&gt; output after a day of running.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5 Tools: Quick Comparison Before the Detail
&lt;/h2&gt;

&lt;p&gt;The most useful thing to know upfront: these five tools split cleanly into two categories that serve different operators. Two of them — GlitchTip and Errbit — exist specifically to be Sentry-compatible backends. Point your existing DSN at them and you're done. The other three — SigNoz, Highlight.io, and Baselime (now AWS-native) — are broader observability stacks where error tracking is one tab among several. Picking the wrong category means either ripping out instrumentation you don't need to touch, or paying for a full observability platform when you just want a crash log.&lt;/p&gt;

&lt;p&gt;Sentry SDK compatibility is the deciding filter for most self-hosters. If your app already ships &lt;code&gt;@sentry/node&lt;/code&gt;, &lt;code&gt;sentry-python&lt;/code&gt;, or the browser &lt;code&gt;@sentry/browser&lt;/code&gt; package, a DSN-compatible backend costs you zero instrumentation rework — you change one environment variable. If you pick a non-compatible platform, you're re-instrumenting every service, which is a real cost on a multi-service setup. Don't let a feature list override that operational reality.&lt;/p&gt;

&lt;p&gt;Here's the comparison across the dimensions that actually matter when you're planning a self-hosted deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tool          Min RAM    Backing Store         Sentry SDK Compat   Source Maps   Biggest Dealbreaker
-----------   --------   -------------------   -----------------   -----------   -------------------------------------------
GlitchTip     512 MB     Postgres + Redis       Yes (drop-in DSN)   Yes           Grouping logic lags behind Sentry's
Errbit        256 MB     MongoDB                Yes (drop-in DSN)   No native     No timeline view; Ruby-era UX
SigNoz        4 GB+      ClickHouse             No                  Yes           Heavy stack; ClickHouse eats disk fast
Highlight.io  2 GB+      Postgres + ClickHouse  No                  Yes           Requires full session replay pipeline
Baselime       —         AWS (not self-hosted)  No                  Yes           Not actually self-hostable; AWS-only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few numbers worth expanding on: SigNoz's ClickHouse dependency is the operational weight you're accepting. ClickHouse is a columnar store optimized for high-throughput append workloads — great for traces and metrics at scale, but it will consume disk at a rate that surprises operators coming from Postgres-only stacks. Expect to set aggressive TTL policies on your spans table within the first week or you'll watch a modest event volume balloon into tens of gigabytes. GlitchTip's 512 MB floor is real and reproducible on a $6/month VPS, making it the only option here that doesn't demand a dedicated box. Errbit's 256 MB claim holds only if you keep MongoDB lean — in practice, with a few weeks of error history, you're looking at 1 GB+ for the Mongo data directory alone.&lt;/p&gt;

&lt;p&gt;Source map support is the other sharp dividing line, especially for frontend-heavy teams. GlitchTip handles uploaded source maps through its release artifact API, which is compatible with the standard &lt;code&gt;sentry-cli&lt;/code&gt; upload workflow. Errbit has no built-in source map processing — stack traces from minified JS land in your inbox exactly as mangled as they came in, which makes it a poor fit for any project shipping a bundled frontend. If you're also evaluating AI-assisted debugging tooling alongside these, see our guide on &lt;a href="https://techdigestor.com/best-ai-coding-tools-2026/" rel="noopener noreferrer"&gt;AI Coding Tools in 2026: Cloud Copilots vs Local Models&lt;/a&gt; for the broader context on where automated analysis fits into an error triage workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  GlitchTip: The Closest Drop-In for Sentry SDK Users
&lt;/h2&gt;

&lt;p&gt;The migration story is almost suspiciously simple: swap one URL. GlitchTip uses the exact same DSN format as Sentry, which means the difference between pointing at sentry.io and pointing at your own box is literally a hostname change. &lt;code&gt;SENTRY_DSN=https://key@sentry.io/123&lt;/code&gt; becomes &lt;code&gt;SENTRY_DSN=https://key@your-glitchtip.host/123&lt;/code&gt; — no SDK version bump, no new client library, no configuration schema to learn. If you're already running Sentry SDKs across a handful of services, GlitchTip is the only self-hosted option where you aren't also rewriting instrumentation.&lt;/p&gt;

&lt;p&gt;The full stack is a single Django app, Postgres, and Redis. That's it. A working &lt;code&gt;docker-compose.yml&lt;/code&gt; stays under 50 lines with room to spare, and after the containers warm up, idle RAM hovers in the 200–300 MB range total — not per service. The minimum viable setup needs three environment variables before &lt;code&gt;docker compose up -d&lt;/code&gt; will get you anywhere useful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env — minimum required before first boot
&lt;/span&gt;&lt;span class="n"&gt;SECRET_KEY&lt;/span&gt;=&lt;span class="n"&gt;replace&lt;/span&gt;-&lt;span class="n"&gt;with&lt;/span&gt;-&lt;span class="n"&gt;a&lt;/span&gt;-&lt;span class="n"&gt;long&lt;/span&gt;-&lt;span class="n"&gt;random&lt;/span&gt;-&lt;span class="n"&gt;string&lt;/span&gt;
&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;=&lt;span class="n"&gt;postgres&lt;/span&gt;://&lt;span class="n"&gt;glitchtip&lt;/span&gt;:&lt;span class="n"&gt;pass&lt;/span&gt;@&lt;span class="n"&gt;db&lt;/span&gt;:&lt;span class="m"&gt;5432&lt;/span&gt;/&lt;span class="n"&gt;glitchtip&lt;/span&gt;
&lt;span class="n"&gt;GLITCHTIP_DOMAIN&lt;/span&gt;=&lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;errors&lt;/span&gt;.&lt;span class="n"&gt;yourdomain&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The non-obvious one to set immediately — before you ingest a single event — is &lt;code&gt;GLITCHTIP_MAX_EVENT_LIFE_DAYS&lt;/code&gt;. GlitchTip has no automatic event expiry enabled out of the box. On a busy application this means your Postgres volume grows without any ceiling, and on a cheap VPS with 20–40 GB of disk you'll eventually hit a full-disk condition that takes the whole container stack down silently. The fix is a one-liner in your env file, but the docs don't surface it prominently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# add to .env — without this, Postgres grows unbounded
&lt;/span&gt;&lt;span class="n"&gt;GLITCHTIP_MAX_EVENT_LIFE_DAYS&lt;/span&gt;=&lt;span class="m"&gt;90&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Performance monitoring exists — GlitchTip does accept transaction data from Sentry SDKs — but treat it as a checkbox, not a feature. You get basic transaction lists and error-rate summaries. What you don't get: session replay, profiling, span-level flame graphs, or anything from the Sentry performance tab that you'd actually open during an incident. If your team uses Sentry primarily to catch and group unhandled exceptions, GlitchTip covers that workload cleanly. If someone on your team has Sentry's profiler tab bookmarked, they'll notice the gap within a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errbit: For Teams Running Ruby or Wanting Airbrake Compatibility
&lt;/h2&gt;

&lt;p&gt;The first thing to understand about Errbit is that it does &lt;em&gt;not&lt;/em&gt; speak Sentry's protocol. It implements the Airbrake v2 API — which means you're reaching for the &lt;code&gt;airbrake&lt;/code&gt; gem or &lt;code&gt;airbrake-js&lt;/code&gt;, not any Sentry SDK. That's a hard architectural constraint, not something you toggle in a config file. If your codebase already has Sentry SDK calls scattered through it, Errbit is not a drop-in swap. But if you're on a legacy Rails monolith that's been using the Airbrake notifier for years, Errbit is essentially a self-hosted backend that already matches your client setup exactly.&lt;/p&gt;

&lt;p&gt;The Docker compose setup is straightforward: the official &lt;code&gt;errbit/errbit&lt;/code&gt; image paired with a &lt;code&gt;mongo:6&lt;/code&gt; container. No Redis dependency, which keeps the stack simpler than most alternatives. The real resource cost is MongoDB — expect 300–500 MB resident memory on a quiet instance just from the Mongo process sitting there. That's not a dealbreaker, but it's the number to check against your VPS tier before committing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;errbit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;errbit/errbit:latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;RACK_ENV=production&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MONGO_URL=mongodb://mongo:27017/errbit&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SECRET_KEY_BASE=changeme_generate_with_openssl_rand&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;EMAIL_FROM=errors@yourdomain.com&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mongo&lt;/span&gt;

  &lt;span class="na"&gt;mongo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mongo:6&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;errbit_mongo:/data/db&lt;/span&gt;
    &lt;span class="c1"&gt;# no auth config here — add --auth and a keyfile for production&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;errbit_mongo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Error grouping uses backtrace fingerprinting, which handles Ruby and Rails stack traces well — the frames are stable, human-readable, and consistent between deploys as long as your gem versions don't churn. Where this breaks down is minified JavaScript. Without source maps pre-processed server-side before errors arrive, Errbit groups JS errors against mangled frame addresses that change every build. You get a new "unique" error on every deploy rather than a deduplicated stream. If JavaScript error tracking is a significant part of your needs, that friction is real and not easily patched around.&lt;/p&gt;

&lt;p&gt;The honest best-fit profile for Errbit: a Rails monolith already wired to the Airbrake notifier, an infrastructure where MongoDB is already running for something else (so the memory cost is already paid), and a team that doesn't need Slack-level integrations or sophisticated alerting rules. It's minimal, it's stable, and it doesn't require you to retrain anyone who's been using Airbrake. Outside that context — especially if you're running a polyglot stack or need strong JavaScript support — the API lock-in will cost you more than the self-hosting saves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signoz: When You Need Traces and Metrics Alongside Errors
&lt;/h2&gt;

&lt;p&gt;The biggest conceptual shift with SigNoz isn't the UI or the query language — it's that errors don't arrive via a Sentry DSN at all. SigNoz is OpenTelemetry-native, which means exceptions surface as OTel log and exception events flowing through the collector pipeline. If you've been using &lt;code&gt;@sentry/node&lt;/code&gt; or any Sentry SDK, swapping to SigNoz means ripping that out and replacing it with the OTel SDK stack. That's not a config change; it's a real migration. Expect to touch every service that currently initializes Sentry, replace the SDK initialization, and wire up the exporter endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Replacing Sentry init with OTel in a Node.js service&lt;/span&gt;
&lt;span class="c"&gt;# Before (Sentry):&lt;/span&gt;
&lt;span class="c"&gt;# Sentry.init({ dsn: "https://...", tracesSampleRate: 1.0 });&lt;/span&gt;

&lt;span class="c"&gt;# After (OTel):&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @opentelemetry/sdk-node &lt;span class="se"&gt;\&lt;/span&gt;
  @opentelemetry/auto-instrumentations-node &lt;span class="se"&gt;\&lt;/span&gt;
  @opentelemetry/exporter-otlp-grpc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// instrumentation.ts — runs before anything else via --require&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NodeSDK&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@opentelemetry/sdk-node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getNodeAutoInstrumentations&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@opentelemetry/auto-instrumentations-node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;OTLPTraceExporter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@opentelemetry/exporter-trace-otlp-grpc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sdk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NodeSDK&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// Point at your SigNoz OTel collector, not a Sentry DSN&lt;/span&gt;
  &lt;span class="na"&gt;traceExporter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OTLPTraceExporter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;grpc://your-signoz-host:4317&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;instrumentations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;getNodeAutoInstrumentations&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// Unhandled exceptions now appear as span events in SigNoz,&lt;/span&gt;
&lt;span class="c1"&gt;// not as Sentry issues — the grouping model is completely different&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Docker Compose install is where "lightweight" gets complicated. The official stack pulls in ClickHouse, a query service, the OTel collector, an alertmanager, and several supporting containers — the total is typically 8+ containers. SigNoz's own docs recommend a 4 GB RAM floor just for the stack itself. On a shared 4 GB VPS that's already running Nginx, Postgres, and your app, SigNoz will win the memory contest and everything else will lose. ClickHouse under indexing load is particularly aggressive about buffer allocation. This isn't a dealbreaker — it just means SigNoz belongs on a dedicated node or a home-lab machine where you have 8+ GB free to hand it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Checking what the Compose stack actually pulls&lt;/span&gt;
git clone https://github.com/SigNoz/signoz.git
&lt;span class="nb"&gt;cd &lt;/span&gt;signoz/deploy/docker/clickhouse-setup
docker compose config &lt;span class="nt"&gt;--services&lt;/span&gt;
&lt;span class="c"&gt;# Expect: clickhouse, query-service, frontend, alertmanager,&lt;/span&gt;
&lt;span class="c"&gt;#         otel-collector, otel-collector-metrics, logspout, ...&lt;/span&gt;
&lt;span class="c"&gt;# Count them before you commit to a VPS tier&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you actually gain over simpler alternatives like GlitchTip is real correlated observability: a single failing request shows you the exception, the full distributed trace, the service map hop where latency spiked, and infrastructure metrics on the same timeline. That correlation is the feature. When an error fires at 2am, you're not manually cross-referencing three dashboards — the trace is attached to the exception event. The query interface also lets you slice arbitrary log fields rather than just exception type and message, which matters once you're past basic error grouping and want to understand patterns across deployments or regions.&lt;/p&gt;

&lt;p&gt;The operational cost for that capability is real. When the stack fails to start cleanly, you're not reading one log — you're reading ClickHouse startup output &lt;em&gt;and&lt;/em&gt; the OTel collector logs simultaneously to figure out which dependency failed to become healthy first. A common failure mode is ClickHouse not being ready when the query service attempts its initial schema migration; the fix is usually just waiting and restarting the query service container, but figuring that out the first time takes longer than it should. Use SigNoz when you have spare hardware capacity and you've outgrown pure error tracking — when you need traces to explain &lt;em&gt;why&lt;/em&gt; errors happen, not just &lt;em&gt;that&lt;/em&gt; they happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Highlight.io: Session Replay Plus Errors in One Self-Hosted Stack
&lt;/h2&gt;

&lt;p&gt;Session replay baked into the same error pipeline is the actual differentiator here — not in a marketing sense, but in a debugging sense. When a user hits a &lt;code&gt;TypeError: Cannot read properties of undefined&lt;/code&gt; in your React app, you don't just get a stack trace; you get an rrweb recording of exactly what they clicked, scrolled, and typed before the crash. Correlating errors to sessions without a separate tool like LogRocket or FullStory is the whole reason to look at Highlight.io seriously.&lt;/p&gt;

&lt;p&gt;The self-hosted stack is honest about its complexity. The Docker Compose setup pulls in Postgres (session metadata, user data), ClickHouse (event storage and aggregation), and either MinIO or an S3-compatible store for the raw replay chunks. That's not bloat for the sake of it — ClickHouse is genuinely the right engine for time-series event queries at replay scale, and object storage is the only sane place to put binary replay data. But the practical consequence is that you need &lt;strong&gt;6+ GB RAM as a floor before your actual application runs&lt;/strong&gt;. ClickHouse alone wants 2-4 GB to behave, MinIO needs headroom, and the Highlight app containers add more on top. If you're thinking about slotting this onto a $6/mo VPS, the math doesn't work. This belongs on a home-lab box or a cloud instance with at least 8 GB dedicated to the observability stack.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The core of the self-hosted compose — what you're actually committing to:&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;highlight&lt;/span&gt;
  &lt;span class="na"&gt;clickhouse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;clickhouse/clickhouse-server:23.12&lt;/span&gt;
    &lt;span class="c1"&gt;# expects ~2GB RAM minimum under real query load&lt;/span&gt;
    &lt;span class="na"&gt;ulimits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;nofile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;soft&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;262144&lt;/span&gt;
        &lt;span class="na"&gt;hard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;262144&lt;/span&gt;
  &lt;span class="na"&gt;minio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio/minio&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server /data --console-address ":9001"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_ROOT_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;highlight&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changethis&lt;/span&gt;
  &lt;span class="na"&gt;highlight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/highlight/highlight:latest&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;clickhouse&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;minio&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK situation is where migration cost becomes real. Highlight ships &lt;code&gt;@highlight-run/node&lt;/code&gt; for the backend and &lt;code&gt;highlight.run&lt;/code&gt; for the browser — neither is a drop-in for the Sentry SDK. There's no Sentry-compatible DSN endpoint, no &lt;code&gt;captureException&lt;/code&gt; alias that just works. You're doing a proper SDK swap: remove &lt;code&gt;@sentry/node&lt;/code&gt;, install &lt;code&gt;@highlight-run/node&lt;/code&gt;, update your error boundary wrappers, update your environment config, and then learn a new dashboard. For a mature app with Sentry wired into a dozen places, that's a half-day minimum, not a config swap. The upside is the product you get is genuinely richer — network request timelines, console logs, and the replay are all first-class in the same view.&lt;/p&gt;

&lt;p&gt;The honest use-case boundary: if your errors are predominantly backend — Node.js workers, API services, queue consumers — Highlight's resource overhead is hard to justify. GlitchTip or Signoz will give you error aggregation for a fraction of the RAM. Highlight earns its place specifically when you're debugging &lt;strong&gt;frontend-triggered failures&lt;/strong&gt; where the stack trace alone doesn't tell you enough. A user reports a blank screen on checkout; the replay shows them resizing the browser window at a specific breakpoint right before the React hydration error fires. That's the scenario where six gigabytes of supporting infrastructure actually pays for itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking the Right Tool for Your Setup
&lt;/h2&gt;

&lt;p&gt;The honest answer most "comparison" posts dodge: for a solo operator who just wants Sentry-compatible error tracking without babysitting infrastructure, &lt;strong&gt;GlitchTip on a 1 GB VPS with Postgres&lt;/strong&gt; is the default. Drop in your existing Sentry SDK, set one environment variable, put Nginx in front, and you're done. The config that actually matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# docker-compose env block — the one variable most people miss
&lt;/span&gt;&lt;span class="n"&gt;GLITCHTIP_MAX_EVENT_LIFE_DAYS&lt;/span&gt;=&lt;span class="m"&gt;90&lt;/span&gt;   &lt;span class="c"&gt;# without this, the DB grows unbounded
&lt;/span&gt;&lt;span class="n"&gt;SECRET_KEY&lt;/span&gt;=&lt;span class="n"&gt;your&lt;/span&gt;-&lt;span class="n"&gt;random&lt;/span&gt;-&lt;span class="m"&gt;64&lt;/span&gt;-&lt;span class="n"&gt;char&lt;/span&gt;-&lt;span class="n"&gt;string&lt;/span&gt;
&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;=&lt;span class="n"&gt;postgres&lt;/span&gt;://&lt;span class="n"&gt;glitchtip&lt;/span&gt;:&lt;span class="n"&gt;pass&lt;/span&gt;@&lt;span class="n"&gt;db&lt;/span&gt;:&lt;span class="m"&gt;5432&lt;/span&gt;/&lt;span class="n"&gt;glitchtip&lt;/span&gt;
&lt;span class="n"&gt;EMAIL_URL&lt;/span&gt;=&lt;span class="n"&gt;smtp&lt;/span&gt;://&lt;span class="n"&gt;user&lt;/span&gt;:&lt;span class="n"&gt;pass&lt;/span&gt;@&lt;span class="n"&gt;smtp&lt;/span&gt;.&lt;span class="n"&gt;yourhost&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;:&lt;span class="m"&gt;587&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nginx in front handles TLS termination, and Certbot handles renewal. The entire ops surface is one Compose file and a cron for &lt;code&gt;docker compose exec web python manage.py migrate&lt;/code&gt; after upgrades. A 1 GB VPS handles low-to-moderate error volumes without complaint — Postgres is the only datastore, so backups are a single &lt;code&gt;pg_dump&lt;/code&gt; command. That simplicity is the point.&lt;/p&gt;

&lt;p&gt;Two legitimate reasons to deviate from that default. If you're running a &lt;strong&gt;legacy Ruby app already wired to Airbrake&lt;/strong&gt;, pick Errbit and accept MongoDB. Rewriting SDK calls across a mature Rails codebase to hit a different endpoint costs more than running Mongo ever will. MongoDB on a single node with a scheduled &lt;code&gt;mongodump&lt;/code&gt; is not a complex ops burden — the friction of fighting SDK compatibility is. Conversely, if you've got a &lt;strong&gt;spare home-lab node with 8+ GB RAM and need traces plus metrics alongside errors&lt;/strong&gt;, SigNoz is the correct answer. Instrument everything with OpenTelemetry from day one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# Node.js OTel bootstrap — same collector config works for SigNoz today,
# any OTLP-compatible backend tomorrow
&lt;/span&gt;&lt;span class="n"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;=&lt;span class="n"&gt;http&lt;/span&gt;://&lt;span class="n"&gt;signoz&lt;/span&gt;-&lt;span class="n"&gt;otel&lt;/span&gt;-&lt;span class="n"&gt;collector&lt;/span&gt;:&lt;span class="m"&gt;4317&lt;/span&gt;
&lt;span class="n"&gt;OTEL_SERVICE_NAME&lt;/span&gt;=&lt;span class="n"&gt;my&lt;/span&gt;-&lt;span class="n"&gt;api&lt;/span&gt;
&lt;span class="n"&gt;OTEL_TRACES_EXPORTER&lt;/span&gt;=&lt;span class="n"&gt;otlp&lt;/span&gt;
&lt;span class="n"&gt;OTEL_METRICS_EXPORTER&lt;/span&gt;=&lt;span class="n"&gt;otlp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The OTel abstraction means you're not locked to SigNoz — if you outgrow it or migrate, the instrumentation code doesn't change, only the collector endpoint does. That matters more than any feature SigNoz ships next quarter.&lt;/p&gt;

&lt;p&gt;The one case where none of the above applies: &lt;strong&gt;frontend apps where session replay is a hard requirement&lt;/strong&gt;. Highlight.io is the only self-hosted option that ships replay, error tracking, and logging as a single deployable stack. Building replay yourself on top of GlitchTip or Errbit means maintaining a separate rrweb pipeline, a storage layer for recordings, and a playback UI — that's a significant side project. Highlight.io requires a dedicated host with meaningful RAM headroom, but if replay is genuinely non-negotiable for debugging user-reported issues, the ops cost is justified and there's no real alternative in the self-hosted space.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/5-lightweight-self-hosted-error-trackers-that-actually-replace-sentry-on-your-own-hardware/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Grafana + Prometheus on a Raspberry Pi: Build a Telemetry Dashboard That Actually Stays Up</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Fri, 24 Jul 2026 08:10:51 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/grafana-prometheus-on-a-raspberry-pi-build-a-telemetry-dashboard-that-actually-stays-up-37cl</link>
      <guid>https://dev.to/ericwoooo_kr/grafana-prometheus-on-a-raspberry-pi-build-a-telemetry-dashboard-that-actually-stays-up-37cl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The failure mode nobody warns you about with Raspberry Pi clusters: the service dies, you SSH in twenty minutes later, and &lt;code&gt;dmesg&lt;/code&gt; shows the CPU was thermally throttling for two hours before the crash.  No alert fired.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~21 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem: Flying Blind on Low-Power Hardware&lt;/li&gt;
&lt;li&gt;Architecture Before You Install Anything&lt;/li&gt;
&lt;li&gt;Installation: Docker Compose Stack with Real Config Files&lt;/li&gt;
&lt;li&gt;node_exporter: What to Enable and What to Skip&lt;/li&gt;
&lt;li&gt;Building the Dashboard: Panels That Catch Real Problems&lt;/li&gt;
&lt;li&gt;Alerting Without AlertManager Complexity&lt;/li&gt;
&lt;li&gt;Gotchas That Will Cost You an Afternoon&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem: Flying Blind on Low-Power Hardware
&lt;/h2&gt;

&lt;p&gt;The failure mode nobody warns you about with Raspberry Pi clusters: the service dies, you SSH in twenty minutes later, and &lt;code&gt;dmesg&lt;/code&gt; shows the CPU was thermally throttling for two hours before the crash. No alert fired. Nothing logged the temperature climb. You were flying completely blind while your node was quietly strangling itself.&lt;/p&gt;

&lt;p&gt;Thermal throttling on ARM chips is insidious because it doesn't fail loudly — it just makes everything slower in a way that looks like bad code. The Pi 4 and Pi 5 will both throttle aggressively when the SoC hits 80°C, dropping clock speed without any visible error. SD card I/O saturation is the same story: &lt;code&gt;iowait&lt;/code&gt; climbs past 40%, your service's write queue backs up, and the symptom looks like a memory leak or a hung process. Memory exhaustion on a 2GB board will OOM-kill a process while leaving the system technically "up," which means your monitoring check passes but your actual workload is dead. Without instrumentation, you're diagnosing all of this from crash artifacts instead of catching the curve before it peaks.&lt;/p&gt;

&lt;p&gt;The minimal signal set you actually need to answer these questions is smaller than most guides suggest. Four metrics cover the majority of failure modes on constrained hardware:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;CPU temperature&lt;/strong&gt; — the raw SoC temp in Celsius, not CPU utilization, which can look fine while the chip throttles&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Memory pressure&lt;/strong&gt; — available memory plus swap usage, because free memory is misleading when the kernel is aggressively reclaiming cache&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Disk I/O wait&lt;/strong&gt; — &lt;code&gt;iowait&lt;/code&gt; percentage and queue depth, which catches SD card saturation before it becomes a hang&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Network throughput&lt;/strong&gt; — bytes in/out per interface, which surfaces runaway log shippers or unexpected traffic on headless nodes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Prometheus plus Grafana wins on this hardware class for a specific structural reason: the scrape model puts the collection burden on the exporter, not a persistent agent. &lt;code&gt;node_exporter&lt;/code&gt; on ARM64 idles around 10–20MB RSS during normal operation — it wakes up, serves a scrape, and goes back to sleep. Compare that to the Telegraf + InfluxDB stack, where Telegraf runs continuous collection intervals and InfluxDB's write-ahead log and compaction processes compete for memory on the same 1–4GB budget. The Prometheus TSDB is also local-queryable without a separate query engine, which matters when you're running Grafana on the same Pi rather than a separate host. The tradeoff you accept is that Prometheus's local retention gets expensive on disk past 15 days of high-cardinality data — on an SD card, that's a real constraint you'll need to tune. If you're also running AI tooling or heavier inference workloads on the same Pi or a companion machine, the &lt;a href="https://techdigestor.com/best-ai-coding-tools-2026/" rel="noopener noreferrer"&gt;AI Coding Tools in 2026: Cloud Copilots vs Local Models&lt;/a&gt; guide covers the broader toolchain context worth reading before you allocate RAM budgets across competing services.&lt;/p&gt;

&lt;p&gt;One gotcha that isn't in the official docs: Prometheus's default scrape interval of 15 seconds is too coarse to catch transient I/O spikes on slow SD cards. A spike that saturates the card for 8 seconds and then clears will be missed entirely between two 15-second samples. Drop the scrape interval to 5 seconds for disk metrics specifically — you can do this per-job in the scrape config without affecting everything else — and your storage costs increase proportionally, so plan retention accordingly before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Before You Install Anything
&lt;/h2&gt;

&lt;p&gt;The topology decision matters more than any config tweak you'll make later. Running Prometheus and Grafana directly on the Pi 4 is the obvious path, but on a 4 GB model you're looking at Prometheus sitting around 80–150 MB RSS at rest, Grafana adding another 100–150 MB, plus whatever else the OS is doing. That's 300–400 MB committed before you've scraped a single metric. It's workable, but if the Pi is already doing something else — running a media server, a home automation stack, anything that actually uses memory — you'll feel it. The cleaner split is running Prometheus and Grafana on a separate host (a spare x86 machine, another Pi, even a cheap VPS) and dropping only &lt;code&gt;node_exporter&lt;/code&gt; on the Pi itself. &lt;code&gt;node_exporter&lt;/code&gt; idles under 10 MB RSS and barely touches the CPU. That's the architecture I'd recommend if you have the option — the Pi becomes a pure data source, and your monitoring stack doesn't compete with whatever workload the Pi is actually supposed to run.&lt;/p&gt;

&lt;p&gt;Scrape interval is where people make a quiet mistake with SD cards. The Prometheus default of &lt;code&gt;15s&lt;/code&gt; is sane for most metrics, but if you want to catch thermal throttling events — the Pi starts throttling at 80°C and the kernel can flip the throttle flag and clear it within seconds — you need &lt;code&gt;5s&lt;/code&gt; or you'll miss the event entirely in the time series. The cost of dropping to 5s isn't compute, it's writes. At 15s scrape with node_exporter exposing roughly 700–900 metrics, Prometheus writes compressed blocks to disk in chunks, not per-scrape, so the write amplification is lower than you'd expect. But at 5s you're tripling the in-memory accumulation rate and flushing chunks more frequently. On a USB SSD this is a non-issue — the write endurance is effectively unlimited at this workload. On an SD card running &lt;code&gt;ext4&lt;/code&gt; with default mount options, sustained small writes shorten card life measurably over months. If you're stuck with SD, at minimum mount &lt;code&gt;noatime&lt;/code&gt; and point Prometheus's TSDB data directory at a USB stick or external drive.&lt;/p&gt;

&lt;p&gt;The retention math is worth doing before you provision storage. Prometheus with &lt;code&gt;node_exporter&lt;/code&gt; at a 15s scrape interval generates somewhere in the 200–400 MB range per month of compressed TSDB data — the variance comes from how many network interfaces, disks, and CPUs you're exposing. The default retention is 15 days, which keeps you under 200 MB in most node_exporter-only setups. If you add custom metrics or drop the scrape interval, recalculate. Set &lt;code&gt;--storage.tsdb.retention.time&lt;/code&gt; explicitly regardless — if you don't set it, you're relying on the compiled default surviving across version upgrades, which it won't always do cleanly. A reasonable explicit flag for a Pi with a USB SSD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--storage&lt;/span&gt;.tsdb.path&lt;span class="o"&gt;=&lt;/span&gt;/data/prometheus &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--storage&lt;/span&gt;.tsdb.retention.time&lt;span class="o"&gt;=&lt;/span&gt;30d &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--storage&lt;/span&gt;.tsdb.retention.size&lt;span class="o"&gt;=&lt;/span&gt;2GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;retention.size&lt;/code&gt; flag is your safety net. Prometheus will evict oldest blocks before it hits that limit, so even if your scrape rate spikes, you won't fill the drive silently.&lt;/p&gt;

&lt;p&gt;Docker Compose versus bare-metal is an honest trade-off, not a clear winner. Compose gives you version-pinned images, a single &lt;code&gt;docker-compose.yml&lt;/code&gt; that you can commit to git, and teardown/rebuild in under two minutes — that reproducibility pays off the first time you corrupt a config and need to roll back. The cost is real: the Docker daemon itself plus the overhead of two containers adds roughly 80–120 MB RSS that you don't pay on bare metal. On a 4 GB Pi running nothing else, that's tolerable. On a 2 GB Pi or a Pi that's doing actual work, that overhead pushes you toward bare-metal installs via the official APT repos or prebuilt ARM binaries from the Prometheus GitHub releases page. If you go bare-metal, pin your versions explicitly in whatever provisioning script you use — &lt;code&gt;prometheus-2.51.2.linux-armv7&lt;/code&gt; not "latest" — because the ARM binary naming conventions have shifted between releases and an unattended upgrade can silently pull a mismatched build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation: Docker Compose Stack with Real Config Files
&lt;/h2&gt;

&lt;p&gt;Pinning image versions on ARM64 is non-negotiable. The &lt;code&gt;latest&lt;/code&gt; tag for several Prometheus ecosystem images has a documented lag on ARM64 — the amd64 manifest updates first, and if your Pi pulls during that window, you either get a stale layer or a failed pull. Beyond that, unpinned images mean a &lt;code&gt;docker compose pull&lt;/code&gt; three months from now silently changes behavior. Pick a version, write it down, upgrade deliberately.&lt;/p&gt;

&lt;p&gt;Here's the full &lt;code&gt;docker-compose.yml&lt;/code&gt;. Every image is pinned to a specific minor version, volumes are named (not anonymous), and the network is explicit so Prometheus can reach node-exporter by service name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.8"&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;telemetry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheus_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;grafana_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;node-exporter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/node-exporter:v1.8.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_exporter&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;telemetry&lt;/span&gt;
    &lt;span class="na"&gt;pid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;host&lt;/span&gt;                          &lt;span class="c1"&gt;# needed to expose real host-level CPU/mem metrics&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/proc:/host/proc:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/sys:/host/sys:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/:/rootfs:ro&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--path.procfs=/host/proc'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--path.sysfs=/host/sys'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--path.rootfs=/rootfs'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'&lt;/span&gt;

  &lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/prometheus:v2.51.2&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;telemetry&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./prometheus.yml:/etc/prometheus/prometheus.yml:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;prometheus_data:/prometheus&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--config.file=/etc/prometheus/prometheus.yml'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.path=/prometheus'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.retention.time=15d'&lt;/span&gt;  &lt;span class="c1"&gt;# explicit retention, default is 15d but state it&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;65534:65534"&lt;/span&gt;                &lt;span class="c1"&gt;# nobody:nogroup — see ownership note below&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9090:9090"&lt;/span&gt;

  &lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/grafana:10.4.2&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;telemetry&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;grafana_data:/var/lib/grafana&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./provisioning:/etc/grafana/provisioning:ro&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GF_SECURITY_ADMIN_PASSWORD=changeme&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GF_USERS_ALLOW_SIGN_UP=false&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;prometheus.yml&lt;/code&gt; scrape config below uses a &lt;code&gt;job_label&lt;/code&gt; on the target so that if you later add a second Pi, you're not staring at unlabeled &lt;code&gt;localhost:9100&lt;/code&gt; entries in every query. Label the host now, even if it's a single node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scrape_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;
  &lt;span class="na"&gt;evaluation_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;

&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node"&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node-exporter:9100"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# service name resolves on the telemetry network&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pi-living-room"&lt;/span&gt;          &lt;span class="c1"&gt;# change per node; shows up in every metric series&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grafana provisioning is the piece most tutorials skip, and it's what keeps your Prometheus datasource alive through &lt;code&gt;docker compose down &amp;amp;&amp;amp; docker compose up&lt;/code&gt;. Without it, the datasource lives only in the SQLite database inside the named volume — which is fine until you recreate the volume or restore from backup without also restoring the DB. Create &lt;code&gt;./provisioning/datasources/prometheus.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;

&lt;span class="na"&gt;datasources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prometheus&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
    &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://prometheus:9090&lt;/span&gt;   &lt;span class="c1"&gt;# container-to-container on the shared network&lt;/span&gt;
    &lt;span class="na"&gt;isDefault&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;editable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;               &lt;span class="c1"&gt;# prevent accidental UI edits from diverging from file&lt;/span&gt;
    &lt;span class="na"&gt;jsonData&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;timeInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;15s"&lt;/span&gt;        &lt;span class="c1"&gt;# match global scrape_interval or Grafana will warn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two ownership problems will bite you on first start if you use bind mounts instead of named volumes — and even with named volumes on some Raspberry Pi OS setups. Prometheus runs as UID &lt;code&gt;65534&lt;/code&gt; (nobody) and will refuse to start if it can't write to &lt;code&gt;/prometheus&lt;/code&gt;. If you ever switch to a bind mount for the data directory, run &lt;code&gt;sudo chown -R 65534:65534 ./prometheus_data&lt;/code&gt; before &lt;code&gt;docker compose up&lt;/code&gt;. Grafana uses UID &lt;code&gt;472&lt;/code&gt;, which is non-obvious and not the same as any default user on Raspberry Pi OS. Same fix: &lt;code&gt;sudo chown -R 472:472 ./grafana_data&lt;/code&gt; if you're bind-mounting. Named volumes sidestep this because Docker manages the directory and applies container ownership automatically — but the moment you &lt;code&gt;ls -la /var/lib/docker/volumes/&lt;/code&gt; and try to manually drop files in, you're back to the same problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  node_exporter: What to Enable and What to Skip
&lt;/h2&gt;

&lt;p&gt;The default node_exporter install ships with roughly 50 collectors active. On a Pi, maybe a dozen of those are useful. The rest are either irrelevant (NFS, RAID), cause unnecessary disk writes on SD cards, or pull kernel subsystems that don't exist on ARM. Getting the collector list right upfront saves you from scraping noise and from quietly shortening the life of your boot media.&lt;/p&gt;

&lt;p&gt;The collectors that actually earn their keep on a Pi: &lt;code&gt;cpu&lt;/code&gt;, &lt;code&gt;meminfo&lt;/code&gt;, &lt;code&gt;diskstats&lt;/code&gt;, &lt;code&gt;filesystem&lt;/code&gt;, &lt;code&gt;netdev&lt;/code&gt;, and &lt;code&gt;thermal_zone&lt;/code&gt;. The thermal collector reads directly from &lt;code&gt;/sys/class/thermal/thermal_zone*/temp&lt;/code&gt; — verify it's wired up before you trust your Grafana panel by hitting the metrics endpoint directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:9100/metrics | &lt;span class="nb"&gt;grep &lt;/span&gt;node_thermal

&lt;span class="c"&gt;# expect something like:&lt;/span&gt;
&lt;span class="c"&gt;# node_thermal_zone_temp{type="cpu-thermal",zone="0"} 52000&lt;/span&gt;
&lt;span class="c"&gt;# value is in millidegrees — divide by 1000 for Celsius in your PromQL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that line is missing entirely, &lt;code&gt;thermal_zone&lt;/code&gt; is either disabled or &lt;code&gt;/sys/class/thermal&lt;/code&gt; is empty — check &lt;code&gt;ls /sys/class/thermal/&lt;/code&gt; on the host. On Pi 4 and 5 with a stock Raspberry Pi OS kernel, it's populated. If you're running a custom kernel or a container OS, it may not be.&lt;/p&gt;

&lt;p&gt;Collectors worth explicitly disabling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;--no-collector.mdadm&lt;/code&gt;&lt;/strong&gt;: Probes RAID arrays. There are none. It still runs and spits errors into the log on every scrape.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;--no-collector.nfs&lt;/code&gt; and &lt;code&gt;--no-collector.nfsd&lt;/code&gt;&lt;/strong&gt;: Same pattern — reads from &lt;code&gt;/proc/net/rpc/nfs&lt;/code&gt; which either doesn't exist or returns zeros, but the read happens on every scrape interval and counts as an I/O op on your SD card.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;--no-collector.xfs&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;--no-collector.zfs&lt;/code&gt;&lt;/strong&gt;: Unless you're running ZFS on your Pi (you're not), drop them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;textfile&lt;/code&gt; collector is underused and worth knowing. Instead of enabling a heavyweight collector that runs on every Prometheus scrape, write a cron job that dumps a &lt;code&gt;.prom&lt;/code&gt; file on a slower schedule — say, every 5 minutes — and let &lt;code&gt;textfile&lt;/code&gt; serve it statically. This is how I expose custom metrics like UPS battery level and Docker container counts without polling on every 15-second scrape. The setup is two lines: point node_exporter at the directory with &lt;code&gt;--collector.textfile.directory=/var/lib/node_exporter/textfile&lt;/code&gt;, then write a script that outputs valid OpenMetrics format and drops the result atomically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# write to tmp then mv — prevents Prometheus scraping a partial file&lt;/span&gt;
&lt;span class="nv"&gt;TMPFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"# HELP my_sensor_temp_celsius Temperature from I2C sensor"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMPFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"# TYPE my_sensor_temp_celsius gauge"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMPFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"my_sensor_temp_celsius &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;read_sensor.py&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMPFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMPFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /var/lib/node_exporter/textfile/sensors.prom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;hwmon&lt;/code&gt; collector is worth a separate callout because the behavior differs by Pi model. On Pi 5, &lt;code&gt;node_hwmon_temp_celsius&lt;/code&gt; appears in metrics, but the chip and sensor labels are sometimes empty strings, which means your Grafana query returns a result but the legend is blank and filtering by label breaks. Debug it before building any panel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:9100/metrics | &lt;span class="nb"&gt;grep &lt;/span&gt;hwmon

&lt;span class="c"&gt;# Pi 5 — you might see:&lt;/span&gt;
&lt;span class="c"&gt;# node_hwmon_temp_celsius{chip="platform_rp1_adc",sensor="temp1"} 42.651&lt;/span&gt;
&lt;span class="c"&gt;# or chip="" sensor="" depending on kernel driver binding&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the labels are empty, use &lt;code&gt;thermal_zone&lt;/code&gt; metrics instead — they're more reliable across Pi generations and the &lt;code&gt;type&lt;/code&gt; label (&lt;code&gt;cpu-thermal&lt;/code&gt;) is always populated.&lt;/p&gt;

&lt;p&gt;Run node_exporter directly on the host, not in a container. This is the one place where "just containerize everything" actively hurts you. The filesystem collector needs to see real mount points, not the container's namespaced view. The netdev collector needs the host network namespace to report &lt;code&gt;eth0&lt;/code&gt; and &lt;code&gt;wlan0&lt;/code&gt; accurately. If you insist on containerizing it, you need &lt;code&gt;--path.rootfs=/host&lt;/code&gt;, a bind mount of &lt;code&gt;/&lt;/code&gt; into the container, host network mode, and PID namespace sharing — at which point you've added four failure surfaces to get the same data you'd get from a single binary on the host. On my own setup I run node_exporter as a systemd service on the Pi itself and only Prometheus and Grafana live in Docker. The service file is minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;span class="nt"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;node_exporter
&lt;span class="nt"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;network.target

&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;span class="nt"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;node_exporter
&lt;span class="nt"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;/usr/local/bin/node_exporter &lt;span class="se"&gt;\
&lt;/span&gt;  --no-collector.mdadm &lt;span class="se"&gt;\
&lt;/span&gt;  --no-collector.nfs &lt;span class="se"&gt;\
&lt;/span&gt;  --no-collector.nfsd &lt;span class="se"&gt;\
&lt;/span&gt;  --no-collector.xfs &lt;span class="se"&gt;\
&lt;/span&gt;  --no-collector.zfs &lt;span class="se"&gt;\
&lt;/span&gt;  --collector.textfile.directory=/var/lib/node_exporter/textfile
&lt;span class="nt"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;on-failure

&lt;span class="k"&gt;[Install]&lt;/span&gt;
&lt;span class="nt"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;multi-user.target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop that in &lt;code&gt;/etc/systemd/system/node_exporter.service&lt;/code&gt;, run &lt;code&gt;systemctl daemon-reload &amp;amp;&amp;amp; systemctl enable --now node_exporter&lt;/code&gt;, and you get accurate host metrics with no namespace gymnastics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Dashboard: Panels That Catch Real Problems
&lt;/h2&gt;

&lt;p&gt;The throttle ratio panel is the single most useful thing you can build for a Pi dashboard, and almost nobody sets it up. The metric &lt;code&gt;node_cpu_scaling_frequency_hertz&lt;/code&gt; tells you the current CPU frequency; &lt;code&gt;node_cpu_scaling_frequency_max_hertz&lt;/code&gt; tells you what it &lt;em&gt;could&lt;/em&gt; be running at. The ratio between them is what actually matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;node_cpu_scaling_frequency_hertz&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cpu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;"cpu0"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;/&lt;/span&gt;
&lt;span class="n"&gt;node_cpu_scaling_frequency_max_hertz&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cpu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;"cpu0"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A healthy Pi sits at 1.0. When it drops to 0.6 or below under load, that's not a busy system — that's a thermally limited one. The distinction matters enormously for diagnosis: if you're seeing slow response times and the CPU panel shows 80% utilization, you'll assume the workload is the problem. But if the throttle ratio is simultaneously showing 0.5, the actual problem is heat, and adding more RAM or tuning your app won't fix it. Add a stat panel with thresholds: green above 0.9, orange between 0.7–0.9, red below 0.7. Pair it with a case without airflow and you'll see red within minutes of a heavy encode or compile job.&lt;/p&gt;

&lt;p&gt;For memory, skip &lt;code&gt;node_memory_MemFree_bytes&lt;/code&gt; entirely. Free memory on Linux is a misleading number because the kernel aggressively uses spare RAM for disk cache. The metric that actually tells you the system is approaching an OOM kill is &lt;code&gt;node_memory_MemAvailable_bytes&lt;/code&gt; — that's the estimate of how much memory can be reclaimed quickly. Express it as a percentage of total RAM and set a Grafana alert threshold at 15%:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;node_memory_MemAvailable_bytes&lt;/span&gt;
      &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;node_memory_MemTotal_bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a Pi 4 with 4GB running Prometheus, node_exporter, and a couple of containers, available memory can drift below 20% during scrape cycles without anything obviously wrong. That 15% threshold gives you a warning before the kernel starts killing processes. Wire this to a Grafana alert that fires to a webhook or ntfy.sh notification — you want to know about it before you SSH in to find your database process missing.&lt;/p&gt;

&lt;p&gt;The disk I/O wait panel is what separates "my Pi is slow" from "my SD card is dying." Use &lt;code&gt;rate(node_disk_io_time_seconds_total[2m])&lt;/code&gt; on your root device — typically &lt;code&gt;mmcblk0&lt;/code&gt;. This metric is the fraction of time the device is busy, so values above 0.8 sustained over several minutes mean processes are sitting in uninterruptible sleep waiting for the card. If you're running Prometheus's TSDB on the same SD card as the OS, you will see this spike every 2 hours when Prometheus compacts blocks to disk. That's expected. What's not expected is a sustained baseline above 0.4 with light workloads — that usually means the card is worn and write latency has degraded badly. Swap to a USB-attached SSD and watch the metric drop to near zero at idle.&lt;/p&gt;

&lt;p&gt;Once you have panels worth keeping, export the dashboard JSON from Grafana's UI (Dashboard → Share → Export → Save to file) and commit it to your provisioning folder. The directory structure Grafana expects is &lt;code&gt;/etc/grafana/provisioning/dashboards/&lt;/code&gt;, and you need exactly two files: a provider YAML and the dashboard JSON itself. The provider YAML is minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/grafana/provisioning/dashboards/pi.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pi-dashboards&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/grafana/provisioning/dashboards&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop the exported JSON in the same directory and restart Grafana. On next boot — or after a container recreate — the dashboard is there automatically, with no clicking through the UI. The one gotcha: Grafana will treat provisioned dashboards as read-only in the UI by default. If you need to make edits, either set &lt;code&gt;allowUiUpdates: true&lt;/code&gt; in the provider YAML (and remember to re-export after changes), or edit the JSON directly and restart. Either way, keeping the JSON in version control means your entire dashboard survives a full wipe and reinstall in about 30 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alerting Without AlertManager Complexity
&lt;/h2&gt;

&lt;p&gt;The standard Prometheus alerting story involves deploying AlertManager as a separate container, writing a routing tree in YAML, configuring inhibition rules, and maintaining receiver configs that drift out of sync with your actual intent. For a single-operator Pi setup, that's architectural overhead that buys you nothing. Grafana's unified alerting — available since Grafana 9, and now the default in Grafana 10+ — handles the entire pipeline inside the same process you're already running. One fewer container, one fewer config file to version, one fewer thing to restart when you change a threshold.&lt;/p&gt;

&lt;p&gt;Contact points are where most people reach for email and immediately hit deliverability problems. A better pairing for self-hosted setups: either a self-hosted &lt;strong&gt;ntfy.sh&lt;/strong&gt; instance or a &lt;strong&gt;Telegram bot webhook&lt;/strong&gt;. Both are free, both survive the kind of flaky home network conditions where your SMTP relay might queue for ten minutes before admitting failure. The ntfy contact point in Grafana takes a URL and an optional token — that's it. For Telegram, you need a bot token from BotFather and your chat ID, then configure a webhook contact point with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Grafana&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;contact&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;point&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Telegram&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;webhook&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;URL:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;https://api.telegram.org/bot&amp;lt;YOUR_BOT_TOKEN&amp;gt;/sendMessage&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Method:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Content-Type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;application/json&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Optional Webhook Settings"&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"chat_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;YOUR_CHAT_ID&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{{ len .Alerts.Firing }} alert(s) firing on Pi: {{ range .Alerts.Firing }}{{ .Labels.alertname }} {{ end }}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two alert rules justify the entire setup and should be configured before you consider anything else. First: CPU temperature above 80°C sustained for 2 minutes. The 2-minute window matters — without it, a brief spike during a compile job pages you at 2am for nothing. The PromQL is straightforward if you're scraping the thermal zone via the node exporter's textfile collector or a custom metric:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Alert: Pi CPU overtemperature&lt;/span&gt;
&lt;span class="c1"&gt;# Condition: for 2m&lt;/span&gt;
&lt;span class="s"&gt;node_thermal_zone_temp{type="cpu-thermal"} &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;80&lt;/span&gt;

&lt;span class="c1"&gt;# If you're exposing temperature as a custom gauge named rpi_cpu_temp_celsius:&lt;/span&gt;
&lt;span class="s"&gt;rpi_cpu_temp_celsius &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second rule is more valuable: projecting disk exhaustion using &lt;code&gt;predict_linear&lt;/code&gt;. This catches slow write leaks — logging misconfiguration, a runaway SQLite database, Docker layer accumulation — long before &lt;code&gt;df -h&lt;/code&gt; looks worrying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Alert: disk will fill within 24 hours based on 4h trend&lt;/span&gt;
&lt;span class="c1"&gt;# Condition: for 10m (avoids alerting on brief write bursts)&lt;/span&gt;
&lt;span class="s"&gt;predict_linear(&lt;/span&gt;
  &lt;span class="s"&gt;node_filesystem_avail_bytes{mountpoint="/", fstype!="tmpfs"}[4h],&lt;/span&gt;
  &lt;span class="m"&gt;86400&lt;/span&gt;   &lt;span class="c1"&gt;# seconds = 24 hours&lt;/span&gt;
&lt;span class="s"&gt;) &amp;lt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The silencing gotcha hits every first-time Grafana alerting user: when Prometheus restarts — during an upgrade, after a config reload, during a Pi reboot — Grafana receives no data for that metric and fires the alert with state &lt;strong&gt;No Data&lt;/strong&gt; by default. This causes a false page every time you touch your stack. The fix is buried in the alert rule editor under "Configure no data and error handling": set &lt;em&gt;No data&lt;/em&gt; to &lt;strong&gt;OK&lt;/strong&gt; during initial setup and any planned maintenance window. Switch it back to &lt;em&gt;Alerting&lt;/em&gt; once you're confident the scrape is stable. It's a per-rule setting, not global, which is the right design — your temperature alert probably should page on no data in production, but your predict_linear disk rule absolutely should not fire just because Prometheus was down for 90 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas That Will Cost You an Afternoon
&lt;/h2&gt;

&lt;p&gt;The Prometheus OOM kill on a 2 GB Pi 4 is the first thing that will happen to you, and it will happen silently — the container just disappears and &lt;code&gt;docker ps&lt;/code&gt; shows nothing running. The default &lt;code&gt;chunks_head_series&lt;/code&gt; limit is effectively unbounded, so Prometheus happily grows its in-memory TSDB head until the kernel OOM killer intervenes. Adding &lt;code&gt;--storage.tsdb.head-chunks-write-queue-size=0&lt;/code&gt; and &lt;code&gt;--query.max-samples=5000000&lt;/code&gt; to your Prometheus flags helps cap runaway query memory, but neither flag fixes the actual problem: WAL writes to a slow SD card create backpressure that keeps more chunks in memory longer than they should be. The real fix is pointing &lt;code&gt;--storage.tsdb.path&lt;/code&gt; at a USB SSD. On a Pi 4 with a decent SSD attached over USB 3.0, WAL flush latency drops from hundreds of milliseconds to single digits, and Prometheus memory behavior becomes predictable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml snippet — Prometheus targeting USB SSD mount&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/prometheus:v2.51.2&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--config.file=/etc/prometheus/prometheus.yml'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.path=/prometheus-data'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.retention.time=15d'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.head-chunks-write-queue-size=0'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--query.max-samples=5000000'&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# USB SSD mounted at /mnt/usb on the host&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/usb/prometheus:/prometheus-data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./prometheus.yml:/etc/prometheus/prometheus.yml:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grafana's SQLite locking errors are a different failure mode but the same root cause: SD card I/O latency. When two browser tabs hit different dashboards simultaneously, Grafana opens concurrent write transactions against &lt;code&gt;grafana.db&lt;/code&gt;, and SQLite's file locking stalls long enough that Grafana's own timeout fires first. You'll see &lt;code&gt;database is locked&lt;/code&gt; in the container logs and a spinner in the UI that never resolves. Moving the Grafana data directory to a &lt;code&gt;tmpfs&lt;/code&gt; path like &lt;code&gt;/tmp/grafana-data&lt;/code&gt; does make the locking errors stop — SQLite on RAM is fast enough — but you lose your dashboard config on every reboot. For anything you want to keep, put the volume on the USB drive and set &lt;code&gt;GF_DATABASE_WAL=true&lt;/code&gt; in your environment block, which at least enables WAL mode and reduces lock contention.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/grafana:10.4.2&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GF_DATABASE_WAL=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GF_PATHS_DATA=/var/lib/grafana&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# Same USB SSD, different subdirectory&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/usb/grafana:/var/lib/grafana&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Time sync problems produce some of the most confusing Prometheus behavior: graphs with gaps, negative rate calculations, and counters that appear to reset. On Raspberry Pi OS Lite, &lt;code&gt;systemd-timesyncd&lt;/code&gt; is enabled by default, but if you've also installed &lt;code&gt;ntp&lt;/code&gt; or &lt;code&gt;chrony&lt;/code&gt; at any point, you can end up with two daemons fighting over the clock. Run &lt;code&gt;timedatectl status&lt;/code&gt; and look at the &lt;code&gt;NTP service&lt;/code&gt; line — if it says &lt;code&gt;active&lt;/code&gt; but &lt;code&gt;timedatectl timesync-status&lt;/code&gt; shows stale sync timestamps, something is blocking &lt;code&gt;timesyncd&lt;/code&gt;. The clean fix is picking one daemon and fully disabling the other: &lt;code&gt;sudo systemctl disable --now ntp&lt;/code&gt; followed by &lt;code&gt;sudo systemctl enable --now systemd-timesyncd&lt;/code&gt;. Prometheus itself timestamps scraped metrics at the moment of the HTTP response, so even a 30-second clock jump introduces gaps that look like node failures in your dashboards.&lt;/p&gt;

&lt;p&gt;ARM64 image availability is the gotcha that bites you late, after you've built out half your stack. &lt;code&gt;prom/prometheus&lt;/code&gt;, &lt;code&gt;prom/node-exporter&lt;/code&gt;, and &lt;code&gt;grafana/grafana&lt;/code&gt; all publish proper &lt;code&gt;linux/arm64&lt;/code&gt; manifests and the images run natively on Pi 4 and Pi 5 without emulation. The problem is community exporters — things like MySQL exporters from third-party registries, or Homebridge metric plugins, or anything that hasn't seen a release in 18 months. Before adding any exporter to your &lt;code&gt;docker-compose.yml&lt;/code&gt;, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker manifest inspect prom/mysqld-exporter:v0.15.1 | &lt;span class="nb"&gt;grep &lt;/span&gt;architecture
&lt;span class="c"&gt;# Expected output includes:&lt;/span&gt;
&lt;span class="c"&gt;#   "architecture": "arm64"&lt;/span&gt;
&lt;span class="c"&gt;# If you only see "amd64", you're running under QEMU emulation&lt;/span&gt;
&lt;span class="c"&gt;# which means 3-5x slower scrape handling and potential segfaults&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;QEMU emulation on Docker Desktop or via &lt;code&gt;binfmt_misc&lt;/code&gt; on the Pi itself won't crash immediately — the container starts fine, metrics appear to scrape, and everything looks normal until load increases or you hit a specific syscall that QEMU emulates incorrectly. Check the architecture before you wire something into your alert rules and depend on it being accurate.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/grafana-prometheus-on-a-raspberry-pi-build-a-telemetry-dashboard-that-actually-stays-up/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Free Self-Hosted Alternatives to Datadog: What Actually Works on Your Own Hardware</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Wed, 22 Jul 2026 08:10:57 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/free-self-hosted-alternatives-to-datadog-what-actually-works-on-your-own-hardware-16m4</link>
      <guid>https://dev.to/ericwoooo_kr/free-self-hosted-alternatives-to-datadog-what-actually-works-on-your-own-hardware-16m4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Datadog's per-host pricing is deliberately structured to feel cheap at two or three nodes and expensive by the time you actually need it.  The base infrastructure plan charges per host, per month, and that cost multiplies with every add-on — APM, log management, synthetics — each&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~21 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Why Datadog's Pricing Forces the Self-Hosting Conversation&lt;/li&gt;
&lt;li&gt;The Five Contenders and Their Honest Resource Costs&lt;/li&gt;
&lt;li&gt;Setting Up Prometheus + Grafana in Docker Compose&lt;/li&gt;
&lt;li&gt;VictoriaMetrics as a Prometheus Drop-In: Where It Wins&lt;/li&gt;
&lt;li&gt;Netdata for Operators Who Want Zero Configuration&lt;/li&gt;
&lt;li&gt;Matching Tool to Situation: The Decision Framework&lt;/li&gt;
&lt;li&gt;Common Failure Modes and How to Catch Them Early&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why Datadog's Pricing Forces the Self-Hosting Conversation
&lt;/h2&gt;

&lt;p&gt;Datadog's per-host pricing is deliberately structured to feel cheap at two or three nodes and expensive by the time you actually need it. The base infrastructure plan charges per host, per month, and that cost multiplies with every add-on — APM, log management, synthetics — each billed separately. Once you're past a handful of nodes, the invoice scales faster than your infrastructure does. The free tier lets you evaluate the product convincingly, then cuts retention to one day and limits custom metrics aggressively enough that real alerting becomes impossible without upgrading.&lt;/p&gt;

&lt;p&gt;The specific operator problem isn't abstract cost anxiety — it's that you need continuous visibility into memory pressure, disk saturation rates, container restart loops, and traffic anomalies across your own hardware, and you don't want that telemetry leaving your network. Sending host-level metrics to a third-party SaaS means your capacity patterns, failure modes, and workload fingerprints are visible to another company's infrastructure. For homelab operators running LLM workloads or anything with sensitive data in memory, that's a hard no before you even look at the bill.&lt;/p&gt;

&lt;p&gt;What self-hosted monitoring actually demands breaks down into three functional pieces that you can run as a monolith or compose from separate tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;A metrics store with sustainable retention&lt;/strong&gt; — something that handles 30-day or 90-day TSDB retention without OOMing on a modest VM. Prometheus with its default in-memory index is fine at small cardinality but will eat RAM if you're scraping hundreds of containers with high-label-cardinality metrics. VictoriaMetrics handles the same workload with a much smaller heap.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;A dashboard layer&lt;/strong&gt; — Grafana is the obvious answer here, and it talks to nearly every backend. The question is whether you want dashboards bundled with the metrics store (Netdata does this) or composable (Prometheus + Grafana).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;An alerting engine&lt;/strong&gt; — Prometheus AlertManager is the standard, but it's configuration-heavy. Tools like Grafana Alerting or Netdata's built-in alerting give you something usable without writing YAML state machines from scratch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The composable route gives you more control and survives tool swaps. The monolith route (a single tool that does all three) gets you to alerting faster but makes migration painful later. Neither is universally correct — it depends on whether you're instrumenting three personal servers or managing a dozen-node homelab with mixed workloads. For readers also evaluating AI-assisted dev tooling in their stack, see our guide on &lt;a href="https://techdigestor.com/best-ai-coding-tools-2026/" rel="noopener noreferrer"&gt;AI Coding Tools in 2026: Cloud Copilots vs Local Models&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Five Contenders and Their Honest Resource Costs
&lt;/h2&gt;

&lt;p&gt;The most common trap when replacing Datadog is reaching for Prometheus + Grafana immediately because every tutorial points there — without accounting for what that stack actually costs at rest. Prometheus memory usage is governed almost entirely by cardinality: the number of unique label combinations across all your metrics. A modest home lab scraping 10 exporters with aggressive labeling (pod name, container name, instance, job, environment, region) can push RAM well above 1GB before you've connected a single dashboard. On my 32GB workstation this is invisible, but on a 4GB VPS it's the first thing that bites. Budget 500MB as an absolute floor for a minimal Prometheus instance and expect 1.5–2GB once you add node_exporter, cAdvisor, and a few application exporters with non-trivial label sets.&lt;/p&gt;

&lt;p&gt;VictoriaMetrics solves exactly that problem. The single-node binary (&lt;code&gt;victoria-metrics&lt;/code&gt;) is a drop-in Prometheus remote_write target and also speaks PromQL natively, so your existing Grafana dashboards require zero changes. The compression ratio compared to Prometheus's TSDB is genuinely dramatic — the same time series that chews through 10GB of disk in Prometheus will often land under 3GB in VictoriaMetrics. More importantly, its memory ceiling is roughly proportional to active query complexity, not raw cardinality, which means you can throw high-cardinality Kubernetes label sets at it without triggering OOM kills. It ships with its own alerting engine (vmalert) so you can drop Alertmanager from the stack entirely if you want a simpler footprint.&lt;/p&gt;

&lt;p&gt;Netdata occupies a completely different design space. Rather than a scrape-then-store model, it runs as a streaming agent with 1-second resolution out of the box, auto-discovers Docker containers, systemd services, and dozens of application plugins without any configuration. The agent itself requires no cloud account — the Netdata Cloud UI is optional and the local dashboard runs fully offline at &lt;code&gt;http://localhost:19999&lt;/code&gt;. The trade-off is that local retention defaults to roughly 1 day of per-second data (configurable via the &lt;code&gt;dbengine&lt;/code&gt; settings in &lt;code&gt;netdata.conf&lt;/code&gt;), so Netdata excels at real-time visibility and short-term debugging but isn't the right fit if you need 90-day trend analysis. RAM footprint on a typical node runs 80–150MB, which makes it genuinely viable on a Raspberry Pi 4.&lt;/p&gt;

&lt;p&gt;Checkmk Raw Edition comes from a different heritage entirely — enterprise infrastructure monitoring rather than cloud-native metrics pipelines. The free tier has no host limit, which is unusual and worth taking seriously. The install footprint is heavier: it runs its own Apache instance, a Python-based check engine, and an OMD (Open Monitoring Distribution) site structure that isolates itself from your system packages. Expect 300–500MB RAM at idle for a single site. What you get in return is strong host state monitoring — service checks, hardware health via SNMP, Windows agent support — that Prometheus + Grafana handles awkwardly through a patchwork of exporters. If your environment mixes Linux servers, network gear, and Windows machines and you care more about "is this thing up and healthy" than "show me p99 latency histograms," Checkmk fits better than the metrics-first stack.&lt;/p&gt;

&lt;p&gt;Here's how they compare on the dimensions that actually matter for small self-hosters:&lt;/p&gt;

&lt;p&gt;Tool&lt;/p&gt;

&lt;p&gt;Storage Backend&lt;/p&gt;

&lt;p&gt;Min RAM (realistic)&lt;/p&gt;

&lt;p&gt;Retention Default&lt;/p&gt;

&lt;p&gt;Docker Support&lt;/p&gt;

&lt;p&gt;Alerting Built-in&lt;/p&gt;

&lt;p&gt;Biggest Dealbreaker for Small Self-Hosters&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prometheus + Grafana&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Custom TSDB (local)&lt;/p&gt;

&lt;p&gt;500MB–2GB&lt;/p&gt;

&lt;p&gt;15 days&lt;/p&gt;

&lt;p&gt;Via cAdvisor exporter&lt;/p&gt;

&lt;p&gt;Alertmanager (separate process)&lt;/p&gt;

&lt;p&gt;Cardinality spikes OOM without warning; five separate processes to maintain&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VictoriaMetrics&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Custom TSDB (local)&lt;/p&gt;

&lt;p&gt;200–400MB&lt;/p&gt;

&lt;p&gt;1 month (configurable)&lt;/p&gt;

&lt;p&gt;Via cAdvisor or Prometheus exporters&lt;/p&gt;

&lt;p&gt;vmalert (built-in)&lt;/p&gt;

&lt;p&gt;No native dashboards — still need Grafana for visualization&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Netdata&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;dbengine (local, tiered)&lt;/p&gt;

&lt;p&gt;80–150MB&lt;/p&gt;

&lt;p&gt;~1 day per-second, longer at lower resolution&lt;/p&gt;

&lt;p&gt;Auto-discovers without config&lt;/p&gt;

&lt;p&gt;Yes (health.d rules)&lt;/p&gt;

&lt;p&gt;Long-term retention requires significant disk + tuning; PromQL not supported natively&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkmk Raw&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;RRDtool&lt;/p&gt;

&lt;p&gt;300–500MB&lt;/p&gt;

&lt;p&gt;2 years (RRD, fixed-size, downsampled)&lt;/p&gt;

&lt;p&gt;Agent or SNMP; container discovery not as automatic&lt;/p&gt;

&lt;p&gt;Yes (native, rule-based)&lt;/p&gt;

&lt;p&gt;RRDtool pre-aggregates data — you cannot query raw samples after the fact&lt;/p&gt;

&lt;p&gt;The Checkmk RRDtool point deserves emphasis: RRDtool is a fixed-size circular buffer that downsamples older data automatically. After a week, your 1-minute resolution data gets consolidated into 5-minute averages and the originals are gone. For host-state monitoring this is fine. For post-incident forensics where you need to replay exactly what a metric did at 3:47 AM two weeks ago, it's a hard wall. Prometheus, VictoriaMetrics, and Netdata's dbengine all retain raw samples, which is the correct default for anything beyond basic uptime checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Prometheus + Grafana in Docker Compose
&lt;/h2&gt;

&lt;p&gt;The stack that trips up most first-timers isn't Prometheus itself — it's the combination of volume mount paths that silently reset on container restart, Grafana's security defaults that give you a blank login page with zero log output, and a &lt;code&gt;prometheus.yml&lt;/code&gt; that scrapes nothing because the service name doesn't match what you think it does. Start with the compose file and get all three services talking before touching dashboards.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.8"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/prometheus:v2.52.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9090:9090"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;prometheus_data:/prometheus&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--config.file=/etc/prometheus/prometheus.yml"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.path=/prometheus"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.retention.time=30d"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.retention.size=10GB"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--web.enable-lifecycle"&lt;/span&gt;   &lt;span class="c1"&gt;# lets you POST /-/reload without restarting&lt;/span&gt;

  &lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/grafana:10.4.2&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;grafana_data:/var/lib/grafana&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GF_AUTH_ANONYMOUS_ENABLED=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GF_SECURITY_ADMIN_PASSWORD=changeme&lt;/span&gt;   &lt;span class="c1"&gt;# still sets the admin account&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;

  &lt;span class="na"&gt;node_exporter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/node-exporter:v1.8.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_exporter&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9100:9100"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# read-only host mounts so node_exporter sees real filesystem/proc data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/proc:/host/proc:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/sys:/host/sys:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/:/rootfs:ro&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--path.procfs=/host/proc"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--path.sysfs=/host/sys"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--path.rootfs=/rootfs"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheus_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;grafana_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# prometheus/prometheus.yml&lt;/span&gt;
&lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scrape_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;
  &lt;span class="na"&gt;evaluation_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;

&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prometheus"&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost:9090"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node_exporter"&lt;/span&gt;
    &lt;span class="c1"&gt;# use the Docker service name — not localhost, not the host IP&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node_exporter:9100"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;GF_AUTH_ANONYMOUS_ENABLED=true&lt;/code&gt; env var is the non-obvious one. Grafana 10.x ships with anonymous access off by default, and when you hit port 3000 on first load without it, you get a login form — which is expected — but if you're reverse-proxying through nginx or Tailscale, the redirect chain fails silently and you see a blank page or a 302 loop. Setting &lt;code&gt;GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer&lt;/code&gt; alongside it means unauthenticated users land on dashboards read-only, which is fine for a home lab or internal tool. If you want to lock it down later, flip the env var and bounce the container — no data loss since everything lives in the named volume.&lt;/p&gt;

&lt;p&gt;For Alertmanager, the routing tree is where most people create their own alert storms. The defaults are aggressive. Here's a skeleton that actually behaves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# alertmanager/alertmanager.yml&lt;/span&gt;
&lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;resolve_timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;

&lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alertname"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;group_wait&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;        &lt;span class="c1"&gt;# wait this long before sending the first alert in a group&lt;/span&gt;
  &lt;span class="na"&gt;group_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;     &lt;span class="c1"&gt;# how long to wait before sending new alerts in an existing group&lt;/span&gt;
  &lt;span class="na"&gt;repeat_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4h&lt;/span&gt;    &lt;span class="c1"&gt;# don't re-fire a still-active alert more often than this&lt;/span&gt;
  &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slack-ops"&lt;/span&gt;

&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slack-ops"&lt;/span&gt;
    &lt;span class="na"&gt;slack_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;api_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://hooks.slack.com/services/YOUR/WEBHOOK/URL"&lt;/span&gt;
        &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#alerts"&lt;/span&gt;
        &lt;span class="na"&gt;send_resolved&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;template&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"slack.default.title"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
          &lt;span class="s"&gt;{{ range .Alerts }}&lt;/span&gt;
            &lt;span class="s"&gt;*Alert:* {{ .Annotations.summary }}&lt;/span&gt;
            &lt;span class="s"&gt;*Details:* {{ range .Labels.SortedPairs }} {{ .Name }}={{ .Value }} {{ end }}&lt;/span&gt;
          &lt;span class="s"&gt;{{ end }}&lt;/span&gt;

&lt;span class="na"&gt;inhibit_rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical"&lt;/span&gt;
    &lt;span class="na"&gt;target_match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;warning"&lt;/span&gt;
    &lt;span class="na"&gt;equal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alertname"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# suppress warning if critical is already firing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;group_wait: 30s&lt;/code&gt; / &lt;code&gt;group_interval: 5m&lt;/code&gt; combination is what saves you from getting 40 Slack messages during a single node bounce. &lt;code&gt;group_wait&lt;/code&gt; batches the initial burst; &lt;code&gt;group_interval&lt;/code&gt; controls how long Alertmanager waits before sending updates to an already-notified group. Set &lt;code&gt;repeat_interval&lt;/code&gt; below 1h and you will regret it during any multi-hour incident.&lt;/p&gt;

&lt;p&gt;On storage: setting only &lt;code&gt;--storage.tsdb.retention.time=30d&lt;/code&gt; means Prometheus will happily consume unbounded disk if your scrape cardinality spikes — a misbehaving exporter dumping thousands of label combinations will fill a volume in hours with no obvious warning until the container OOMs or the disk hits 100%. Set &lt;code&gt;--storage.tsdb.retention.size=10GB&lt;/code&gt; alongside the time flag and Prometheus enforces whichever limit triggers first. On a typical single-host setup scraping node_exporter plus a few app exporters at 15s intervals, 10GB covers well over 30 days — but if you add Kubernetes pod metrics or high-cardinality app labels, that number compresses fast. Run &lt;code&gt;prometheus_tsdb_head_series&lt;/code&gt; as a metric to watch cardinality in real time; anything above 500k active series on a single Prometheus instance starts hurting query latency noticeably.&lt;/p&gt;

&lt;h2&gt;
  
  
  VictoriaMetrics as a Prometheus Drop-In: Where It Wins
&lt;/h2&gt;

&lt;p&gt;The single-binary story is genuinely what gets you to try it. One Docker command and you have a running TSDB that accepts Prometheus &lt;code&gt;remote_write&lt;/code&gt; without touching your scrape configs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; victoria-metrics &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /path/victoria-metrics-data:/victoria-metrics-data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 8428:8428 &lt;span class="se"&gt;\&lt;/span&gt;
  victoriametrics/victoria-metrics:v1.101.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--retentionPeriod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;6  &lt;span class="c"&gt;# months, not days&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in your existing &lt;code&gt;prometheus.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;remote_write&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://your-vm-host:8428/api/v1/write&lt;/span&gt;
    &lt;span class="c1"&gt;# keep your existing Prometheus scraping — VM just receives the data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;High-cardinality label sets that were making Prometheus grind — think per-pod labels with UUID suffixes — compress dramatically under VictoriaMetrics's storage engine. You don't have to tune anything; the headaches just shrink.&lt;/p&gt;

&lt;p&gt;The disk and memory difference is real and consistent enough to plan around. VictoriaMetrics's custom compression achieves 3–7x less disk usage than Prometheus's TSDB on comparable datasets — this is documented behavior from the project, reproducible by pointing both at the same &lt;code&gt;remote_write&lt;/code&gt; stream and watching &lt;code&gt;du -sh&lt;/code&gt; over a week. More practically: Prometheus holds its entire index in RAM. VictoriaMetrics doesn't need to, so its memory ceiling is lower under the same cardinality. On a constrained host — a small VPS or a shared homelab node — that difference determines whether the process survives a traffic spike.&lt;/p&gt;

&lt;p&gt;VMAlert handles alerting rules and the compatibility story is mostly painless. Your existing Prometheus alerting rule YAML runs without modification in the common case. You point VMAlert at your VictoriaMetrics datasource, wire it to a notifier (Alertmanager if you have it, or a direct webhook if you don't), and you're done. For setups where you don't want to run a full Alertmanager stack, VMAlert can call a webhook directly — enough to cover Slack, PagerDuty, or whatever HTTP endpoint you're using for notifications.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# vmalert minimal config pointing at VM + direct webhook notifier&lt;/span&gt;
./vmalert &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--datasource&lt;/span&gt;.url&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8428 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--remoteWrite&lt;/span&gt;.url&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8428 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--notifier&lt;/span&gt;.url&lt;span class="o"&gt;=&lt;/span&gt;http://your-webhook-endpoint/alert &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rule&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/etc/vmalert/rules/&lt;span class="k"&gt;*&lt;/span&gt;.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where VictoriaMetrics bites you: its PromQL dialect diverges just enough to break some Grafana dashboards built around Prometheus-specific functions. &lt;code&gt;histogram_quantile&lt;/code&gt; with native histograms, certain &lt;code&gt;label_replace&lt;/code&gt; edge cases, and subquery syntax can behave differently or fail silently — returning empty results rather than an error, which is the worst kind of failure for a monitoring dashboard. Before migrating any production dashboards, run both Prometheus and VictoriaMetrics in parallel on the same data stream, load every dashboard against both datasources, and diff the panel outputs. The incompatibilities are rarely showstoppers but they're also rarely obvious until a dashboard goes blank during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Netdata for Operators Who Want Zero Configuration
&lt;/h2&gt;

&lt;p&gt;Most monitoring tools front-load the configuration burden — you spend an hour writing scrapers before seeing a single graph. Netdata inverts that. Run this on a bare Linux host and you'll have a full dashboard in under a minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wget &lt;span class="nt"&gt;-O&lt;/span&gt; /tmp/netdata-kickstart.sh https://my-netdata.io/kickstart.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; sh /tmp/netdata-kickstart.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single command installs the agent, detects your OS, and starts streaming CPU per-core, memory pressure, disk I/O, network throughput, and swap at 1-second granularity. No YAML, no scrape targets, no exporters. The dashboard is live at &lt;code&gt;http://your-host:19999&lt;/code&gt; before you've had time to open the docs. For operators who've wrestled with Prometheus + Grafana setup just to watch a box — this contrast is jarring in a good way.&lt;/p&gt;

&lt;p&gt;Docker support is equally low-friction. The agent reads &lt;code&gt;/var/run/docker.sock&lt;/code&gt; and automatically surfaces per-container cgroup metrics — CPU, memory limits vs. actual usage, network I/O — without requiring any label annotations or compose file edits. On my workstation running a dozen containers through Docker, every one of them showed up named and graphed within seconds of the agent starting. The one gotcha: the agent process needs socket access, so if you're running Netdata itself in a container you need to mount the socket explicitly. Running it on the host directly sidesteps the whole problem.&lt;/p&gt;

&lt;p&gt;The storage behavior trips up most first-time users. Out of the box, Netdata stores metrics in a RAM ring-buffer — roughly one hour of history, gone on restart. For lab use that's fine; for anything production-adjacent it's a problem. The fix is switching to dbengine mode, which persists to disk with a configurable cap. Edit &lt;code&gt;/etc/netdata/netdata.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[db]&lt;/span&gt;
    &lt;span class="py"&gt;mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;dbengine&lt;/span&gt;
    &lt;span class="c"&gt;# storage tiers control how long data is kept at each resolution
&lt;/span&gt;    &lt;span class="err"&gt;storage&lt;/span&gt; &lt;span class="py"&gt;tiers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;
    &lt;span class="c"&gt;# tier 0: 1-second granularity, cap in MiB
&lt;/span&gt;    &lt;span class="err"&gt;dbengine&lt;/span&gt; &lt;span class="err"&gt;multihost&lt;/span&gt; &lt;span class="err"&gt;disk&lt;/span&gt; &lt;span class="err"&gt;space&lt;/span&gt; &lt;span class="py"&gt;MB&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1024&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;mode = dbengine&lt;/code&gt; and a 1 GB disk cap you get several days of 1-second data for a moderately busy host. The tiering system downsamples older data automatically — tier 1 goes to per-minute aggregates, tier 2 to hourly — so you're not burning disk storing raw second-level data indefinitely. This is in the docs but buried several pages deep, which is why a lot of people assume Netdata just doesn't do persistence.&lt;/p&gt;

&lt;p&gt;The alert system is where the tool shows its seams. Rules live in &lt;code&gt;/etc/netdata/health.d/*.conf&lt;/code&gt; and the format is Netdata-specific — not compatible with Prometheus alerting rules and not transferable if you ever switch stacks. Simple threshold alerts are genuinely readable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt; &lt;span class="na"&gt;alarm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;disk_space_usage&lt;/span&gt;
    &lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;disk.space&lt;/span&gt;
&lt;span class="na"&gt;lookup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;average -10m percentage of used&lt;/span&gt;
 &lt;span class="s"&gt;units&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="err"&gt;%&lt;/span&gt;
 &lt;span class="na"&gt;every&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
  &lt;span class="s"&gt;warn&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$this &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;80&lt;/span&gt;
  &lt;span class="na"&gt;crit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$this &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;90&lt;/span&gt;
  &lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;disk space usage on $label:mount_point&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For that kind of alert — one metric, one threshold, notify when breached — the format is cleaner than writing a PromQL expression. But if you need composite conditions (alert when CPU is high AND disk I/O is saturated AND available memory is below X), the rule language gets awkward fast. You can reference other alarms and use &lt;code&gt;$this&lt;/code&gt; substitution, but there's no join-like construct across different collectors the way PromQL handles it natively. If your alerting needs are threshold-based on individual metrics, Netdata's built-in rules are genuinely usable. If you're building multi-signal correlation alerts, you'll hit the ceiling quickly and probably end up routing to an external system anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Matching Tool to Situation: The Decision Framework
&lt;/h2&gt;

&lt;p&gt;Most people spend hours comparing dashboards before asking the question that actually narrows the field: how many nodes, and how long does the data need to live? Those two numbers eliminate more options than any feature matrix.&lt;/p&gt;

&lt;h4&gt;
  
  
  Under 10 nodes — Netdata with dbengine, setup under 30 minutes
&lt;/h4&gt;

&lt;p&gt;Netdata's dbengine mode stores metrics compressed on disk rather than purely in RAM, so you're not trading retention for node count at small scale. The install is a single command, the auto-detection of processes, containers, and network interfaces works without touching a config file, and the built-in dashboards are genuinely useful out of the box — no Grafana provisioning, no data source wiring. The trade-off: Netdata's alerting is passable but not composable. If you need routing rules that send disk alerts to one channel and app latency to another, you'll hit the ceiling fast. For a handful of hosts where you want eyes on the system in under an hour, nothing else comes close on setup time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Netdata one-liner install — works on Debian, Ubuntu, RHEL, Fedora&lt;/span&gt;
wget &lt;span class="nt"&gt;-O&lt;/span&gt; /tmp/netdata-kickstart.sh https://my-netdata.io/kickstart.sh
sh /tmp/netdata-kickstart.sh &lt;span class="nt"&gt;--stable-channel&lt;/span&gt; &lt;span class="nt"&gt;--disable-telemetry&lt;/span&gt;

&lt;span class="c"&gt;# Confirm dbengine is active (default since v1.23)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; dbengine /etc/netdata/netdata.conf
&lt;span class="c"&gt;# Expected: [db] mode = dbengine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  10–50 nodes, 90-day retention, already on Grafana: VictoriaMetrics single-node
&lt;/h4&gt;

&lt;p&gt;VictoriaMetrics single-node binary accepts Prometheus remote_write from any scrape agent, compresses metrics better than stock Prometheus on disk, and runs with a fraction of the memory overhead at this node count. You drop the existing Grafana dashboards on top, point the data source at the VictoriaMetrics HTTP port, and you're done. There's no cluster mode to configure, no separate compaction job to monitor, and retention is a single startup flag: &lt;code&gt;-retentionPeriod=3&lt;/code&gt; for three months. The ops burden is genuinely low — it restarts cleanly, the WAL handles ungraceful shutdowns, and you don't need a sidecar to manage block lifecycle. Where it doesn't help: if your org already has Thanos running or you need multi-tenant query isolation, you're better off staying in the Prometheus ecosystem and not bifurcating your storage layer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Minimal VictoriaMetrics single-node with 90-day retention&lt;/span&gt;
&lt;span class="s"&gt;docker run -d \&lt;/span&gt;
  &lt;span class="s"&gt;--name victoriametrics \&lt;/span&gt;
  &lt;span class="s"&gt;-p 8428:8428 \&lt;/span&gt;
  &lt;span class="s"&gt;-v /data/vm:/storage \&lt;/span&gt;
  &lt;span class="s"&gt;victoriametrics/victoria-metrics:v1.101.0 \&lt;/span&gt;
  &lt;span class="s"&gt;-storageDataPath=/storage \&lt;/span&gt;
  &lt;span class="s"&gt;-retentionPeriod=3&lt;/span&gt;  &lt;span class="c1"&gt;# months&lt;/span&gt;

&lt;span class="c1"&gt;# Prometheus scrape agent remote_write config&lt;/span&gt;
&lt;span class="na"&gt;remote_write&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://victoriametrics:8428/api/v1/write&lt;/span&gt;
    &lt;span class="na"&gt;queue_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;max_samples_per_send&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Multi-service stack needing composite alerting: Prometheus + Alertmanager + Grafana
&lt;/h4&gt;

&lt;p&gt;The setup overhead is real — you're writing scrape configs, wiring Alertmanager receivers, tuning recording rules — but the ecosystem payoff is also real. Every non-trivial open source project ships a Prometheus exporter. Recording rules let you pre-compute expensive aggregations at scrape time so dashboards stay fast. Alertmanager's routing tree handles deduplication, grouping, and inhibition in ways that YAML-configured alternatives simply don't match. And if you ever need to scale retention, the Thanos sidecar drops onto an existing Prometheus deployment without requiring you to migrate your metrics format or re-instrument your apps. Accept that the first weekend of config is genuinely fiddly. The compounding value of the exporter ecosystem is worth it once you pass a handful of distinct services.&lt;/p&gt;

&lt;h4&gt;
  
  
  Legacy infra, Windows hosts, SNMP, or service-state monitoring: Checkmk Raw Edition
&lt;/h4&gt;

&lt;p&gt;Prometheus exporters for Windows exist but they're a second-class experience — the WMI exporter requires PowerShell provisioning, the cardinality explodes with IIS or SQL Server metrics, and you end up writing PromQL that nobody else on your team can read. Checkmk's agent installs as an MSI, auto-discovers services, and speaks the same check language across Linux, Windows, and SNMP devices. The Raw Edition (fully open source, no node limit beyond what your hardware handles) gives you service-state monitoring — is this Windows service running, is this SNMP interface up — which is categorically different from metric-threshold alerting. The install is heavier: it runs its own web interface, its own notification engine, and an OMD-based site structure that takes getting used to. That's the trade you make for a tool that actually handles the breadth of a mixed infrastructure without needing a different agent per platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Failure Modes and How to Catch Them Early
&lt;/h2&gt;

&lt;p&gt;The failure that kills self-hosted monitoring setups fastest isn't a missing feature — it's Prometheus cardinality explosion. Add one exporter that stuffs a request ID, trace ID, or UUID into a label value and you've created a new time series per request. Prometheus holds all active series in memory, so this compounds fast: a moderately busy API endpoint with request IDs in labels can generate millions of unique series within hours. The OOM kill comes with no warning and no useful log message, just a dead process. The fix is to audit before you ship. Hit &lt;code&gt;:9090/tsdb/status&lt;/code&gt; in a browser and look at the "Top 10 series by label name" table — it shows which labels are contributing the most cardinality. If you see anything unbounded at the top, fix the exporter config or relabeling rules before it reaches production. Specifically, use &lt;code&gt;metric_relabel_configs&lt;/code&gt; in your scrape config to drop or hash the offending label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metric_relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;request_id&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;labeldrop&lt;/span&gt;
    &lt;span class="c1"&gt;# dropping unbounded label before TSDB ingestion&lt;/span&gt;
    &lt;span class="c1"&gt;# hashing instead of dropping: action: replace, target_label: request_id, replacement: "redacted"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grafana data source drift is subtle enough that it doesn't announce itself — panels just go empty and the error message is usually something useless like "No data." This happens constantly after a VictoriaMetrics migration because VM and Prometheus have different data source UIDs in Grafana, and any dashboard that hardcoded a UID internally now points at nothing. The reliable fix is to stop letting Grafana manage data source configuration interactively. Provision it via YAML with an explicit, stable UID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/grafana/provisioning/datasources/victoriametrics.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;datasources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;VictoriaMetrics&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
    &lt;span class="na"&gt;uid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-vm-ds-uid-001&lt;/span&gt;   &lt;span class="c1"&gt;# hardcoded — same across restores, migrations, upgrades&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://victoriametrics:8428&lt;/span&gt;
    &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
    &lt;span class="na"&gt;isDefault&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that file in place, the UID survives container rebuilds and database restores. Any dashboard JSON that references &lt;code&gt;my-vm-ds-uid-001&lt;/code&gt; will resolve correctly regardless of what Grafana's internal state thinks. Without this, every environment rebuild risks silent dashboard rot.&lt;/p&gt;

&lt;p&gt;Netdata's default alert thresholds are tuned for general-purpose Linux servers, which means they'll fire constantly on machines doing unusual workloads. Running Ollama with a large model loaded keeps GPU VRAM pinned near saturation by design — that's the point — but the memory pressure also shows up in system memory metrics and triggers &lt;code&gt;memory.available&lt;/code&gt; alerts in a loop. The right fix isn't silencing all alerts, it's retuning specifically for that machine. Override the threshold in &lt;code&gt;/etc/netdata/health.d/ram.conf&lt;/code&gt; (create it if it doesn't exist — overrides in &lt;code&gt;/etc/netdata/health.d/&lt;/code&gt; take precedence over &lt;code&gt;/usr/lib/netdata/conf.d/&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/netdata/health.d/ram.conf&lt;/span&gt;
 &lt;span class="na"&gt;alarm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ram_available&lt;/span&gt;
    &lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;system.ram&lt;/span&gt;
&lt;span class="na"&gt;lookup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;average -1m percentage of avail&lt;/span&gt;
 &lt;span class="s"&gt;units&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="err"&gt;%&lt;/span&gt;
 &lt;span class="na"&gt;every&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
  &lt;span class="s"&gt;warn&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$this &amp;lt; &lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;      &lt;span class="c1"&gt;# drop from default ~20% to 5% for inference workloads&lt;/span&gt;
  &lt;span class="na"&gt;crit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$this &amp;lt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;percentage of available RAM — tuned down for Ollama host&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checkmk's default active check interval looks reasonable on paper until you have 50+ hosts and start watching monitoring server CPU. Active checks — SSH-based checks, custom scripts, anything that spawns a process — stack up fast. The monitoring server is running one check process per host per service per minute. For non-critical services (disk space on a dev box, certificate expiry checks with 90-day certs), bump the check interval to 5 or 10 minutes in the service configuration. In Checkmk's Setup UI, that's under the "Normal check interval for service checks" rule in the ruleset browser, scoped to a host tag so you don't accidentally slow down critical path checks. The practical difference between a 1-minute and 5-minute interval for "disk usage on the NAS" is zero operational value — the difference in CPU headroom on a small monitoring host is measurable.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/free-self-hosted-alternatives-to-datadog-what-actually-works-on-your-own-hardware/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Raspberry Pi Telemetry Dashboard for Home Automation: Setup to Production</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Mon, 20 Jul 2026 08:11:08 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/raspberry-pi-telemetry-dashboard-for-home-automation-setup-to-production-248o</link>
      <guid>https://dev.to/ericwoooo_kr/raspberry-pi-telemetry-dashboard-for-home-automation-setup-to-production-248o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The sensor data problem isn't that you're not collecting it — Home Assistant's SQLite recorder captures almost everything by default.  The problem is that SQLite starts choking on range queries once your history table grows past a few weeks of dense sensor data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~22 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem: Home Automation Data Is Scattered and Unactionable&lt;/li&gt;
&lt;li&gt;Hardware and OS Prerequisites Before You Touch a Config File&lt;/li&gt;
&lt;li&gt;Docker Compose Stack: InfluxDB 2.x, Grafana, and Telegraf&lt;/li&gt;
&lt;li&gt;Pulling Home Automation Data Into the Pipeline&lt;/li&gt;
&lt;li&gt;Building the Grafana Dashboard: Panels That Actually Tell You Something&lt;/li&gt;
&lt;li&gt;Retention, Storage, and Keeping the Pi From Dying Under Load&lt;/li&gt;
&lt;li&gt;Wiring Grafana Alerts Into Your Automation Pipeline&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem: Home Automation Data Is Scattered and Unactionable
&lt;/h2&gt;

&lt;p&gt;The sensor data problem isn't that you're not collecting it — Home Assistant's SQLite recorder captures almost everything by default. The problem is that SQLite starts choking on range queries once your history table grows past a few weeks of dense sensor data. Run &lt;code&gt;EXPLAIN QUERY PLAN SELECT * FROM states WHERE entity_id = 'sensor.living_room_temp' AND last_updated &amp;gt; datetime('now', '-30 days')&lt;/code&gt; against a mature HA database and you'll see full table scans. The default purge interval is 10 days precisely because the recorder isn't designed for long-term analytical queries — it's designed for state restoration and the last-24-hours view.&lt;/p&gt;

&lt;p&gt;The fragmentation problem compounds this. A typical home automation setup ends up with Home Assistant for sensor state, a router admin panel (UniFi, OpenWrt, or whatever shipped with your ISP box) for network device presence and bandwidth, a UPS management interface for power draw, and maybe a separate Uptime Kuma instance for service health. None of these talk to each other. Correlating "my NAS went offline at 2am" with "there was a power fluctuation at 1:58am" requires you to mentally stitch together timestamps from four browser tabs. That's not analysis — that's archaeology.&lt;/p&gt;

&lt;p&gt;The gap this stack actually closes is the distance between raw logged values and &lt;em&gt;actionable pattern recognition&lt;/em&gt;. Having temperature readings in a log file tells you nothing until you can overlay them against HVAC runtime, time-of-day, and outdoor weather. Having power draw numbers is useless until you can set an alert threshold that fires when the whole-home draw spikes above baseline for more than five minutes. Grafana's alerting engine and InfluxDB's Flux query language are specifically built for exactly these cross-signal correlations — and they run comfortably on modest hardware.&lt;/p&gt;

&lt;p&gt;Here's what this article builds, concretely: a Raspberry Pi 4 with 4GB RAM minimum (8GB gives you headroom to add more exporters later), running three containers via Docker Compose — &lt;strong&gt;InfluxDB 2.x&lt;/strong&gt; as the time-series store, &lt;strong&gt;Grafana OSS&lt;/strong&gt; (currently 10.x) as the visualization and alerting layer, and a lightweight &lt;strong&gt;Telegraf&lt;/strong&gt; collector that pulls from Home Assistant's REST API, local system metrics, and SNMP or ping checks for network devices. The Pi handles this workload without breaking a sweat; InfluxDB's TSM storage engine is far more efficient than SQLite for time-series shapes, and Grafana's memory footprint at idle is well under 200MB. The whole stack fits on a 32GB SD card with room to spare, though a USB SSD is strongly recommended for write endurance on any InfluxDB deployment that's ingesting data continuously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardware and OS Prerequisites Before You Touch a Config File
&lt;/h2&gt;

&lt;p&gt;The SD card failure mode is the first thing to get right, and most tutorials skip it. InfluxDB 2.x is write-heavy by design — every scrape interval hammers the storage layer with small random writes, which is exactly what wears out NAND flash on cheap microSD cards. A Class 10 card will die quietly, usually corrupting your database before it throws any obvious errors. Before you commit to any storage layout, boot the Pi, run your planned scrape workload for 20 minutes, and check what's actually hitting the disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;iotop &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iotop &lt;span class="nt"&gt;-ao&lt;/span&gt; &lt;span class="nt"&gt;--only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-a&lt;/code&gt; flag shows accumulated I/O rather than instantaneous rates, and &lt;code&gt;--only&lt;/code&gt; filters to processes actively doing I/O. If you see InfluxDB or the kernel's journal flushing multiple megabytes per minute, a microSD is going to lose that fight inside a few months of continuous operation. A USB 3.0-attached SSD — even a cheap SATA SSD in a USB enclosure — changes the write endurance math entirely. On a Pi 4, USB 3.0 is shared bandwidth with the NIC, so don't expect full SATA speeds, but you'll still get dramatically better random write performance than any SD card. On a Pi 5, the situation is better: you can attach an NVMe drive via the PCIe FFC connector with an appropriate HAT.&lt;/p&gt;

&lt;p&gt;Raspberry Pi OS Lite (64-bit, Bookworm) is the correct base image — not the desktop variant, not the 32-bit build. InfluxDB 2.x only ships ARM64 binaries; the 32-bit ARMv7 Debian packages don't exist in their official repo. Bookworm (Debian 12) also gives you a kernel new enough to not fight with Docker's network namespace handling. Flash it with Raspberry Pi Imager, use the advanced options to pre-configure your SSH key and hostname before first boot, and never attach a monitor. If you're booting headless from the start, you eliminate an entire category of "works on my desk, breaks in the closet" problems.&lt;/p&gt;

&lt;p&gt;Install Docker Engine using the official convenience script, not whatever version is in the Raspberry Pi OS package repos — that copy is usually multiple major versions behind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# One-liner installs the current stable Docker Engine for your architecture&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com | sh

&lt;span class="c"&gt;# Add your user to the docker group so you're not sudo-ing every command&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;

&lt;span class="c"&gt;# Log out and back in, then verify&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't install Docker Desktop — it adds a VM layer that makes zero sense on a Pi and its ARM builds have historically lagged. The Engine-only install is what you want for headless server use.&lt;/p&gt;

&lt;p&gt;Assign a static IP through your router's DHCP reservation table using the Pi's MAC address, not by editing &lt;code&gt;/etc/dhcpcd.conf&lt;/code&gt; on the Pi itself. The reason is operational: when you need to re-image the Pi — and you will need to, eventually — a static lease on the router survives the wipe. Your Grafana bookmarks, your MQTT broker address, your n8n webhook URLs all stay valid without touching any config. Hardcoding the IP in &lt;code&gt;dhcpcd.conf&lt;/code&gt; means you have to remember to replicate that config every time you re-flash. The router-side reservation costs you nothing and makes the Pi functionally disposable, which is exactly the right operational posture for a device running 24/7 in a closet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker Compose Stack: InfluxDB 2.x, Grafana, and Telegraf
&lt;/h2&gt;

&lt;p&gt;The biggest mistake people make with this stack is using &lt;code&gt;latest&lt;/code&gt; tags everywhere and wondering why things break after a &lt;code&gt;docker compose pull&lt;/code&gt; three months later. Pin InfluxDB specifically — the 2.x config model, Flux query language behavior, and bucket auth scheme changed enough between minor versions that an unpinned update will silently break your Telegraf writes. Use &lt;code&gt;influxdb:2.7&lt;/code&gt; and &lt;code&gt;telegraf:1.30&lt;/code&gt;. Grafana OSS moves faster and is generally safer to track at a recent pinned tag, but even there, pin it once your dashboards are stable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.8"&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;telemetry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;influxdb-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;grafana-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;influxdb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;influxdb:2.7&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;influxdb&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;telemetry&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8086:8086"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;influxdb-data:/var/lib/influxdb2&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DOCKER_INFLUXDB_INIT_MODE=setup&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DOCKER_INFLUXDB_INIT_USERNAME=${INFLUXDB_ADMIN_USER}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DOCKER_INFLUXDB_INIT_PASSWORD=${INFLUXDB_ADMIN_PASSWORD}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DOCKER_INFLUXDB_INIT_ORG=${INFLUXDB_ORG}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DOCKER_INFLUXDB_INIT_BUCKET=${INFLUXDB_BUCKET}&lt;/span&gt;
      &lt;span class="c1"&gt;# Token is loaded from .env — never hardcode here&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=${INFLUXDB_ADMIN_TOKEN}&lt;/span&gt;

  &lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/grafana-oss:11.1.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;telemetry&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;grafana-data:/var/lib/grafana&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;influxdb&lt;/span&gt;

  &lt;span class="na"&gt;telegraf&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;telegraf:1.30&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;telegraf&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;telemetry&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# Read-only bind mount — telegraf has no reason to write back to this file&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./telegraf.conf:/etc/telegraf/telegraf.conf:ro&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;influxdb&lt;/span&gt;
    &lt;span class="c1"&gt;# Pi-specific: expose host thermal zone to the container&lt;/span&gt;
    &lt;span class="na"&gt;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/gpiomem:/dev/gpiomem&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env  (chmod 600 this file, add to .gitignore)
&lt;/span&gt;&lt;span class="py"&gt;INFLUXDB_ADMIN_USER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;
&lt;span class="py"&gt;INFLUXDB_ADMIN_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;changeme_strong_password&lt;/span&gt;
&lt;span class="py"&gt;INFLUXDB_ORG&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;homelab&lt;/span&gt;
&lt;span class="py"&gt;INFLUXDB_BUCKET&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;telemetry&lt;/span&gt;
&lt;span class="c"&gt;# Generate with: openssl rand -hex 32
&lt;/span&gt;&lt;span class="py"&gt;INFLUXDB_ADMIN_TOKEN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your_generated_hex_token_here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DOCKER_INFLUXDB_INIT_MODE=setup&lt;/code&gt; only runs the bootstrap sequence when the data volume is empty. Once &lt;code&gt;influxdb-data&lt;/code&gt; has content, InfluxDB ignores those env vars entirely — so leaving them in the compose file after first boot is harmless. What's not harmless: hardcoding the token in the compose file itself, which ends up in shell history, CI logs, and git repos. Keep it in &lt;code&gt;.env&lt;/code&gt;, lock the permissions, and add &lt;code&gt;.env&lt;/code&gt; to your &lt;code&gt;.gitignore&lt;/code&gt; before the first &lt;code&gt;git add&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# telegraf.conf — minimal but useful for a Pi telemetry node&lt;/span&gt;

&lt;span class="nn"&gt;[agent]&lt;/span&gt;
  &lt;span class="py"&gt;interval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"10s"&lt;/span&gt;
  &lt;span class="py"&gt;round_interval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="py"&gt;flush_interval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"10s"&lt;/span&gt;
  &lt;span class="py"&gt;hostname&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"raspberrypi"&lt;/span&gt;

&lt;span class="nn"&gt;[[outputs.influxdb_v2]]&lt;/span&gt;
  &lt;span class="c"&gt;# Use the service name on the shared bridge — not localhost&lt;/span&gt;
  &lt;span class="py"&gt;urls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"http://influxdb:8086"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${INFLUXDB_ADMIN_TOKEN}"&lt;/span&gt;
  &lt;span class="py"&gt;org&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${INFLUXDB_ORG}"&lt;/span&gt;
  &lt;span class="py"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${INFLUXDB_BUCKET}"&lt;/span&gt;

&lt;span class="nn"&gt;[[inputs.cpu]]&lt;/span&gt;
  &lt;span class="py"&gt;percpu&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="py"&gt;totalcpu&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="py"&gt;collect_cpu_time&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="nn"&gt;[[inputs.mem]]&lt;/span&gt;

&lt;span class="nn"&gt;[[inputs.disk]]&lt;/span&gt;
  &lt;span class="py"&gt;ignore_fs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"tmpfs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"devtmpfs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"overlay"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nn"&gt;[[inputs.net]]&lt;/span&gt;
  &lt;span class="py"&gt;interfaces&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"eth0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"wlan0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nn"&gt;[[inputs.temp]]&lt;/span&gt;
  &lt;span class="c"&gt;# On Pi 4/5 this reads from /sys/class/thermal/thermal_zone0/temp&lt;/span&gt;
  &lt;span class="c"&gt;# Reports in Celsius as 'temp' measurement with tag sensor=cpu-thermal&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to get right in the Telegraf config that the docs don't emphasize: first, use the Docker service name &lt;code&gt;influxdb&lt;/code&gt; as the hostname in &lt;code&gt;urls&lt;/code&gt;, not &lt;code&gt;localhost&lt;/code&gt; — these containers are on the same bridge network, and &lt;code&gt;localhost&lt;/code&gt; inside the Telegraf container points to the Telegraf container itself. Second, pass the token and org as environment variables referencing the same &lt;code&gt;.env&lt;/code&gt; values rather than duplicating the literal string. Telegraf 1.30 expands &lt;code&gt;${VAR}&lt;/code&gt; syntax in the config file directly, so you get one source of truth. The &lt;code&gt;inputs.temp&lt;/code&gt; plugin on a Pi 4 or Pi 5 reads from &lt;code&gt;/sys/class/thermal/&lt;/code&gt; without any extra kernel modules — you'll see a measurement named &lt;code&gt;temp&lt;/code&gt; with a &lt;code&gt;sensor&lt;/code&gt; tag value of &lt;code&gt;cpu-thermal&lt;/code&gt;, which maps cleanly to a Grafana stat panel.&lt;/p&gt;

&lt;p&gt;Named Docker volumes for InfluxDB and Grafana data are non-negotiable if you ever run &lt;code&gt;docker compose down&lt;/code&gt; — which you will, during updates. Bind mounts work too, but named volumes survive accidental directory moves and are easier to back up with &lt;code&gt;docker run --rm -v influxdb-data:/data busybox tar czf - /data&lt;/code&gt;. The only bind mount in this stack is &lt;code&gt;telegraf.conf&lt;/code&gt; mounted read-only, because you want to edit it from the host without exec-ing into the container, and Telegraf has no legitimate reason to modify its own config file at runtime. If it can't write there, a misconfiguration or a bad plugin can't corrupt it either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pulling Home Automation Data Into the Pipeline
&lt;/h2&gt;

&lt;p&gt;The least obvious thing about the Home Assistant → InfluxDB path is that it's &lt;em&gt;event-driven, not time-series sampled&lt;/em&gt;. That distinction will bite you the first time you build a Grafana graph and see a door sensor drop off the chart for six hours because nothing happened. Keep that in mind for everything that follows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Home Assistant InfluxDB v2 Integration
&lt;/h3&gt;

&lt;p&gt;The official integration supports InfluxDB v2's API but you have to opt into it explicitly — the default assumes v1. Your &lt;code&gt;configuration.yaml&lt;/code&gt; block needs to look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;influxdb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;192.168.1.x&lt;/span&gt;        &lt;span class="c1"&gt;# your Pi's LAN IP, not localhost if HA runs elsewhere&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8086&lt;/span&gt;
  &lt;span class="na"&gt;ssl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;                &lt;span class="c1"&gt;# skip TLS unless you've got a cert chain sorted&lt;/span&gt;
  &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-influxdb-token-here&lt;/span&gt;
  &lt;span class="na"&gt;organization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homelab&lt;/span&gt;
  &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;home_automation&lt;/span&gt;
  &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;entity_globs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sensor.living_room_*&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sensor.outdoor_*&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;binary_sensor.front_door&lt;/span&gt;
  &lt;span class="na"&gt;exclude&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;entity_globs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sensor.*_last_updated&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sensor.*_friendly_name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;include&lt;/code&gt;/&lt;code&gt;exclude&lt;/code&gt; filtering is not optional if you care about bucket hygiene. A default Home Assistant install with a handful of integrations will push thousands of state changes per day — most of them are UI state, internal metadata, or automation flags that have no business in your metrics store. Be specific with &lt;code&gt;entity_globs&lt;/code&gt; from day one; retrofitting this after six weeks of accumulated noise is painful. After restarting HA, tail the log and watch for InfluxDB write errors — the most common is a mismatched organization name (case-sensitive).&lt;/p&gt;

&lt;h3&gt;
  
  
  MQTT Bridge via Telegraf
&lt;/h3&gt;

&lt;p&gt;Zigbee2MQTT, Tasmota, and ESPHome all publish to MQTT natively. Wiring Telegraf directly to Mosquitto means you're capturing that data independently of whether Home Assistant is running — useful during HA restarts or migrations. In your &lt;code&gt;telegraf.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[inputs.mqtt_consumer]]&lt;/span&gt;
  &lt;span class="py"&gt;servers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"tcp://192.168.1.x:1883"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="py"&gt;topics&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"home/#"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"zigbee2mqtt/#"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"tele/+/SENSOR"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="py"&gt;data_format&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"json"&lt;/span&gt;
  &lt;span class="py"&gt;json_time_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;         &lt;span class="c"&gt;# let Telegraf timestamp on ingest&lt;/span&gt;
  &lt;span class="py"&gt;qos&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="py"&gt;connection_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"30s"&lt;/span&gt;
  &lt;span class="py"&gt;persistent_session&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="py"&gt;client_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"telegraf-home"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;data_format = "json"&lt;/code&gt; setting does most of the heavy lifting — Telegraf will flatten nested JSON keys into field names automatically. A Zigbee2MQTT payload like &lt;code&gt;{"temperature": 21.4, "humidity": 58, "linkquality": 103}&lt;/code&gt; lands in InfluxDB as three separate fields on one measurement. Tasmota's &lt;code&gt;tele/+/SENSOR&lt;/code&gt; payloads are slightly more nested, so you may need &lt;code&gt;json_string_fields&lt;/code&gt; or a processor to unwrap them cleanly. The wildcard &lt;code&gt;home/#&lt;/code&gt; topic is aggressive — add a tag filter or &lt;code&gt;namepass&lt;/code&gt; rule if you're picking up MQTT traffic from non-sensor sources like presence detection or alarm states.&lt;/p&gt;

&lt;h3&gt;
  
  
  HTTP Scraping for Power Monitors
&lt;/h3&gt;

&lt;p&gt;Shelly devices expose a JSON REST endpoint at &lt;code&gt;http://&amp;lt;device-ip&amp;gt;/status&lt;/code&gt; (gen1) or &lt;code&gt;http://&amp;lt;device-ip&amp;gt;/rpc/Switch.GetStatus?id=0&lt;/code&gt; (gen2/Plus). TP-Link Kasa requires a slightly different approach since it uses an encrypted UDP protocol by default, but the &lt;a href="https://github.com/python-kasa/python-kasa" rel="noopener noreferrer"&gt;python-kasa&lt;/a&gt; library can expose a local HTTP shim you can then scrape. Telegraf's &lt;code&gt;inputs.http&lt;/code&gt; handles both cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[inputs.http]]&lt;/span&gt;
  &lt;span class="py"&gt;urls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;"http://192.168.1.50/status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c"&gt;# Shelly 1PM&lt;/span&gt;
    &lt;span class="s"&gt;"http://192.168.1.51/status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c"&gt;# Shelly EM&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="py"&gt;method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"GET"&lt;/span&gt;
  &lt;span class="py"&gt;interval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"10s"&lt;/span&gt;
  &lt;span class="py"&gt;data_format&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"json"&lt;/span&gt;
  &lt;span class="py"&gt;name_override&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"shelly_power"&lt;/span&gt;

  &lt;span class="c"&gt;# Pull only what matters&lt;/span&gt;
  &lt;span class="py"&gt;json_query&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"meters"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 10-second interval is a reasonable floor for energy data on a Pi 4 — you get enough resolution to catch appliance startup spikes without the CPU overhead of sub-second polling across a dozen devices. Shelly gen1 devices have no rate limiting on the local HTTP endpoint so you can go tighter if needed, but energy billing math doesn't benefit from more than 10s granularity. One gotcha: if your Shelly is on a different VLAN from the Pi, IGMP snooping or inter-VLAN routing issues will silently drop the scrape without a useful error in Telegraf — check with &lt;code&gt;curl&lt;/code&gt; from the Pi first before debugging the plugin config.&lt;/p&gt;

&lt;h3&gt;
  
  
  Handling the Gap Problem in Grafana
&lt;/h3&gt;

&lt;p&gt;Because the HA InfluxDB integration only writes on state change, binary sensors — door contacts, motion detectors, window sensors — will appear as single data points with long gaps between them. Grafana interprets these gaps as missing data and draws nothing. The fix is per-panel: under &lt;strong&gt;Field overrides&lt;/strong&gt;, set &lt;strong&gt;Graph styles → Fill below to → previous&lt;/strong&gt;, or in the older panel editor look for &lt;strong&gt;Null values: connected&lt;/strong&gt; and set it to &lt;strong&gt;Fill: previous&lt;/strong&gt;. This tells Grafana to extend the last known value forward until the next data point, which is the semantically correct behavior for a door that's been closed since 9am. Do not apply this setting to numeric sensors like temperature — you want the gap to be visible there, because a missing temperature reading means your sensor is offline, not that the temperature is unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Grafana Dashboard: Panels That Actually Tell You Something
&lt;/h2&gt;

&lt;p&gt;Most Grafana dashboards I've seen in home-lab write-ups are tourist dashboards — pretty graphs that you look at once and never open again. The ones worth keeping are built around questions you actually ask: &lt;em&gt;is the house using too much power right now?&lt;/em&gt;, &lt;em&gt;did the bedroom get hot while I was away?&lt;/em&gt;, &lt;em&gt;which sensor went silent three hours ago?&lt;/em&gt; Panel layout should answer those in order, top to bottom, without hunting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Flux Over InfluxQL — Not a Preference, a Capability Gap
&lt;/h3&gt;

&lt;p&gt;Connect Grafana to InfluxDB using the &lt;strong&gt;InfluxDB (Flux)&lt;/strong&gt; datasource, not the legacy InfluxQL one. The config difference is a single radio button in the datasource settings, but the query capability gap is significant. Flux's &lt;code&gt;aggregateWindow()&lt;/code&gt; handles time bucketing and gap-filling natively. &lt;code&gt;movingAverage()&lt;/code&gt; smooths noisy sensor data without a subquery. A 5-minute average CPU temperature query on my setup looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"homelab"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_measurement&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nv"&gt;"temp"&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;host&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nv"&gt;"rpi-main"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;aggregateWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;every&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;createEmpty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"mean_temp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;createEmpty: false&lt;/code&gt; is the flag the docs bury — without it, gaps in sensor data produce null-filled windows that Grafana renders as dropped lines, which looks like sensor failures when it isn't. The &lt;code&gt;yield()&lt;/code&gt; at the end is required when you're running multiple queries in the same panel; Grafana uses the yield name as the series label. For power draw with a moving average overlay, chain &lt;code&gt;|&amp;gt; movingAverage(n: 3)&lt;/code&gt; after the aggregation window — that smooths over momentary spikes without hiding real sustained load.&lt;/p&gt;

&lt;h3&gt;
  
  
  Panel Layout Tied to Operator Workflow
&lt;/h3&gt;

&lt;p&gt;The layout I run follows a deliberate information hierarchy. Top row: two single-stat panels — current power draw in watts and current indoor temperature. These use the &lt;strong&gt;Stat&lt;/strong&gt; panel type with thresholds configured (green below 200W, yellow 200–350W, red above). They give you a pass/fail read at a glance without opening a graph. Middle section: two time-series panels spanning 24 hours — one for power, one for temperature across zones. Time range is fixed to &lt;code&gt;now-24h&lt;/code&gt; rather than the dashboard-global variable so it stays useful regardless of what someone drags the global range to. Bottom row: a &lt;strong&gt;Table&lt;/strong&gt; panel that queries last-seen timestamps for every MQTT device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"homelab"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_measurement&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nv"&gt;"mqtt_last_seen"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;last&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;"device_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"_time"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"_value"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;"_time"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sorting ascending by &lt;code&gt;_time&lt;/code&gt; puts the longest-silent device at the top — exactly the one you want to see first. Apply a cell color threshold on the &lt;code&gt;_time&lt;/code&gt; column: anything older than 30 minutes turns red. That table has caught two dead sensor batteries on my setup before I'd have noticed any alert.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alerting Without Giving Grafana Cloud Your Email Address
&lt;/h3&gt;

&lt;p&gt;Grafana OSS 9+ has a fully functional alerting engine without any cloud dependency. Set up a contact point under &lt;strong&gt;Alerting → Contact points&lt;/strong&gt;. For local email, configure SMTP in &lt;code&gt;grafana.ini&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[smtp]&lt;/span&gt;
&lt;span class="py"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;your-smtp-relay:587&lt;/span&gt;
&lt;span class="py"&gt;user&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;alerts@yourdomain.local&lt;/span&gt;
&lt;span class="py"&gt;password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yourpassword&lt;/span&gt;
&lt;span class="py"&gt;from_address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;grafana@yourdomain.local&lt;/span&gt;
&lt;span class="py"&gt;skip_verify&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For anything more than email — routing to a specific n8n flow, posting to a local Matrix room, triggering a Home Assistant webhook — use the &lt;strong&gt;Webhook&lt;/strong&gt; contact point type with your n8n webhook URL. The payload Grafana sends is JSON with the alert state, labels, and values. In n8n, a single &lt;strong&gt;Webhook&lt;/strong&gt; trigger node receives it, and you can branch from there. The alert rule that matters most: on the power draw panel, define an alert that fires when consumption exceeds your threshold for &lt;strong&gt;5 consecutive minutes&lt;/strong&gt;. That "for" duration is set in the alert rule's &lt;em&gt;Pending period&lt;/em&gt; field — set it to &lt;code&gt;5m&lt;/code&gt;. Without a pending period, a kettle or microwave kicks off a false alert every morning.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dashboard JSON in Version Control, Not Just in the Database
&lt;/h3&gt;

&lt;p&gt;Grafana stores dashboards in its internal SQLite or Postgres database, which means a container rebuild or volume wipe takes them with it. The fix is provisioning: export the dashboard JSON from &lt;strong&gt;Dashboard settings → JSON model&lt;/strong&gt;, commit it to a git repo, then mount it into the container and tell Grafana where to look. In your Docker Compose file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/grafana-oss:10.4.2&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./grafana/provisioning:/etc/grafana/provisioning&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./grafana/dashboards:/var/lib/grafana/dashboards&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GF_PATHS_PROVISIONING=/etc/grafana/provisioning&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provisioning directory needs a dashboard provider config at &lt;code&gt;./grafana/provisioning/dashboards/homelab.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homelab&lt;/span&gt;
    &lt;span class="na"&gt;folder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Home Automation&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file&lt;/span&gt;
    &lt;span class="na"&gt;disableDeletion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;updateIntervalSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/grafana/dashboards&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;disableDeletion: true&lt;/code&gt; prevents someone from accidentally deleting the provisioned dashboard from the UI — it'll just reappear on the next sync interval. The &lt;code&gt;updateIntervalSeconds: 30&lt;/code&gt; means a git pull followed by copying the updated JSON into the dashboards directory takes effect in under a minute without a container restart. That's the loop: edit in Grafana UI, export JSON, commit, done. Container rebuilds are now safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention, Storage, and Keeping the Pi From Dying Under Load
&lt;/h2&gt;

&lt;p&gt;The most common Pi telemetry setup failure I see documented online skips the storage math entirely and then wonders why the SD card is dead or the disk is full in three weeks. Before you commit to any hardware, run your intended Telegraf config for 24 hours and pull actual write rates from InfluxDB's built-in &lt;code&gt;/metrics&lt;/code&gt; endpoint — it exposes Prometheus-format output at &lt;code&gt;http://localhost:8086/metrics&lt;/code&gt; and includes &lt;code&gt;influxdb_write_points_ok_total&lt;/code&gt; so you can calculate your actual ingestion rate rather than guessing. Pair that with &lt;code&gt;docker stats&lt;/code&gt; watching the InfluxDB container and you'll know whether a 64GB SSD is sufficient before you're committed. For a typical home setup — a handful of temperature/humidity sensors, one or two power monitors, and basic system metrics — 30 days of retention on a 64GB SSD is about the practical ceiling before you start chasing disk space.&lt;/p&gt;

&lt;p&gt;Set your bucket retention policy at init time, not after the fact. If you're running InfluxDB 2.x in Docker, pass it during the first boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; influxdb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 8086:8086 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; influxdb-data:/var/lib/influxdb2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;DOCKER_INFLUXDB_INIT_MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;setup &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;DOCKER_INFLUXDB_INIT_USERNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;admin &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;DOCKER_INFLUXDB_INIT_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;yourpassword &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;DOCKER_INFLUXDB_INIT_ORG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;homelab &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;DOCKER_INFLUXDB_INIT_BUCKET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;telemetry &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;DOCKER_INFLUXDB_INIT_RETENTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;720h &lt;span class="se"&gt;\ &lt;/span&gt; &lt;span class="c"&gt;# 30 days&lt;/span&gt;
  influxdb:2.7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing retention after the fact via the UI works fine, but the old data doesn't retroactively shrink — InfluxDB runs shard compaction on its own schedule. If you overshoot your retention and need to reclaim space immediately, you'll need to drop and recreate the bucket, which means losing history. Setting it correctly upfront is the only safe path.&lt;/p&gt;

&lt;p&gt;Telegraf input intervals are where cardinality problems quietly compound. The safe defaults: system-level metrics (CPU, memory, disk I/O) at &lt;code&gt;interval = "10s"&lt;/code&gt;, HTTP scrapes of external APIs or device endpoints at &lt;code&gt;interval = "30s"&lt;/code&gt;, and MQTT inputs left event-driven — meaning no polling interval, just &lt;code&gt;[[inputs.mqtt_consumer]]&lt;/code&gt; subscribing and writing on receipt. The mistake is setting a global interval and letting MQTT inherit it. If a sensor fires 20 events per minute and you're also polling it on a 10-second schedule, you're doubling your write volume and creating duplicate tags that inflate cardinality. InfluxDB's cardinality limit isn't documented as a hard wall in the OSS tier, but series cardinality above roughly 10–15 million on a constrained device will cause measurable query slowdown.&lt;/p&gt;

&lt;p&gt;On a Pi running from SD card, InfluxDB's WAL (write-ahead log) will kill your card faster than anything else in this stack. The WAL flushes frequently by design — it's what makes InfluxDB durable. After the first 48 hours of operation, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dmesg | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any &lt;code&gt;mmc0&lt;/code&gt; I/O errors or &lt;code&gt;EXT4-fs error&lt;/code&gt; lines at this stage mean your SD card is already degrading under the write load. On an SSD via USB 3.0 or the Pi 5's PCIe M.2 slot this doesn't happen — SSDs handle random write patterns from WAL workloads without issue. If you're committed to SD card for some reason, at minimum move the InfluxDB data directory to a USB drive and keep the OS on the card. Don't run the WAL on SD and expect it to last a year.&lt;/p&gt;

&lt;p&gt;For backups, a nightly cron job to an NFS share or external USB drive is straightforward and the restore path is actually reliable — unlike some tools that produce backup archives you can't test without a full cluster. The InfluxDB CLI backup format is portable across 2.x minor versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/cron.d/influx-backup&lt;/span&gt;
0 2 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; root influx backup /mnt/backup/influx/&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +&lt;span class="se"&gt;\%&lt;/span&gt;Y-&lt;span class="se"&gt;\%&lt;/span&gt;m-&lt;span class="se"&gt;\%&lt;/span&gt;d&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--host&lt;/span&gt; http://localhost:8086 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--token&lt;/span&gt; &lt;span class="nv"&gt;$INFLUX_TOKEN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store &lt;code&gt;INFLUX_TOKEN&lt;/code&gt; in a root-only readable file and source it in the cron environment, or pass it via a wrapper script — cron's environment doesn't inherit your shell exports. To verify the backup is actually restorable, spin up a temporary InfluxDB container, run &lt;code&gt;influx restore&lt;/code&gt; against it, and confirm your bucket and series show up. Do this once. Backups you've never tested restored aren't backups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring Grafana Alerts Into Your Automation Pipeline
&lt;/h2&gt;

&lt;p&gt;The most underrated part of a Raspberry Pi telemetry setup isn't the data collection — it's closing the feedback loop so the dashboard actually &lt;em&gt;does&lt;/em&gt; something. Grafana's webhook contact point paired with an n8n trigger is how you get from "I can see the power draw spiking" to "the switch turned off automatically and I got a push notification." The wiring is straightforward once you know the exact shape of the payload.&lt;/p&gt;

&lt;p&gt;In Grafana, go to &lt;strong&gt;Alerting → Contact points → New contact point&lt;/strong&gt;, select &lt;strong&gt;Webhook&lt;/strong&gt;, and paste in your n8n webhook URL. On my setup that looks like &lt;code&gt;http://192.168.1.x:5678/webhook/grafana-alerts&lt;/code&gt; — internal IP, no auth header required since it's LAN-only, though you can add a Basic Auth header if your n8n instance is exposed. The payload Grafana sends looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"receiver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n8n-webhook"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"firing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"alerts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"firing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"labels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"alertname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HighPowerDraw"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"device"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"washing_machine_plug"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"values"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"watts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2340&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Alerting"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside n8n, the &lt;strong&gt;Webhook&lt;/strong&gt; trigger node receives that body and you route on &lt;code&gt;{{ $json.alerts[0].status }}&lt;/code&gt; and &lt;code&gt;{{ $json.alerts[0].labels.device }}&lt;/code&gt;. Drop an &lt;strong&gt;IF&lt;/strong&gt; node after it: if status is &lt;code&gt;firing&lt;/code&gt; and device matches the plug you care about, continue down the action branch. The &lt;strong&gt;HTTP Request&lt;/strong&gt; node then calls Home Assistant's REST API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST http://homeassistant.local:8123/api/services/switch/turn_off
Headers:
  Authorization: Bearer YOUR_HA_LONG_LIVED_TOKEN
  Content-Type: application/json

Body:
{
  "entity_id": "switch.washing_machine_plug"
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chain a &lt;strong&gt;Pushover&lt;/strong&gt; node after that (n8n has a native Pushover node — credentials take 30 seconds to configure) and template the message with &lt;code&gt;{{ $json.alerts[0].values.watts }}W detected on {{ $json.alerts[0].labels.device }} — switch turned off&lt;/code&gt;. The whole flow from Grafana firing to your phone buzzing runs in under a minute of wall time once it's built, and maybe 10 minutes to actually build it. For a broader look at how webhook-driven pipelines like this fit into a self-hosted automation stack, see our guide on &lt;a href="https://techdigestor.com/ultimate-productivity-guide-2026/" rel="noopener noreferrer"&gt;Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The gotcha that wastes real time: on Grafana versions below 10.x, the legacy alerting engine either doesn't send a &lt;code&gt;resolved&lt;/code&gt; webhook at all, or sends it in a different schema than the firing payload. That means your n8n flow never gets the "all clear" signal, and if your logic depends on toggling state back — like re-enabling a switch once the draw drops — it silently breaks. Upgrade to Grafana 10.x or later; the unified alerting engine sends both &lt;code&gt;firing&lt;/code&gt; and &lt;code&gt;resolved&lt;/code&gt; states consistently, and the &lt;code&gt;alerts[0].status&lt;/code&gt; field is reliable in both directions. If you're pinned to an older version for some reason, the workaround is polling Home Assistant's sensor state from n8n on a schedule and checking it independently — ugly, but functional.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/raspberry-pi-telemetry-dashboard-for-home-automation-setup-to-production/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>5 Lightweight Tailscale Alternatives Worth Running on Your Own Hardware</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Fri, 17 Jul 2026 08:10:19 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/5-lightweight-tailscale-alternatives-worth-running-on-your-own-hardware-2h1a</link>
      <guid>https://dev.to/ericwoooo_kr/5-lightweight-tailscale-alternatives-worth-running-on-your-own-hardware-2h1a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Tailscale's WireGuard mesh is elegant, and the client software works well.  The problem isn't the protocol — it's that the glue holding your mesh together lives on Tailscale's servers, behind closed-source code you can't audit or replicate.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~17 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Why Tailscale Becomes the Wrong Tool&lt;/li&gt;
&lt;li&gt;Comparison Table: The Five Alternatives at a Glance&lt;/li&gt;
&lt;li&gt;Headscale: Tailscale Clients, Your Control Server&lt;/li&gt;
&lt;li&gt;Netbird: WireGuard Mesh With a Built-in Admin UI&lt;/li&gt;
&lt;li&gt;Nebula: Flat Mesh Without Any Central Relay&lt;/li&gt;
&lt;li&gt;Picking the Right Tool for Your Setup&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why Tailscale Becomes the Wrong Tool
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Coordination Server Is the Hidden Dependency
&lt;/h3&gt;

&lt;p&gt;Tailscale's WireGuard mesh is elegant, and the client software works well. The problem isn't the protocol — it's that the glue holding your mesh together lives on Tailscale's servers, behind closed-source code you can't audit or replicate. Every device auth, every key rotation, every peer discovery event goes through &lt;code&gt;login.tailscale.com&lt;/code&gt;. If that goes down, your mesh doesn't degrade gracefully — peers that haven't cached routes stop resolving each other entirely. For a homelab this is inconvenient. For anything you've wired production services through, it's a real outage you have zero ability to fix.&lt;/p&gt;

&lt;p&gt;The DERP relay situation compounds this. WireGuard needs direct UDP paths between peers; when NAT traversal fails (CGNAT, strict firewalls, mobile carriers), Tailscale falls back to its DERP relay servers. You can run your own DERP node, but your traffic still gets routed through Tailscale's coordination layer to discover which relay to use. You're not actually air-gapped from their infrastructure — you've just moved one hop. Self-hosters running nodes behind carrier-grade NAT will hit DERP relay traffic constantly, and those relays are outside your control, your jurisdiction, and your incident-response loop.&lt;/p&gt;

&lt;p&gt;The free tier's device limit is the friction point that usually forces the decision. The cap isn't enormous, and once you start counting — workstations, servers, VPS nodes, phones, a Raspberry Pi or two, a NAS — you hit it faster than expected. Bumping to a paid plan to host your own infrastructure feels backwards. The pricing isn't outrageous, but you're now paying a recurring fee for a control plane that still isn't yours, with no self-hosted alternative available at any price tier through Tailscale itself.&lt;/p&gt;

&lt;p&gt;The air-gap question is where Tailscale fully stops being an option. Regulated environments, isolated home lab segments you want genuinely off the internet, or just paranoia about external dependencies — none of these are solvable with stock Tailscale. The coordination server must be reachable. There's no offline mode, no bundle-your-own-server path, no "here's the protocol spec, run it yourself." Headscale exists as a third-party reimplementation, which is a reasonable answer, but it's worth being clear that it's a reverse-engineered compatibility layer, not something Tailscale ships or supports.&lt;/p&gt;

&lt;p&gt;The actual operator question is specific: which tool lets you own the full control plane — key exchange, peer discovery, relay infrastructure — without turning network administration into a part-time job? Running raw WireGuard with manual config is the maximalist ownership answer, but you're writing &lt;code&gt;wg0.conf&lt;/code&gt; entries by hand and scripting your own key rotation. The sweet spot is a tool that gives you a self-hosted coordination server with a reasonable operational surface area: one or two containers, a config file you can read, and upgrade paths that don't require re-architecting your mesh. That's the gap the alternatives in this piece are trying to fill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison Table: The Five Alternatives at a Glance
&lt;/h2&gt;

&lt;p&gt;The uncomfortable truth about all five of these tools: you're not escaping operational complexity, you're just moving it somewhere you control. Tailscale's SaaS model absorbs that complexity into their infrastructure. Every option below hands it back to you in the form of a control plane you have to run, upgrade, and back up. That trade is worth making — but go in clear-eyed about what you're taking on.&lt;/p&gt;

&lt;p&gt;Tool&lt;/p&gt;

&lt;p&gt;Control Plane&lt;/p&gt;

&lt;p&gt;WireGuard-based&lt;/p&gt;

&lt;p&gt;NAT Traversal&lt;/p&gt;

&lt;p&gt;Min Server RAM&lt;/p&gt;

&lt;p&gt;Biggest Gotcha&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Headscale&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Self-hosted&lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;DERP relay (Tailscale's protocol, self-hostable)&lt;/p&gt;

&lt;p&gt;~50 MB&lt;/p&gt;

&lt;p&gt;Lags behind official Tailscale client feature releases; some client features silently break&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Netbird&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Self-hosted or vendor&lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;STUN/TURN + ICE (standard WebRTC stack)&lt;/p&gt;

&lt;p&gt;~150 MB (full stack: signal + relay + dashboard)&lt;/p&gt;

&lt;p&gt;Self-hosted stack is four separate services; docker-compose drift between versions is real&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nebula&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Self-hosted (CA + lighthouses)&lt;/p&gt;

&lt;p&gt;No (custom UDP overlay)&lt;/p&gt;

&lt;p&gt;Lighthouse-assisted hole-punching&lt;/p&gt;

&lt;p&gt;~20 MB&lt;/p&gt;

&lt;p&gt;Certificate rotation is fully manual; no built-in revocation workflow&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ZeroTier (self-hosted controller)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Self-hosted&lt;/p&gt;

&lt;p&gt;No (ZeroTier protocol over UDP)&lt;/p&gt;

&lt;p&gt;Distributed roots + optional moons&lt;/p&gt;

&lt;p&gt;~80 MB&lt;/p&gt;

&lt;p&gt;Moon (custom root) propagation can take several minutes; clients occasionally ignore it&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;innernet&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Self-hosted&lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;None — requires routable server or manual port-forward&lt;/p&gt;

&lt;p&gt;~30 MB&lt;/p&gt;

&lt;p&gt;No NAT traversal at all; all peers need path to the innernet server&lt;/p&gt;

&lt;p&gt;The NAT traversal column deserves more attention than it usually gets. Headscale inherits Tailscale's DERP relay infrastructure — you can self-host DERP servers, but most people don't, which means they're still depending on Tailscale's relay nodes when direct connections fail. Netbird's ICE/STUN/TURN stack is more operationally familiar if you've run WebRTC infrastructure, but TURN relay servers are bandwidth-intensive when connections can't punch through directly. Nebula and ZeroTier handle traversal at the protocol level without depending on a relay you separately maintain. innernet doesn't even try — it's the honest option that skips the magic and requires real network access to the server.&lt;/p&gt;

&lt;p&gt;RAM footprints above reflect the &lt;em&gt;server-side control plane only&lt;/em&gt;, not the per-node agent. Agent overhead on nodes is modest across all five — typically under 15 MB RSS once the tunnel is established. Where these numbers start to matter is when you're running the control plane on a small VPS (1 GB RAM, shared) alongside other services. Nebula's lighthouse at ~20 MB barely registers. Netbird's full self-hosted stack at ~150 MB means you'll want it on a dedicated instance or at minimum a 2 GB droplet with swap configured properly.&lt;/p&gt;

&lt;p&gt;Operationally, the gotcha column is the one to read twice before committing. Headscale's lag behind upstream Tailscale client releases has bitten people trying to use newer Tailscale features like tagging or SSH access — the server-side implementation may be absent or broken for weeks after a client ships. Nebula's manual certificate workflow looks manageable until you have 30 nodes and need to rotate the CA. At that point you're writing your own tooling. ZeroTier's moon propagation delays are documented but the client-side behavior — where a client simply keeps routing through public roots even after a moon is configured — requires hands-on debugging to confirm it's actually working. None of these are dealbreakers, but all of them are costs that don't show up in the README.&lt;/p&gt;

&lt;h2&gt;
  
  
  Headscale: Tailscale Clients, Your Control Server
&lt;/h2&gt;

&lt;p&gt;The thing that makes Headscale interesting isn't that it replaces Tailscale — it's that it &lt;em&gt;reuses&lt;/em&gt; Tailscale. Your nodes run the official &lt;code&gt;tailscale&lt;/code&gt; client binary, pointed at your own control server instead of Tailscale's. Headscale reimplements the coordination plane (key exchange, peer lists, ACL distribution) so the actual WireGuard tunnel setup stays identical to what you'd get from the vendor. That's a meaningful distinction: you're not shipping a new VPN stack to every node, you're just swapping where they phone home.&lt;/p&gt;

&lt;p&gt;Getting it running is straightforward. A minimal Docker Compose setup that actually works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;headscale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;headscale/headscale:0.23&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;headscale&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./config:/etc/headscale&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./data:/var/lib/headscale&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9090:9090"&lt;/span&gt;   &lt;span class="c1"&gt;# metrics&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;serve&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;config.yaml&lt;/code&gt; has a lot of keys, but only a handful matter for a working deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;server_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://headscale.yourdomain.com&lt;/span&gt;   &lt;span class="c1"&gt;# what clients advertise to each other&lt;/span&gt;
&lt;span class="na"&gt;listen_addr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0:8080&lt;/span&gt;
&lt;span class="na"&gt;metrics_listen_addr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0:9090&lt;/span&gt;

&lt;span class="na"&gt;db_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sqlite&lt;/span&gt;
&lt;span class="na"&gt;db_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/headscale/db.sqlite&lt;/span&gt;

&lt;span class="na"&gt;dns_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;magic_dns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;base_domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;headnet.internal&lt;/span&gt;
  &lt;span class="na"&gt;nameservers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;1.1.1.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLite is fine for anything under a few hundred nodes — don't reach for Postgres unless you're actually hitting write contention. Once the server is up, registering a node is one command after running &lt;code&gt;tailscale up --login-server https://headscale.yourdomain.com&lt;/code&gt; on the client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# grab the nodekey from the tailscale up output on the client, then on the headscale host:&lt;/span&gt;
headscale nodes register &lt;span class="nt"&gt;--user&lt;/span&gt; myuser &lt;span class="nt"&gt;--key&lt;/span&gt; nodekey:abc123...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MagicDNS works reliably — peers resolve each other by hostname without any additional DNS config on your end. What &lt;em&gt;doesn't&lt;/em&gt; work cleanly is exit node support and anything in Tailscale's newer feature surface: Tailscale SSH, Funnel, and the App Connector are control-plane features Headscale hasn't caught up on. The Headscale team maintains a compatibility matrix in the repo, and it's worth checking it against the specific &lt;code&gt;tailscale&lt;/code&gt; client version you're deploying before you commit. A mismatch between client version and Headscale version tends to manifest as silent registration failures or ACL enforcement behaving unexpectedly rather than clean error messages. The best fit here is a team that already knows Tailscale's UX and just wants the control plane on metal they own. If you need Tailscale SSH or Funnel, Headscale isn't a drop-in — those features don't exist at this layer yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Netbird: WireGuard Mesh With a Built-in Admin UI
&lt;/h2&gt;

&lt;p&gt;The part that surprises most people: Netbird's self-hosted stack is actually three logical components — a signal server, a management server, and a STUN/TURN relay — but they collapse into a single &lt;code&gt;netbird-management&lt;/code&gt; Docker image plus a separately deployed &lt;code&gt;coturn&lt;/code&gt; instance. You're not stitching together five repos. The management image handles peer registration, ACL policy distribution, and the web dashboard. The signal server (bundled in the same image) is the lightweight coordination channel that bootstraps ICE negotiation between peers. Coturn is the fallback relay when direct P2P fails, and it's the one piece you manage separately.&lt;/p&gt;

&lt;p&gt;The minimum viable &lt;code&gt;docker-compose.yml&lt;/code&gt; block for the management server looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;netbird-management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;netbirdio/management:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;33073:33073"&lt;/span&gt;   &lt;span class="c1"&gt;# signal gRPC&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10000:10000"&lt;/span&gt;   &lt;span class="c1"&gt;# management gRPC&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NETBIRD_MGMT_API_ENDPOINT=https://your-domain.example.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NETBIRD_SIGNAL_URI=your-domain.example.com:10000&lt;/span&gt;
      &lt;span class="c1"&gt;# Leave the OIDC block out entirely if you want setup-key auth&lt;/span&gt;
      &lt;span class="c1"&gt;# NETBIRD_OIDC_CONFIGURATION_ENDPOINT=https://your-idp/.well-known/openid-configuration&lt;/span&gt;
      &lt;span class="c1"&gt;# NETBIRD_OIDC_CLIENT_ID=your-client-id&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./netbird-mgmt:/var/lib/netbird&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the OIDC block, Netbird falls back to setup keys — you generate a key in the dashboard, paste it into the client on enrollment, done. For a homelab with five to fifteen nodes this is completely fine and removes an entire dependency (no Keycloak, no Authentik required at setup time). The SSO path is worth it if you're managing more nodes or want per-user audit trails, but it adds a real operational surface. The OIDC configuration endpoint must be reachable from the management container at startup, not just at login time — that burns people who configure it behind a private DNS that the container can't resolve.&lt;/p&gt;

&lt;p&gt;The NAT traversal story is where Netbird diverges meaningfully from Tailscale. Tailscale uses DERP (a relay protocol they operate) as its fallback; you can self-host a DERP server but most people don't. Netbird uses ICE — the same Interactive Connectivity Establishment protocol that WebRTC uses — which has better peer-to-peer success rates specifically on symmetric NAT scenarios because it tries more candidate pairs aggressively. The tradeoff: when ICE fails and you fall through to TURN relay, &lt;em&gt;you're&lt;/em&gt; running coturn, which means you're on the hook for its TLS certs, its UDP port exposure (3478 for STUN, 5349 for TURNS), and its resource cost under relay load. A coturn instance doing no active relay sits around 15 MB RSS. Under heavy relay traffic that number climbs proportionally to throughput, not node count.&lt;/p&gt;

&lt;p&gt;On a small VM — a 1 vCPU / 2 GB instance is realistic — the idle footprint for the full stack is around 120 MB RSS total across management, signal, and coturn. That's comfortable on a 2 GB machine as long as you're not actively relaying significant traffic. The database backing management is SQLite by default, which is fine up to somewhere in the low hundreds of nodes; past that you'd want to look at the Postgres backend option. One gotcha that doesn't surface in the quickstart: the management container writes its SQLite file to &lt;code&gt;/var/lib/netbird/&lt;/code&gt;, and if you forget to bind-mount that directory, a container restart wipes your entire peer registry. Mount it before you enroll your first node, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nebula: Flat Mesh Without Any Central Relay
&lt;/h2&gt;

&lt;p&gt;Most mesh VPN tools still phone home to something — a coordination server, a relay, a SaaS dashboard. Nebula doesn't. The &lt;em&gt;lighthouse&lt;/em&gt; is the closest thing to a central component, and all it does is help peers find each other's public IP and port. Once the handshake completes, the lighthouse is out of the picture entirely. Packets go peer-to-peer, always. That's a fundamentally different threat model and a fundamentally different ops story than Tailscale's DERP relay fallback.&lt;/p&gt;

&lt;p&gt;The CA setup is genuinely two commands, and that's not marketing simplification — that's the actual workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Generate the CA once, store ca.key somewhere safe (not on any node)&lt;/span&gt;
nebula-cert ca &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'homelab'&lt;/span&gt;

&lt;span class="c"&gt;# Sign a cert for each node — this is the only auth mechanism&lt;/span&gt;
nebula-cert sign &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'node1'&lt;/span&gt; &lt;span class="nt"&gt;-ip&lt;/span&gt; &lt;span class="s1"&gt;'192.168.100.1/24'&lt;/span&gt; &lt;span class="nt"&gt;-ca-crt&lt;/span&gt; ca.crt &lt;span class="nt"&gt;-ca-key&lt;/span&gt; ca.key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every node gets a &lt;code&gt;config.yaml&lt;/code&gt;, its own signed cert, and the CA's public cert. There's no token, no API key, no enrollment flow. If the cert is valid and signed by your CA, the node is in. If it's not, it's not. The config itself is about 60 lines for a typical node — lighthouse address, cert paths, firewall rules, and whether this node &lt;em&gt;is&lt;/em&gt; a lighthouse. A minimal non-lighthouse config looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;pki&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ca&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/nebula/ca.crt&lt;/span&gt;
  &lt;span class="na"&gt;cert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/nebula/node1.crt&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/nebula/node1.key&lt;/span&gt;

&lt;span class="na"&gt;static_host_map&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.168.100.254"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-vps-ip:4242"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# lighthouse's nebula IP → public addr&lt;/span&gt;

&lt;span class="na"&gt;lighthouse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;am_lighthouse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.168.100.254"&lt;/span&gt;

&lt;span class="na"&gt;listen&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;  &lt;span class="c1"&gt;# 0 = random ephemeral port, fine for non-lighthouses&lt;/span&gt;

&lt;span class="na"&gt;firewall&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;outbound&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;any&lt;/span&gt;
      &lt;span class="na"&gt;proto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;any&lt;/span&gt;
      &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;any&lt;/span&gt;
  &lt;span class="na"&gt;inbound&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;any&lt;/span&gt;
      &lt;span class="na"&gt;proto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;icmp&lt;/span&gt;
      &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;any&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you give up relative to Tailscale is real and worth stating plainly. Nebula has no automatic key rotation — when a cert expires (you set the duration at signing time with &lt;code&gt;-duration 8760h&lt;/code&gt; for one year), you re-issue and redeploy manually. There's no GUI, no mobile app with a split-tunnel toggle, no ACL editor in a browser. The firewall rules live in each node's YAML. There's also no concept of exit nodes as a first-class feature. Nebula is a pure infrastructure primitive: it gives you an encrypted overlay network and nothing else. The operational surface is low, but the UX surface is zero.&lt;/p&gt;

&lt;p&gt;The sweet spot is a fixed homelab topology — a NAS, a few servers, maybe a VPS, nodes that exist for months or years. Sign the certs with a two-year duration, deploy, and mostly forget it. The worst fit is a dynamic fleet where you're adding and removing devices frequently and want someone (or something) to handle re-enrollment. Every new node requires you to issue a cert, copy three files to it, and start the daemon. That's not hard, but it doesn't scale to a team or a setup where devices join and leave constantly. If your topology changes weekly, the lack of a management plane will grind on you fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  ZeroTier Self-Hosted Controller and innernet: The Honorable Mentions
&lt;/h3&gt;

&lt;p&gt;The "self-hosted ZeroTier" pitch sounds clean until you actually read the architecture. Running &lt;code&gt;ztncui&lt;/code&gt; or posting to the &lt;code&gt;/controller&lt;/code&gt; REST API on your own node gives you local network management — peer authorization, route assignment, all of it. But ZeroTier's planet/moon model still phones home to ZeroTier's root infrastructure for initial peer discovery unless you explicitly configure a private moon. That step is not prominent in most third-party setup guides. Without it, your "self-hosted" controller still depends on ZeroTier's uptime for new peers to find each other. Configuring a moon means generating a moon identity, hosting it on a reachable IP, and distributing the moon definition to every peer via &lt;code&gt;zerotier-cli orbit&lt;/code&gt;. Doable, but it's a second project sitting on top of the first one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Generate a moon from an existing node's identity&lt;/span&gt;
zerotier-idtool initmoon identity.public &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; moon.json
&lt;span class="c"&gt;# Edit moon.json to add your public IP under "stableEndpoints"&lt;/span&gt;
zerotier-idtool genmoon moon.json
&lt;span class="c"&gt;# Output: 000000DEADBEEF.moon — copy to /var/lib/zerotier-one/moons.d/&lt;/span&gt;
&lt;span class="c"&gt;# On every peer:&lt;/span&gt;
zerotier-cli orbit DEADBEEF DEADBEEF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;innernet takes the opposite architectural stance — it's WireGuard all the way down, with a server component written in Rust that distributes peer configuration as TOML files. The peer invite flow is genuinely nicer than Nebula's CA ceremony. You run &lt;code&gt;innernet add-peer &amp;lt;network&amp;gt;&lt;/code&gt; on the server, hand the resulting invite file to the new peer, and they run &lt;code&gt;innernet install &amp;lt;invite.toml&amp;gt;&lt;/code&gt;. No intermediate CA steps, no manually signing certificates. Configuration ends up readable and diffable, which matters when you're debugging at midnight. The catch is the project's commit cadence. At the time of writing, the GitHub activity has stretched into multi-month quiet periods. That's not automatically fatal for stable software, but before you wire innernet into a production homelab, check the commit history yourself — specifically whether issues touching recent kernel versions are getting responses.&lt;/p&gt;

&lt;p&gt;The honest framing for both: the technology underneath is sound. ZeroTier's virtual Ethernet model is mature and the controller API is well-documented. innernet's WireGuard-native design means the crypto and tunnel primitives aren't the risk surface. The risk is organizational. Headscale has active maintainers, a clear contribution pipeline, and enough community momentum that a single contributor going quiet doesn't stall the project. Netbird has a commercial entity behind it with obvious incentive to keep the open-source version functional. ZeroTier self-hosted and innernet carry higher bus-factor exposure — smaller contributor pools, and in innernet's case, no obvious commercial backing pushing maintenance forward. For a homelab where you can tolerate a slower patch cycle, that trade-off is acceptable. For anything you'd be paged about at 3am, weight that risk seriously before committing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking the Right Tool for Your Setup
&lt;/h2&gt;

&lt;p&gt;The fastest way to narrow this down: ask what you're actually replacing. If you're migrating an existing Tailscale deployment — you already have your ACLs defined, your peers are registered, your team knows the &lt;code&gt;tailscale up&lt;/code&gt; workflow — Headscale is the obvious move. The client-side experience is identical. You swap the coordination server URL, re-auth your nodes, and you're done. No new mental model, no relearning. That migration friction is genuinely lower than any other option in this space, and that's not a small thing when you're managing more than a handful of nodes.&lt;/p&gt;

&lt;p&gt;If you need a graphical management interface and real ACL tooling without hand-editing YAML for every peer relationship, Netbird is where you land. The UI is well-built, the policy model maps cleanly to how most people think about network segmentation, and you can self-host the full control plane. The honest cost is that you're now also operating &lt;strong&gt;coturn&lt;/strong&gt; as infrastructure — that's your STUN/TURN relay, and it needs to stay up for peers behind symmetric NAT to stay connected. Plan for that. It's not hard to run, but it's another service to monitor, another port to keep open (&lt;code&gt;3478/udp&lt;/code&gt; and &lt;code&gt;5349/tcp&lt;/code&gt; by default), and another thing that pages you at 2am if it silently dies.&lt;/p&gt;

&lt;p&gt;For a static mesh where you know your topology won't change much and you want the smallest possible attack surface: Nebula. There's no control plane running anywhere. No API endpoint accepting connections. No coordination server to patch when a CVE drops. You generate certs with &lt;code&gt;nebula-cert sign&lt;/code&gt;, distribute them out-of-band, point peers at your lighthouse IPs, and the network runs. The lighthouse is just a rendezvous point — it holds no state about your traffic. If your threat model includes "an attacker who can reach my control plane," Nebula removes that surface entirely. The tradeoff is that adding a new peer is a manual operation, every time.&lt;/p&gt;

&lt;p&gt;The same evaluation logic extends cleanly to AI workloads and automation pipelines that need to run behind a mesh — whether that's an n8n instance talking to a local Ollama endpoint, or a semantic search service you don't want publicly exposed. For those setups, the local-vs-cloud tradeoff question doesn't stop at the model layer; it runs all the way down to the networking layer. The same reasoning that pushes you toward local models for data sensitivity reasons also pushes you toward self-hosted mesh networking. If you're working through that broader tradeoff, the &lt;a href="https://techdigestor.com/best-ai-coding-tools-2026/" rel="noopener noreferrer"&gt;AI Coding Tools in 2026: Cloud Copilots vs Local Models&lt;/a&gt; guide covers how these decisions stack across the toolchain — the criteria transfer directly.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/5-lightweight-tailscale-alternatives-worth-running-on-your-own-hardware/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>productivity</category>
      <category>tools</category>
    </item>
    <item>
      <title>5 Self-Hosted Monitoring Tools Worth Running on Your Own Hardware in 2026</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Wed, 15 Jul 2026 08:10:31 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/5-self-hosted-monitoring-tools-worth-running-on-your-own-hardware-in-2026-5ame</link>
      <guid>https://dev.to/ericwoooo_kr/5-self-hosted-monitoring-tools-worth-running-on-your-own-hardware-in-2026-5ame</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Most SaaS monitoring products are built around the assumption that you're running fleets of identical cloud VMs serving HTTP traffic.  Plug in the agent, get CPU and memory graphs, call it done.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~18 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Monitoring Problem No SaaS Tool Solves for You&lt;/li&gt;
&lt;li&gt;Prometheus + Grafana: The Standard Stack That Earns Its Complexity&lt;/li&gt;
&lt;li&gt;VictoriaMetrics: Prometheus-Compatible, Half the RAM&lt;/li&gt;
&lt;li&gt;Netdata: Useful First, Configurable Later&lt;/li&gt;
&lt;li&gt;Uptime Kuma: Exactly One Job, Done Well&lt;/li&gt;
&lt;li&gt;Loki + Promtail: Log Aggregation That Doesn't Eat Your Disk&lt;/li&gt;
&lt;li&gt;Picking the Right Tool for Your Actual Setup&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Monitoring Problem No SaaS Tool Solves for You
&lt;/h2&gt;

&lt;p&gt;Most SaaS monitoring products are built around the assumption that you're running fleets of identical cloud VMs serving HTTP traffic. Plug in the agent, get CPU and memory graphs, call it done. That model breaks completely when your stack is an Ollama inference server hammering a 32GB VRAM workstation, an n8n instance running inside Docker with a dozen active workflows, and a PM2-managed Node engine firing on cron schedules. The per-host billing model punishes you twice: once for the cost, and again because the default dashboards surface nothing you actually care about.&lt;/p&gt;

&lt;p&gt;The metrics that actually matter for this kind of setup don't exist in any default SaaS template. GPU VRAM headroom is the first thing I check — if &lt;code&gt;nvidia-smi&lt;/code&gt; shows the model context is eating 28GB of 32GB and a second request hits, latency spikes aren't a mystery anymore, they're a predictable consequence. Container restart loops in Docker are a close second: a workflow executor that silently restarts every 40 minutes will look fine in an uptime check but corrupt half your pipeline runs. PM2 cron jobs fail quietly — no exit code surfaces unless you're explicitly scraping the process list. And reverse proxy 502 bursts that last under 30 seconds will disappear from any monitoring tool polling at 1-minute intervals.&lt;/p&gt;

&lt;p&gt;The evaluation criteria for the five tools below are deliberately narrow. Install complexity matters because a monitoring setup that takes three days to configure is a monitoring setup you'll abandon. Resource footprint matters because the host you're monitoring is already doing real work — a 600MB resident-memory agent on the same box as an inference server is a bad trade. And the tool either surfaces ops-relevant data in under an hour of configuration, or it doesn't make the list. No partial credit for "you can build a dashboard that shows this if you write enough PromQL."&lt;/p&gt;

&lt;p&gt;For context on what pipeline-level observability looks like — specifically how n8n workflow success and failure rates translate into something you can alert on — the framing in &lt;a href="https://techdigestor.com/ultimate-productivity-guide-2026/" rel="noopener noreferrer"&gt;Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines&lt;/a&gt; is worth reading alongside this. The monitoring layer and the automation layer solve different problems, but their failure modes overlap more than most ops writeups acknowledge. A cron pipeline that silently stops producing output looks identical to a cron pipeline that never ran — you need both execution telemetry and system metrics to tell them apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prometheus + Grafana: The Standard Stack That Earns Its Complexity
&lt;/h2&gt;

&lt;p&gt;The thing most people get wrong about this stack: Prometheus and Grafana don't integrate — they coexist. Prometheus scrapes exporters on a pull model and stores the resulting time-series locally. Grafana queries that store and draws pictures. There's no magic glue, no plugin that connects them invisibly. You wire them together at the datasource level, and you feel every seam when something breaks. That transparency is actually the argument for using it. When a dashboard shows nothing, you can bisect the problem: is the exporter up? Is Prometheus scraping it? Is the Grafana datasource pointed at the right port? Each layer is independently interrogatable.&lt;/p&gt;

&lt;p&gt;Here's a minimum viable setup that actually runs. The provisioned datasource avoids the "click around in the UI" step that gets lost the moment someone rebuilds the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/prometheus:v2.51.0&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./prometheus.yml:/etc/prometheus/prometheus.yml&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;prometheus_data:/prometheus&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--config.file=/etc/prometheus/prometheus.yml'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.retention.time=90d'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--storage.tsdb.wal-compression'&lt;/span&gt;  &lt;span class="c1"&gt;# cuts WAL disk use 30-40%&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9090:9090"&lt;/span&gt;

  &lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/grafana:10.4.2&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;grafana_data:/var/lib/grafana&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./grafana/provisioning:/etc/grafana/provisioning&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GF_SECURITY_ADMIN_PASSWORD=changeme&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheus_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;grafana_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# prometheus.yml&lt;/span&gt;
&lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scrape_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;

&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;node'&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;node_exporter:9100'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cadvisor'&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cadvisor:8080'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# grafana/provisioning/datasources/prometheus.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;datasources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prometheus&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://prometheus:9090&lt;/span&gt;  &lt;span class="c1"&gt;# container name, not localhost&lt;/span&gt;
    &lt;span class="na"&gt;isDefault&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;editable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retention math is the part nobody thinks about until they're watching &lt;code&gt;df -h&lt;/code&gt; scroll upward. At 15-second scrape intervals, a single exporter like &lt;code&gt;node_exporter&lt;/code&gt; generates roughly 1–2 MB of TSDB data per day. Forty exporters — a realistic number once you're scraping per-container metrics with cAdvisor across several hosts — means 40–80 MB/day, which compounds quickly against a 90-day retention window. The &lt;code&gt;--storage.tsdb.wal-compression&lt;/code&gt; flag pairs with &lt;code&gt;--storage.tsdb.retention.time=90d&lt;/code&gt; and meaningfully reduces the write-ahead log on disk. If you're pushing past that, the Prometheus docs point toward Thanos or VictoriaMetrics for remote storage, but that's a different problem entirely.&lt;/p&gt;

&lt;p&gt;Pick this stack when the requirement is a permanent, queryable, auditable record of your infrastructure — something you can run ad-hoc PromQL against at 2am to figure out what happened three weeks ago. The query language has a learning curve that's real but finite. Once you can write &lt;code&gt;rate(http_requests_total[5m])&lt;/code&gt; and understand why the range vector matters, the model clicks. If the requirement is instead "get a dashboard running before the next standup," this stack will frustrate you. The provisioning alone assumes you understand what a datasource is and why it needs to be inside the Grafana container's filesystem on startup. That's not a criticism — it's a signal about which kind of operator this tool is designed for.&lt;/p&gt;

&lt;h2&gt;
  
  
  VictoriaMetrics: Prometheus-Compatible, Half the RAM
&lt;/h2&gt;

&lt;p&gt;Most people discover VictoriaMetrics the wrong way — they see "Prometheus-compatible" and assume it's just a faster remote storage backend. The more useful mental model: the single-node binary &lt;em&gt;is&lt;/em&gt; the storage &lt;em&gt;and&lt;/em&gt; the scrape target rolled into one process. Drop it in place of Prometheus, point your existing &lt;code&gt;prometheus.yml&lt;/code&gt; at it, and your Grafana dashboards keep working without a single panel edit. MetricsQL is a superset of PromQL, so queries that work in Prometheus work here too — the extension functions are additive, not breaking.&lt;/p&gt;

&lt;p&gt;The RAM story is real but requires nuance. On a scrape config hitting 50 targets at a 15-second interval, VictoriaMetrics single-node typically sits under 200 MB RSS. Prometheus doing the same work tends to land in the 400–600 MB range, sometimes higher depending on how aggressive your recording rules are. The official docs claim up to 7x less RAM, which is technically achievable under ideal conditions — low cardinality, compact label sets. Where the gap closes is high-cardinality data: if you're scraping something that emits per-request or per-user label dimensions, both tools suffer proportionally and the ratio shrinks toward 2–3x. Still meaningful on a constrained machine, but not magic.&lt;/p&gt;

&lt;p&gt;The configuration model is deliberately minimal, which is either refreshing or annoying depending on your background. Retention is a process flag, not a YAML stanza:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose fragment&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;victoriametrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;victoriametrics/victoria-metrics:v1.101.0&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--storageDataPath=/victoria-metrics-data"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--retentionPeriod=12"&lt;/span&gt;   &lt;span class="c1"&gt;# months, not days — easy to misread&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--httpListenAddr=:8428"&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8428:8428"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vm-data:/victoria-metrics-data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want the collection layer decoupled from storage — useful when you're scraping dozens of targets and want independent scaling — &lt;code&gt;vmagent&lt;/code&gt; is the sidecar that handles scraping and &lt;code&gt;remote_write&lt;/code&gt; forwarding. It accepts the same &lt;code&gt;scrape_configs&lt;/code&gt; format Prometheus does. The split also means you can restart the storage node for maintenance without dropping scrape coverage, which Prometheus's monolithic design doesn't give you cleanly.&lt;/p&gt;

&lt;p&gt;Pick VictoriaMetrics over Prometheus when at least one of these is true: you're running on hardware where RAM has real consequences (a mini-PC lab node, a shared VPS where you're paying per GB), you have existing Grafana dashboards you're not willing to rewrite, or you need retention beyond 30 days without provisioning proportionally large disk. Prometheus's default storage compresses reasonably well but still scales linearly with retention window. VictoriaMetrics's storage engine compresses more aggressively — the on-disk footprint for the same dataset is noticeably smaller, which matters when you're trying to keep 6–12 months of metrics on a 50 GB volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Netdata: Useful First, Configurable Later
&lt;/h2&gt;

&lt;p&gt;The install story here is genuinely unusual. Most monitoring tools require you to configure a scrape target, set up a service, restart things three times, and then realize you forgot to open a firewall port. Netdata's kickstart script does none of that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# drops a working dashboard at http://your-host:19999 — no follow-up config required&lt;/span&gt;
wget &lt;span class="nt"&gt;-O&lt;/span&gt; /tmp/netdata-kickstart.sh https://my-netdata.io/kickstart.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; sh /tmp/netdata-kickstart.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under two minutes to a live dashboard. It auto-discovers Docker containers by reading the socket, picks up systemd unit states, and surfaces metrics for Nginx, Postgres, Redis, and most common services without a single config file edit. If you've ever bootstrapped Prometheus from scratch — writing a &lt;code&gt;prometheus.yml&lt;/code&gt;, finding the right exporters, wiring up Grafana datasources, building dashboards — the contrast is jarring. That zero-to-visible speed matters most during incidents when you inherit a broken machine and need situational awareness immediately.&lt;/p&gt;

&lt;p&gt;Where Netdata genuinely outpaces a default Prometheus stack is metric resolution and pre-built visualizations. Prometheus node_exporter &lt;em&gt;collects&lt;/em&gt; CPU steal time, disk latency percentiles, and TCP retransmit rates — but at a 15-second scrape interval by default, and without shipping any dashboards. You get the data; you build the panels. Netdata ships those visualizations pre-wired at one-second resolution. CPU steal spikes that last four seconds show up clearly. A Prometheus setup scraping every 15 seconds can miss those entirely, or flatten them into a near-invisible bump. For the specific problem of catching short-duration resource contention on shared infrastructure, that resolution difference is decisive.&lt;/p&gt;

&lt;p&gt;The retention story is where you have to make a deliberate decision before committing. Netdata's native &lt;code&gt;dbengine&lt;/code&gt; is designed for short-term forensics — excellent for the last few hours, workable for a few days, not designed for 90-day trending or capacity planning queries. Your two options for extending retention both have costs. First, configure a &lt;code&gt;prometheus remote_write&lt;/code&gt; target and push metrics into your existing Prometheus/VictoriaMetrics stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/netdata/exporting.conf
&lt;/span&gt;[&lt;span class="n"&gt;prometheus_remote_write&lt;/span&gt;:&lt;span class="n"&gt;my_victoria&lt;/span&gt;]
    &lt;span class="n"&gt;enabled&lt;/span&gt; = &lt;span class="n"&gt;yes&lt;/span&gt;
    &lt;span class="n"&gt;destination&lt;/span&gt; = &lt;span class="n"&gt;http&lt;/span&gt;://&lt;span class="n"&gt;victoriametrics&lt;/span&gt;:&lt;span class="m"&gt;8428&lt;/span&gt;/&lt;span class="n"&gt;api&lt;/span&gt;/&lt;span class="n"&gt;v1&lt;/span&gt;/&lt;span class="n"&gt;write&lt;/span&gt;
    &lt;span class="n"&gt;remote&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt; &lt;span class="n"&gt;URL&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; = /&lt;span class="n"&gt;api&lt;/span&gt;/&lt;span class="n"&gt;v1&lt;/span&gt;/&lt;span class="n"&gt;write&lt;/span&gt;
    &lt;span class="c"&gt;# send everything; filter later at query time
&lt;/span&gt;    &lt;span class="n"&gt;send&lt;/span&gt; &lt;span class="n"&gt;charts&lt;/span&gt; &lt;span class="n"&gt;matching&lt;/span&gt; = *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second option is Netdata Cloud, which streams your metrics to their hosted infrastructure. For operators running self-hosted specifically because they want data on their own hardware — air-gapped environments, compliance constraints, or just preference — that option is a non-starter. The &lt;code&gt;remote_write&lt;/code&gt; path keeps you in control, but now you're running two systems and the "zero config" advantage is diluted. Pick Netdata when you need immediate visibility with no setup friction, when you're monitoring a secondary node where building a full Prometheus stack isn't worth the overhead, or when per-second granularity is the actual requirement rather than long-term trend analysis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Uptime Kuma: Exactly One Job, Done Well
&lt;/h2&gt;

&lt;p&gt;The Prometheus stack will tell you your p99 latency drifted 40ms — but if Nginx is spitting 502s at every visitor while your internal health endpoint still responds on port 8080, you might not find out until someone complains. Uptime Kuma solves exactly that gap: external black-box polling that tests what a real client actually sees. It doesn't try to be a metrics platform. That restraint is the whole point.&lt;/p&gt;

&lt;p&gt;Check types cover the common cases without bloat: HTTP/HTTPS with configurable expected status codes and keyword matching, TCP port reachability, DNS record resolution, and Docker container up/down status. If you need distributed tracing or cardinality-rich time series, look elsewhere. If you need to know whether your VPN endpoint is accepting connections from the outside, Uptime Kuma answers that in under thirty seconds of setup.&lt;/p&gt;

&lt;p&gt;The deploy is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;always &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 3001:3001 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; uptime-kuma:/app/data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; uptime-kuma &lt;span class="se"&gt;\&lt;/span&gt;
  louislam/uptime-kuma:1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything — monitors, alert configs, notification channels, status history — lives in a single SQLite file inside that named volume. Backup strategy is literally &lt;code&gt;cp uptime-kuma.db uptime-kuma.db.bak&lt;/code&gt;. No external database to manage, no schema migrations to babysit across upgrades. The tradeoff is that SQLite doesn't scale to hundreds of monitors with sub-second polling intervals, but for a self-hosted stack watching thirty to fifty endpoints that ceiling is not a real concern.&lt;/p&gt;

&lt;p&gt;The alerting integrations are where it earns its slot alongside a full metrics stack. Telegram, Discord, Slack, and generic webhooks are all first-class. The webhook path drops cleanly into n8n — POST the payload to an n8n webhook trigger, route on monitor name or status, and you have conditional logic, escalation delays, or incident log writes without touching Uptime Kuma's internals at all. Configuration for any of these takes five minutes, not fifty.&lt;/p&gt;

&lt;p&gt;One gotcha that doesn't surface until you try the Docker container monitor: it requires mounting the Docker socket into the Uptime Kuma container.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;always &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 3001:3001 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; uptime-kuma:/app/data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /var/run/docker.sock:/var/run/docker.sock &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; uptime-kuma &lt;span class="se"&gt;\&lt;/span&gt;
  louislam/uptime-kuma:1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mounting &lt;code&gt;/var/run/docker.sock&lt;/code&gt; gives the container effective root on the host — any process inside it can spawn, stop, or inspect any container on the machine. On a firewalled home-lab box where you control all ingress, that risk profile is manageable. On a VPS with a public IP and Uptime Kuma's web UI exposed directly (even behind basic auth), evaluate carefully. The container status monitor is convenient; it isn't worth a compromised host to get it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loki + Promtail: Log Aggregation That Doesn't Eat Your Disk
&lt;/h2&gt;

&lt;p&gt;The real reason to add Loki to a self-hosted stack isn't log &lt;em&gt;storage&lt;/em&gt; — it's log &lt;em&gt;recall&lt;/em&gt;. &lt;code&gt;docker logs --tail 100 n8n&lt;/code&gt; gives you the last hundred lines right now, but if your n8n workflow executor threw a 500 at 2 AM and you're looking at it at 9 AM, those lines are gone. Either you have log rotation eating them, or the buffer scrolled past them hours ago. Tracking that down with &lt;code&gt;grep&lt;/code&gt; across rotated &lt;code&gt;/var/lib/docker/containers/**/*-json.log&lt;/code&gt; files is painful enough once that you'll set up Loki immediately afterward. The architectural reason Loki doesn't balloon your disk is that it indexes only labels — not the full log text. The log lines themselves get compressed and stored as chunks; the index is tiny. Full-text search comes at query time via regex on the stored chunks, not via an inverted index the way Elasticsearch works. That trade-off means slower arbitrary searches but dramatically lower storage overhead for typical DevOps log volumes.&lt;/p&gt;

&lt;p&gt;Getting Promtail to auto-discover Docker containers takes about 20 lines of config. The key is &lt;code&gt;docker_sd_configs&lt;/code&gt;, which reads the Docker socket and emits a scrape target per running container. Relabeling pulls &lt;code&gt;container_name&lt;/code&gt; and &lt;code&gt;com.docker.compose.service&lt;/code&gt; out of the discovered metadata and turns them into Loki labels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;http_listen_port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9080&lt;/span&gt;

&lt;span class="na"&gt;positions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/tmp/positions.yaml&lt;/span&gt;

&lt;span class="na"&gt;clients&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://loki:3100/loki/api/v1/push&lt;/span&gt;

&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
    &lt;span class="na"&gt;docker_sd_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unix:///var/run/docker.sock&lt;/span&gt;
        &lt;span class="na"&gt;refresh_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;
    &lt;span class="na"&gt;relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# keep the bare container name (strips leading slash Docker adds)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__meta_docker_container_name&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/(.*)"&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;container_name&lt;/span&gt;
      &lt;span class="c1"&gt;# pull the Compose service label if it exists&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__meta_docker_container_label_com_docker_compose_service&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compose_service&lt;/span&gt;
      &lt;span class="c1"&gt;# standard log path Promtail needs to tail&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__meta_docker_container_log_stream&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stream&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mount &lt;code&gt;/var/run/docker.sock&lt;/code&gt; read-only into the Promtail container and this works without touching individual container configs. After it's running, &lt;code&gt;{container_name="n8n"}&lt;/code&gt; in Grafana's Explore view returns every log line that n8n has emitted since Promtail started. You can narrow it to &lt;code&gt;{container_name="n8n"} |= "500"&lt;/code&gt; or pipe through a regex for specific workflow IDs. The label cardinality stays low because container names and Compose service names are a short, stable set — exactly what Loki's index is designed for.&lt;/p&gt;

&lt;p&gt;The part the official getting-started docs consistently underplay: Loki will accumulate index chunks indefinitely unless you explicitly configure the compactor to run retention. The &lt;code&gt;boltdb-shipper&lt;/code&gt; compactor defaults to compaction only, not deletion. You need two separate config blocks to actually enforce retention:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;compactor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;working_directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/loki/compactor&lt;/span&gt;
  &lt;span class="na"&gt;shared_store&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filesystem&lt;/span&gt;
  &lt;span class="na"&gt;compaction_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
  &lt;span class="na"&gt;retention_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;        &lt;span class="c1"&gt;# this flag is the non-obvious one&lt;/span&gt;
  &lt;span class="na"&gt;retention_delete_delay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2h&lt;/span&gt;
  &lt;span class="na"&gt;retention_delete_worker_count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;150&lt;/span&gt;

&lt;span class="na"&gt;limits_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;retention_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;744h&lt;/span&gt;         &lt;span class="c1"&gt;# 31 days; set per-tenant or globally here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;retention_enabled: true&lt;/code&gt; in the compactor block, the &lt;code&gt;retention_period&lt;/code&gt; value in &lt;code&gt;limits_config&lt;/code&gt; does nothing. The chunks and index keep growing. Disk fills up on a timeline that depends on your log volume, but on a moderately busy Docker host running a dozen containers, expect several gigabytes per week before compression. With retention enabled and 31 days configured, disk usage plateaus and stays there. Check that it's actually deleting by watching &lt;code&gt;loki_compactor_deleted_chunks_total&lt;/code&gt; in Prometheus — if that counter never moves, the compactor config isn't being picked up.&lt;/p&gt;

&lt;p&gt;The right time to add Loki is exactly when you already have Prometheus and Grafana running. You add Loki as a second datasource in Grafana's datasource settings — same UI, same dashboards panel editor, just a different query language (LogQL instead of PromQL). The marginal operational cost is one more container plus Promtail, and the compactor config above. What you get back is the ability to correlate a spike in an HTTP 500 metric on a Prometheus graph with the exact log lines that caused it, in the same Grafana window, without switching tools. That's the payoff: not fancy log analytics, just not being blind at 2 AM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking the Right Tool for Your Actual Setup
&lt;/h2&gt;

&lt;p&gt;The honest answer to "which tool should I use" is that the decision almost always comes down to two constraints that people don't state up front: how much idle RAM they can spare on the host, and whether they already have a query/visualization layer or need one bundled in. Everything else — cardinality limits, retention policies, integrations — is secondary until you've cleared those two gates.&lt;/p&gt;

&lt;p&gt;Here's how the five tools stack up across the dimensions that actually matter for a self-hosted setup:&lt;/p&gt;

&lt;p&gt;Tool&lt;/p&gt;

&lt;p&gt;Primary Use Case&lt;/p&gt;

&lt;p&gt;Storage Model&lt;/p&gt;

&lt;p&gt;Approx. Idle RAM&lt;/p&gt;

&lt;p&gt;Retention Flexibility&lt;/p&gt;

&lt;p&gt;Biggest Operational Gotcha&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prometheus&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Metrics scraping + alerting&lt;/p&gt;

&lt;p&gt;Local TSDB (pull-based)&lt;/p&gt;

&lt;p&gt;~250–400 MB&lt;/p&gt;

&lt;p&gt;Fixed retention flag; no tiered storage natively&lt;/p&gt;

&lt;p&gt;High-cardinality label sets will eat RAM fast; no horizontal scale without remote write&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VictoriaMetrics&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Long-term metrics storage&lt;/p&gt;

&lt;p&gt;Custom columnar TSDB&lt;/p&gt;

&lt;p&gt;~50–120 MB (single-node)&lt;/p&gt;

&lt;p&gt;Per-metric TTL, downsampling, configurable at ingest&lt;/p&gt;

&lt;p&gt;MetricsQL is close to PromQL but not identical — existing dashboards need audit&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grafana&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Unified query + visualization&lt;/p&gt;

&lt;p&gt;No native metrics store&lt;/p&gt;

&lt;p&gt;~150–250 MB&lt;/p&gt;

&lt;p&gt;Depends entirely on backend datasource&lt;/p&gt;

&lt;p&gt;Dashboard state stored in SQLite by default — back it up before any container restart&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Netdata&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Real-time system telemetry&lt;/p&gt;

&lt;p&gt;In-memory ring buffer + optional DB engine&lt;/p&gt;

&lt;p&gt;~150–300 MB (scales with metrics count)&lt;/p&gt;

&lt;p&gt;Short by default; DB engine required for &amp;gt;1 day&lt;/p&gt;

&lt;p&gt;Cloud-connected by default; disable &lt;code&gt;claim&lt;/code&gt; or it phones home on first run&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uptime Kuma&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Endpoint + uptime checks&lt;/p&gt;

&lt;p&gt;SQLite&lt;/p&gt;

&lt;p&gt;~60–100 MB&lt;/p&gt;

&lt;p&gt;Configurable per-monitor history trim&lt;/p&gt;

&lt;p&gt;No native metric federation; alerting is push-only with no query interface&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loki&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Log aggregation + querying&lt;/p&gt;

&lt;p&gt;Object store or local filesystem chunks&lt;/p&gt;

&lt;p&gt;~100–200 MB (single-binary)&lt;/p&gt;

&lt;p&gt;Retention via compactor; per-stream rules possible&lt;/p&gt;

&lt;p&gt;LogQL regex on high-volume streams is slow without good label strategy upfront&lt;/p&gt;

&lt;p&gt;The decision path by constraint is fairly mechanical once you're honest about your situation. RAM-limited host running under 4 GB free — go VictoriaMetrics over Prometheus, full stop. The idle footprint difference is real and compounds when you're also running Grafana, a Node process, and Docker overhead on the same box. Need immediate visibility into a new host with zero config written — Netdata is the only one that installs and shows you useful data in under five minutes; everything else requires you to define scrape targets or shipping configs first. Need uptime alerting wired into an existing webhook or n8n flow — Uptime Kuma's webhook output is dead simple and requires no intermediate exporter layer. Need to correlate "the API started failing" with "what was in the logs at that exact timestamp" — Loki alongside whichever metrics backend you chose, queried together inside Grafana. That combination is the only way to close that loop without grep-ing through raw log files by hand.&lt;/p&gt;

&lt;p&gt;For a single-workstation home lab running Ollama, Docker services, and a Node automation engine — which is exactly my setup — the stack I'd recommend and actually run is: Uptime Kuma handling external endpoint checks and pushing alerts to n8n webhooks, VictoriaMetrics as the metrics backend because it handles Prometheus remote_write and stays light, Grafana as the single query interface pointed at both VictoriaMetrics and Loki, and Loki added the moment you've had one debugging session where you were correlating container restarts to application errors without correlated timestamps. That last condition sounds vague until it happens, and then Loki goes in immediately. The docker-compose fragment to wire VictoriaMetrics as Prometheus remote_write target looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# prometheus.yml scrape config with remote_write to VictoriaMetrics&lt;/span&gt;
&lt;span class="c1"&gt;# VictoriaMetrics accepts the /api/v1/write endpoint natively&lt;/span&gt;
&lt;span class="na"&gt;remote_write&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://victoriametrics:8428/api/v1/write&lt;/span&gt;
    &lt;span class="na"&gt;queue_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;max_samples_per_send&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10000&lt;/span&gt;
      &lt;span class="c1"&gt;# tune this down on low-traffic hosts to reduce write amplification&lt;/span&gt;
      &lt;span class="na"&gt;batch_send_deadline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One boundary worth being explicit about: none of these tools — not Netdata, not VictoriaMetrics, not any combination of them — will give you per-request Ollama inference latency or VRAM fragmentation state between model loads out of the box. Ollama exposes &lt;code&gt;/api/tags&lt;/code&gt; for loaded model state and you can scrape process-level GPU stats via &lt;code&gt;nvidia-smi --query-gpu=memory.used,memory.free --format=csv,noheader&lt;/code&gt; on a cron or as a custom exporter, but stitching that into meaningful per-request latency tracking requires either a sidecar exporter or middleware instrumentation at the API layer. That's a different problem and a separate article — don't let the absence of it here suggest any of these five tools are incomplete. They're solving infrastructure visibility, not ML observability.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/5-self-hosted-monitoring-tools-worth-running-on-your-own-hardware-in-2026/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Fine-Tuning Qwen2-VL for Blockchain Graph Classification on AMD MI300X: What the Docs Don't Tell You</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Tue, 14 Jul 2026 18:53:39 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/fine-tuning-qwen2-vl-for-blockchain-graph-classification-on-amd-mi300x-what-the-docs-dont-tell-you-4247</link>
      <guid>https://dev.to/ericwoooo_kr/fine-tuning-qwen2-vl-for-blockchain-graph-classification-on-amd-mi300x-what-the-docs-dont-tell-you-4247</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Graph renderings of blockchain transactions carry topology signals that serialize badly into token sequences.  A hub node surrounded by 47 short-lived leaf wallets looks like a table of addresses and amounts in text form — recognizable only if you already know the pattern.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~23 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem: Blockchain Forensics Needs Vision, Not Just Text&lt;/li&gt;
&lt;li&gt;Hardware and Environment Setup on MI300X&lt;/li&gt;
&lt;li&gt;Data Pipeline: Rendering Blockchain Graphs as Training Images&lt;/li&gt;
&lt;li&gt;Fine-Tuning Loop: LoRA on 7B vs Full-Parameter on 7B&lt;/li&gt;
&lt;li&gt;ROCm-Specific Failure Modes and How to Diagnose Them&lt;/li&gt;
&lt;li&gt;Inference Serving: vLLM on ROCm for Classification Throughput&lt;/li&gt;
&lt;li&gt;Verdict: When This Setup Makes Sense and When It Doesn't&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem: Blockchain Forensics Needs Vision, Not Just Text
&lt;/h2&gt;

&lt;p&gt;Graph renderings of blockchain transactions carry topology signals that serialize badly into token sequences. A hub node surrounded by 47 short-lived leaf wallets looks like a table of addresses and amounts in text form — recognizable only if you already know the pattern. Rendered as an image, that star topology is immediately visible as a structural shape. The same applies to layering patterns in mixing operations, where funds move through sequential depth levels that form visually distinct bands, and to clustering signatures where tightly-coupled address groups show dense internal edges versus sparse external ones. A vision-language model can learn to classify on those shapes directly. A text-based LLM working from a transaction list has to reconstruct the topology from raw numbers, which is possible but brittle — edge count and clustering coefficient can be computed and injected as tokens, but that's you doing the feature engineering that the vision model can learn to do itself.&lt;/p&gt;

&lt;p&gt;The reason Qwen2-VL entered this experiment rather than a GNN is mostly practical. Graph neural networks are the academically correct tool for graph classification, but they require a fixed-schema graph dataset and a training pipeline separate from anything you'd use for general reasoning tasks. Qwen2-VL's 7B variant handles native high-resolution image input through its Naive Dynamic Resolution mechanism — it doesn't resize everything to a fixed grid, it tokenizes image tiles at the resolution they arrive at, which matters when you're rendering transaction graphs at 1024×1024 or higher to preserve edge detail. Both the 7B and 72B variants are open-weight, and there are documented fine-tuning paths through LLaMA-Factory and ms-swift that don't require you to reimplement the training loop from scratch. That combination — capable vision encoder, open weights, working fine-tuning toolchain — is what makes it a reasonable thing to actually attempt rather than just discuss.&lt;/p&gt;

&lt;p&gt;The catch is that every fine-tuning guide in the wild assumes CUDA. LLaMA-Factory's documentation, ms-swift's quickstart, the Qwen2-VL model card examples — all of it is written assuming an NVIDIA GPU with a working &lt;code&gt;nvidia-smi&lt;/code&gt;. AMD MI300X with ROCm 6.x is a legitimate high-VRAM compute surface (192GB HBM3 on the full card), but it's a different operational surface. ROCm's PyTorch builds lag behind CUDA builds on library support, flash attention requires &lt;code&gt;flash-attn&lt;/code&gt; compiled against ROCm or a fallback to &lt;code&gt;xformers&lt;/code&gt; with its own build friction, and environment variables that CUDA ignores (&lt;code&gt;ROCR_VISIBLE_DEVICES&lt;/code&gt;, &lt;code&gt;HSA_OVERRIDE_GFX_VERSION&lt;/code&gt;) become load-bearing parts of your startup script. The documentation gaps aren't theoretical — they show up immediately when you try to run a training script that works fine on an A100 and silently falls back to CPU ops on MI300X because a kernel dispatch didn't resolve.&lt;/p&gt;

&lt;p&gt;What this article actually covers: the fine-tuning loop I ran, the specific ROCm friction points that cost time to diagnose, and honest characterization of throughput and classification accuracy — framed as a development log, not a benchmark report. There's no finished product here. The goal is to give someone attempting the same stack a map of where the real problems are, with enough specifics (ROCm version, library versions, config file contents, error messages) to skip the failure modes I already hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardware and Environment Setup on MI300X
&lt;/h2&gt;

&lt;p&gt;The MI300X's 192GB HBM3 pool is the first thing that reframes your assumptions about what's tractable on a single node. Full-parameter fine-tuning of a 7B model like Qwen2-VL sits comfortably under 80GB with optimizer states and gradients included — which means you're not rationing memory, you're thinking about compute scheduling instead. LoRA on a 72B model becomes a legitimate single-node workflow rather than a multi-GPU coordination problem. The practical shift: your bottleneck moves from "will this fit?" to "am I keeping the compute units saturated?" That's a better problem to have, but it means naive training loops that constantly wait on data loading or tokenization will leave the hardware idle in ways that don't show up obviously in memory profiling.&lt;/p&gt;

&lt;p&gt;ROCm 6.1 is the minimum to get Qwen2-VL's Flash Attention path working correctly. The fastest route to a working environment is the AMD-maintained Docker base image rather than trying to layer ROCm onto a generic Ubuntu install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull rocm/pytorch:rocm6.1_ubuntu22.04_py3.10_pytorch_2.1.2

docker run &lt;span class="nt"&gt;--device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/kfd &lt;span class="nt"&gt;--device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/dri &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--group-add&lt;/span&gt; video &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ipc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;host &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cap-add&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SYS_PTRACE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-opt&lt;/span&gt; &lt;span class="nv"&gt;seccomp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;unconfined &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /path/to/your/project:/workspace &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-it&lt;/span&gt; rocm/pytorch:rocm6.1_ubuntu22.04_py3.10_pytorch_2.1.2 bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--ipc=host&lt;/code&gt; flag matters here — skip it and you'll see silent hangs during multi-worker DataLoader initialization that look like training stalls. The base image gets you a working HIP stack, ROCm-aware PyTorch, and the right RCCL build for collective ops without having to resolve the kernel header version conflicts that show up when you install &lt;code&gt;rocm-dev&lt;/code&gt; packages manually on top of a stock Ubuntu image.&lt;/p&gt;

&lt;p&gt;Three environment variables control whether your ops actually run on the GPU or silently fall back to CPU:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;HIP_VISIBLE_DEVICES&lt;/code&gt;&lt;/strong&gt; — controls which GPU indices HIP sees; analogous to &lt;code&gt;CUDA_VISIBLE_DEVICES&lt;/code&gt; but they don't alias to each other cleanly on all ROCm versions&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;ROCR_VISIBLE_DEVICES&lt;/code&gt;&lt;/strong&gt; — operates at the runtime layer below HIP; if this disagrees with &lt;code&gt;HIP_VISIBLE_DEVICES&lt;/code&gt;, ops route inconsistently&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;HSA_OVERRIDE_GFX_VERSION=9.4.2&lt;/code&gt;&lt;/strong&gt; — required for gfx942 (which is what the MI300X reports) when compiled kernels don't yet have explicit gfx942 entries; without it, PyTorch falls back to a generic path or CPU silently&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure mode when any of these is missing is insidious: no crash, no warning, training proceeds, loss curves look plausible, and you only notice something is wrong when throughput is 20x slower than expected or you profile and find all the matmul time sitting on CPU. Set them explicitly in your container entrypoint or your training launch script, not as shell exports you might forget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add to your training launch script, not just your .bashrc&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;HIP_VISIBLE_DEVICES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ROCR_VISIBLE_DEVICES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;HSA_OVERRIDE_GFX_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;9.4.2

&lt;span class="c"&gt;# Verify before starting a long run&lt;/span&gt;
python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import torch; print(torch.cuda.get_device_name(0)); print(torch.cuda.is_available())"&lt;/span&gt;
&lt;span class="c"&gt;# Expected: AMD Instinct MI300X, True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flash Attention 2 on ROCm deserves its own warning. The &lt;code&gt;flash-attn&lt;/code&gt; pip wheel that installs without error is built against CUDA. It will install cleanly on a ROCm system, import without complaint, and produce incorrect gradients during training — not NaNs, not explosions, just subtly wrong values that corrupt your fine-tune. The two legitimate paths are: build &lt;code&gt;flash-attn&lt;/code&gt; from source against your ROCm stack (time-consuming but explicit), or use PyTorch 2.3+ which exposes an &lt;code&gt;aotriton&lt;/code&gt;-backed Flash Attention implementation that works natively on HIP without a separate package. If you're on the 2.1.2 base image, plan for the source build or consider upgrading the PyTorch layer inside the container before starting fine-tuning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Check which attention backend is actually being used
# Add this to your model init code to verify
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;transformers.utils&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;is_flash_attn_2_available&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;is_flash_attn_2_available&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;# If False and you expected True, your flash-attn install is the CUDA wheel
# Uninstall and build from source:
&lt;/span&gt;&lt;span class="n"&gt;pip&lt;/span&gt; &lt;span class="n"&gt;uninstall&lt;/span&gt; &lt;span class="n"&gt;flash&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;attn&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;
&lt;span class="c1"&gt;# Then clone https://github.com/Dao-AILab/flash-attention and build with HIP env set
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Data Pipeline: Rendering Blockchain Graphs as Training Images
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Graph-to-Image Rendering Choices Matter More Than You'd Think
&lt;/h3&gt;

&lt;p&gt;The rendering pipeline is where most blockchain graph classification projects quietly lose performance before training even starts. NetworkX + Matplotlib at 512×512 is the obvious starting point — it's three lines of code and handles graphs up to a few hundred nodes without complaint. The problem shows up at scale: edge weights get aliased into visual noise, high-degree nodes collapse into black blobs, and graphs with 500+ edges become indistinguishable smears that even a human analyst can't read. If the image is ambiguous to a human, it's going to be rough for a vision model too.&lt;/p&gt;

&lt;p&gt;Switching to force-directed layouts — specifically Gephi's ForceAtlas2 exported as SVG, then rasterized to 1024×1024 with &lt;code&gt;cairosvg&lt;/code&gt; or Inkscape's headless CLI — improved class separability in early evaluation runs noticeably enough that it changed the training priority. The SVG intermediate step matters: you get lossless geometry at any export resolution, and you can tune node sizing, edge opacity, and color encoding in the Gephi config before rasterization rather than fighting Matplotlib's layout engine. The command that ends up in the pipeline looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Headless Inkscape SVG → PNG at 1024×1024&lt;/span&gt;
inkscape &lt;span class="nt"&gt;--export-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;png &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="nt"&gt;--export-filename&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;output.png &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="nt"&gt;--export-width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1024 &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="nt"&gt;--export-height&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1024 &lt;span class="se"&gt;\&lt;/span&gt;
         graph.svg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For graphs that need to stay fully automated (no Gephi GUI), &lt;code&gt;graphviz&lt;/code&gt; with the &lt;code&gt;sfdp&lt;/code&gt; engine via the Python &lt;code&gt;graphviz&lt;/code&gt; package handles force-directed layout programmatically and produces cleaner SVGs than NetworkX's spring layout at equivalent node counts. The trade-off: &lt;code&gt;sfdp&lt;/code&gt; layout is slower per graph than NetworkX's &lt;code&gt;spring_layout&lt;/code&gt;, and for batch jobs rendering thousands of graphs overnight that runtime adds up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Label Schema: The Visual Distinction Problem
&lt;/h3&gt;

&lt;p&gt;The four-class schema — normal transfer cluster, mixer/tumbler topology, exchange hot-wallet fan-out, DeFi contract interaction web — was chosen because the graph structures are visually distinct when rendered correctly, not just semantically distinct. That second constraint is actually the harder one. A mixer/tumbler topology has a characteristic many-to-one-to-many structure with high betweenness centrality on a small set of intermediate nodes. A hot-wallet fan-out from an exchange looks like a star graph with high out-degree from one central node. A DeFi interaction web has dense triangle-clustering from contract addresses. If your rendering choices flatten those structural differences — same node color for all address types, no edge-weight encoding, poor contrast — you're asking the model to learn from visually homogeneous images with heterogeneous labels, which is a recipe for a model that memorizes rather than generalizes.&lt;/p&gt;

&lt;p&gt;Encoding node type in color (EOA addresses vs. contract addresses vs. exchange-tagged addresses) and edge weight in line thickness before export adds meaningful signal the model can actually use. This requires your graph objects to carry those attributes. In NetworkX:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;networkx&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;

&lt;span class="n"&gt;G&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DiGraph&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;# node_type: 'eoa', 'contract', 'exchange'
&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0xABC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exchange&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#e74c3c&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0xABC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0xDEF&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Pass attributes through to the draw call
&lt;/span&gt;&lt;span class="n"&gt;node_colors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;color&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#3498db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
&lt;span class="n"&gt;edge_widths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;width&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;

&lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;draw_spring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node_color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;node_colors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;edge_widths&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Dataset Format for ms-swift Fine-Tuning
&lt;/h3&gt;

&lt;p&gt;Qwen2-VL fine-tuning via ms-swift expects JSON-L where each line is a self-contained training example. The two keys that matter are &lt;code&gt;image&lt;/code&gt; and &lt;code&gt;conversations&lt;/code&gt;. The &lt;code&gt;image&lt;/code&gt; field accepts a local path, a URL, or a base64-encoded string. Local paths sound convenient but have a real gotcha: ms-swift resolves paths relative to the working directory at launch time, and if you restart training from a checkpoint in a different shell or Docker container, relative paths silently resolve to the wrong location and you get a dataset that appears to load but actually feeds the model blank images. Absolute paths sidestep this entirely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/data/blockchain_graphs/train/mixer_0041.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"conversations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;Classify the transaction graph topology shown."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mixer_tumbler"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/data/blockchain_graphs/train/hotspot_0012.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"conversations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;Classify the transaction graph topology shown."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"exchange_hot_wallet_fanout"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Base64 encoding is stable across restarts but bloats the JSON-L file size considerably — a 1024×1024 PNG that's 180KB on disk becomes roughly 240KB of base64 text per line, and for a training set of tens of thousands of images that matters for I/O throughput during training. Absolute local paths with a mounted volume or a consistent directory convention are the practical choice for anything beyond a quick prototype.&lt;/p&gt;

&lt;h3&gt;
  
  
  Train/Val Split Discipline: The Leakage Nobody Catches Until It's Too Late
&lt;/h3&gt;

&lt;p&gt;Splitting blockchain graph images naively by shuffling and taking 80/20 will give you inflated validation accuracy — expect 10–15 points above what you'll see on held-out real data — because graphs generated from the same wallet cluster share address nodes. A wallet that appears in a training graph almost certainly appears in some validation graph, and the model learns to recognize address-level features rather than topological patterns. This is the blockchain equivalent of patient data leakage in medical imaging: the split looks correct by file count but isn't correct by independence.&lt;/p&gt;

&lt;p&gt;The fix is to split at the cluster level, not the image level. Before rendering, assign each wallet cluster a unique cluster ID, then do your train/val split on cluster IDs. Every image derived from cluster &lt;code&gt;wc_00441&lt;/code&gt; goes to train; every image from &lt;code&gt;wc_00812&lt;/code&gt; goes to val. This means your val set might have slightly different class balance than train — that's real, and you want to know about it. A minimal implementation using pandas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.model_selection&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GroupShuffleSplit&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;graph_metadata.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# columns: image_path, label, cluster_id
&lt;/span&gt;
&lt;span class="n"&gt;splitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GroupShuffleSplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_splits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;test_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;train_idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;val_idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;splitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cluster_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;

&lt;span class="n"&gt;train_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iloc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;train_idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;val_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iloc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;val_idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Verify no cluster_id overlap
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;train_df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cluster_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val_df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cluster_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That assertion at the end isn't optional. Run it every time you regenerate the split. Address graph datasets have a way of accumulating new images from existing clusters as you expand the dataset, and without the check you'll quietly reintroduce leakage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fine-Tuning Loop: LoRA on 7B vs Full-Parameter on 7B
&lt;/h2&gt;

&lt;p&gt;The most counterintuitive finding from running this fine-tune: LoRA on Qwen2-VL is a trap if you only target the language decoder. Every tutorial and most of the Hugging Face examples show LoRA applied to &lt;code&gt;q_proj&lt;/code&gt;, &lt;code&gt;v_proj&lt;/code&gt;, &lt;code&gt;k_proj&lt;/code&gt;, &lt;code&gt;o_proj&lt;/code&gt; in the language model layers — and that's fine for text tasks. For visual classification on structured graph images (transaction graphs, address clustering diagrams, flow charts of on-chain activity), it leaves 8-12 accuracy points on the table compared to full fine-tuning. The visual encoder's cross-attention layers need to be in scope. Once I added those target modules, the LoRA run started producing logit distributions that actually separated graph classes instead of hedging everything toward the majority label.&lt;/p&gt;

&lt;p&gt;The LoRA config that closed most of that gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# passed to PEFT's LoraConfig
&lt;/span&gt;&lt;span class="n"&gt;lora_rank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;
&lt;span class="n"&gt;lora_alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;          &lt;span class="c1"&gt;# alpha/rank = 2.0; higher ratios tend to overfit fast on small graph datasets
&lt;/span&gt;&lt;span class="n"&gt;lora_dropout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;
&lt;span class="n"&gt;bias&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;target_modules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;# language decoder attention
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model.layers.*.self_attn.q_proj&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model.layers.*.self_attn.k_proj&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model.layers.*.self_attn.v_proj&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model.layers.*.self_attn.o_proj&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# visual encoder cross-attention — this is the part most examples skip
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;visual.blocks.*.attn.proj&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;visual.blocks.*.attn.qkv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;task_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CAUSAL_LM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact module names depend on the version of &lt;code&gt;Qwen2-VL-7B-Instruct&lt;/code&gt; you're loading — run &lt;code&gt;model.named_modules()&lt;/code&gt; and grep for &lt;code&gt;attn&lt;/code&gt; inside the &lt;code&gt;visual&lt;/code&gt; prefix to confirm paths before you commit to a config. With rank 64 and those targets, the trainable parameter count sits around 2% of total, and HBM consumption drops to roughly 40-45GB versus the ~120GB you're looking at for full-parameter bf16 training with batch size 4 and gradient checkpointing. That 120GB number is reproducible: full-param at bf16, batch 4, 1024×1024 inputs, checkpointing on — you'll see the MI300X sitting comfortable but not spacious. Bump batch to 8 and you're flirting with OOM even with checkpointing.&lt;/p&gt;

&lt;p&gt;Learning rate is where Qwen2-VL burned me the first two runs. The ms-swift defaults push the peak LR to 5e-5, which is fine for the language head but aggressive for the visual tower. The visual encoder's weights are already well-conditioned for image understanding; slamming them with a high LR early in training causes the cross-attention representations to degrade before the language side has caught up. A warm-up that starts at 1e-6, peaks at 2e-5 at roughly 10% of total steps, then applies cosine decay over 3 epochs was meaningfully more stable — validation loss stopped spiking at epoch boundaries. You can set this in a standard HuggingFace Trainer config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;training_args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TrainingArguments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;learning_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2e-5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;warmup_ratio&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lr_scheduler_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cosine&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;num_train_epochs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;per_device_train_batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;gradient_checkpointing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bf16&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# ROCm on MI300X: set this or you get silent NCCL-equivalent hangs
&lt;/span&gt;    &lt;span class="n"&gt;dataloader_num_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Throughput is what it is. Full-parameter training on MI300X at 1024×1024 input lands between 2.8 and 3.4 images per second depending on graph complexity and whether the tokenizer is batching efficiently. LoRA gets you to around 4.5 img/s — a real throughput win. But for this particular task, structured blockchain graph images with fine-grained topology differences between graph classes, the accuracy gap is meaningful enough that you'd need a substantial dataset size advantage to justify LoRA. If your labeled dataset is smaller than a few thousand samples, the full-parameter run at the slower throughput will likely converge to better validation accuracy in fewer wall-clock hours anyway, because you're not running as many epochs to compensate for the LoRA accuracy floor.&lt;/p&gt;

&lt;h2&gt;
  
  
  ROCm-Specific Failure Modes and How to Diagnose Them
&lt;/h2&gt;

&lt;p&gt;The NaN loss failure is the most disorienting one because it looks like a data or learning rate problem. Training runs clean for anywhere from 50 to 200 steps, then loss spikes to NaN and everything after is garbage. The actual culprit is almost always a Flash Attention wheel that was compiled against CUDA running silently on a ROCm stack — no import error, no warning, just wrong math. The check is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import flash_attn; print(flash_attn.__file__)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that path points into a &lt;code&gt;site-packages/flash_attn&lt;/code&gt; directory that came from a PyPI CUDA wheel rather than an AMD-compiled artifact or the ROCm-patched fork at &lt;a href="https://github.com/ROCm/flash-attention" rel="noopener noreferrer"&gt;github.com/ROCm/flash-attention&lt;/a&gt;, you have the wrong binary loaded. The fix is to uninstall the CUDA wheel and build or install the ROCm variant explicitly. Until you do, disabling Flash Attention entirely with &lt;code&gt;TRANSFORMERS_ATTENTION_IMPLEMENTATION=eager&lt;/code&gt; at least gives you stable (if slower) training to confirm everything else is sane.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;hipErrorOutOfMemory&lt;/code&gt; mid-epoch problem is nastier because &lt;code&gt;rocm-smi&lt;/code&gt; will show what looks like available HBM headroom at the moment training dies. This isn't a measurement lie — it's allocator fragmentation. The HIP allocator carves up memory in a way that leaves enough free pages in aggregate but not a single contiguous block of the size being requested. Setting the following environment variable before launching training resolves this in most cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PYTORCH_HIP_ALLOC_CONF&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;expandable_segments:True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This flag tells the allocator to grow segments incrementally rather than reserving fixed-size pools, which sidesteps the fragmentation pattern that emerges during multi-resolution image batches — exactly the workload you hit with Qwen2-VL on blockchain graph images of varying sizes. Add it to your training launch script or your Docker environment block, not just a shell session, or it will disappear on the next subprocess fork.&lt;/p&gt;

&lt;p&gt;Gradient checkpointing with &lt;code&gt;torch.compile&lt;/code&gt; deserves its own section in any ROCm fine-tuning guide because the combination fails in a way the error message doesn't explain. On ROCm 6.1, enabling &lt;code&gt;torch.compile&lt;/code&gt; alongside gradient checkpointing on a model receiving dynamic image shapes triggers graph capture failures — the compiler tries to trace a static computation graph and hits recompilation loops or outright segfaults when image tensor shapes change between batches. You have two exits: drop &lt;code&gt;torch.compile&lt;/code&gt; entirely (gradient checkpointing alone still meaningfully reduces HBM pressure), or pad all input images to a fixed spatial size before the forward pass so the compiled graph stays static. Fixed padding to something like 1120×1120 before batching is the lower-friction option and doesn't require touching the model code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pad to fixed size before collation
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch.nn.functional&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pad_to_fixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tensor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1120&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# assumes tensor shape [C, H, W]
&lt;/span&gt;    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;th&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tensor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tensor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;tw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;th&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For anyone moving to multi-node MI300X training, RCCL (ROCm's NCCL equivalent) will silently stall collective operations rather than erroring out when the network configuration isn't explicit. InfiniBand is usually absent in on-prem GPU clusters that aren't HPC-grade, and RCCL's defaults assume it might be present. Without the correct interface hint, &lt;code&gt;AllReduce&lt;/code&gt; ops just hang at a barrier and your training looks frozen with no useful log output. The two environment variables that fix this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;NCCL_SOCKET_IFNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0   &lt;span class="c"&gt;# replace with your actual interface name&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;NCCL_IB_DISABLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1         &lt;span class="c"&gt;# forces RCCL to use socket transport&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify your interface name with &lt;code&gt;ip link show&lt;/code&gt; — on some AMD server boards it comes up as &lt;code&gt;ens3&lt;/code&gt; or &lt;code&gt;enp65s0f0&lt;/code&gt; rather than &lt;code&gt;eth0&lt;/code&gt;. Setting &lt;code&gt;NCCL_DEBUG=INFO&lt;/code&gt; alongside these will dump transport negotiation to stderr, which is the fastest way to confirm RCCL actually connected over the socket backend rather than timing out waiting for an IB device that isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inference Serving: vLLM on ROCm for Classification Throughput
&lt;/h2&gt;

&lt;p&gt;The single most useful thing vLLM 0.4.x brought to AMD hardware isn't the ROCm label on the release notes — it's that the memory manager actually releases allocations between requests instead of holding peak VRAM indefinitely. On MI300X with 192GB HBM3, that sounds irrelevant until you're serving three model types simultaneously and the allocator turns greedy. More on that failure mode at the end, but first: getting a fine-tuned Qwen2-VL-7B checkpoint actually serving.&lt;/p&gt;

&lt;p&gt;The default vLLM launch command will silently fail on non-standard checkpoint directories because the tokenizer-inferred image input path assumes a Hugging Face Hub layout. With a locally fine-tuned checkpoint that's been merged and saved via &lt;code&gt;save_pretrained()&lt;/code&gt;, you need to be explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; vllm.entrypoints.openai.api_server &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; /checkpoints/qwen2-vl-7b-blockchain-v3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dtype&lt;/span&gt; bfloat16 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 4096 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-input-type&lt;/span&gt; pixel_values &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.85 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--port&lt;/span&gt; 8100
&lt;span class="c"&gt;# --image-input-type pixel_values bypasses the tokenizer's&lt;/span&gt;
&lt;span class="c"&gt;# hub-fetching logic; without it you get a cryptic KeyError&lt;/span&gt;
&lt;span class="c"&gt;# on 'image_processor_type' from a missing config.json field&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--max-model-len 4096&lt;/code&gt; matters here because blockchain transaction graphs rendered as images don't benefit from longer context — you're classifying a single graph image, not running a document. Letting vLLM allocate KV cache for 32K tokens wastes the HBM budget you need for batching. At bf16 with that cap, the 7B model sits at roughly 15GB VRAM, leaving room to batch aggressively.&lt;/p&gt;

&lt;p&gt;Latency behavior is where the ROCm path starts paying off operationally. Single-image classification requests hit roughly 420ms end-to-end — that's from HTTP POST to parsed JSON response, including image encoding overhead on the client side. Batching 8 images in a single request drops per-image time to around 110ms, which is the threshold that makes near-real-time transaction monitoring viable without a dedicated GPU per pipeline stage. The prompt structure that produces reliable output without constrained decoding looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data:image/png;base64,&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;b64_graph&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Classify this blockchain transaction graph. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Respond with a JSON object only, no prose. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fields: class (one of: normal, mixing, layering, smurfing) &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;and confidence (float 0.0-1.0). Example: {&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;class&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;normal&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;: 0.94}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Qwen2-VL's instruction-following holds up for a 4-class problem without needing constrained decoding or grammar samplers. The model respects the JSON-only instruction reliably enough for production parsing — a &lt;code&gt;json.loads()&lt;/code&gt; on the first code block extracted from the response works without a retry loop in practice. If you push to 8+ classes or the class boundaries are semantically close, that reliability degrades and you'd want to add constrained decoding via outlines or a logit processor.&lt;/p&gt;

&lt;p&gt;The architecture warning for mixed-workload operators is blunt: don't run code generation, embedding models, and vision classification under the same vLLM process on ROCm yet. The model-parallel serving and the ROCm memory manager interact badly when you switch between model types mid-session — observed symptom is VRAM fragmentation that accumulates across requests and doesn't compact, eventually causing OOM on the vision model even though &lt;code&gt;rocm-smi&lt;/code&gt; shows available headroom. The fragmentation lives in the allocator's free list, not reported VRAM. Until vLLM's ROCm path matures (watch the &lt;code&gt;rocm&lt;/code&gt; label on the vLLM GitHub), the stable architecture is dedicated serving processes: one vLLM instance for the vision classifier, a separate Ollama instance for code/chat, and bge-m3 embeddings in its own container. More overhead to manage, but zero mystery OOMs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verdict: When This Setup Makes Sense and When It Doesn't
&lt;/h2&gt;

&lt;p&gt;The honest answer is that Qwen2-VL fine-tuning on MI300X for blockchain graph classification is a narrow fit — but when the fit is right, nothing else in the local-model stack gets close. The approach earns its keep when the classification target is &lt;em&gt;inherently visual&lt;/em&gt;: topology patterns, cluster density, hub-and-spoke structures, layered mixers that show up in rendered graph images but are lossy to encode as adjacency features. If a human analyst would look at the graph image and immediately recognize the pattern class, that's a signal the visual encoder is the right inductive bias. Pair that with a labeled dataset in the hundreds to low thousands of examples and you're in the sweet spot — enough signal to adapt the model's attention to your schema, not so much that you'd rather train a GNN from scratch and be done with it.&lt;/p&gt;

&lt;p&gt;The cost calculus flips hard if you're renting MI300X instances. At data-center GPU rental rates, fine-tuning a 7B vision-language model to do a job a three-layer GNN could handle in an afternoon on a CPU cluster is genuinely hard to justify. The MI300X angle only makes sense if that hardware is already in your stack — an on-prem node, a shared inference box, an existing cluster allocation. Spinning up cloud MI300X time specifically for this is an expensive way to avoid learning PyTorch Geometric. Similarly, if your labels map cleanly onto graph features — transaction volume, in-degree, hop distance to known flagged addresses — a GNN will train faster, run faster, and produce feature importances you can actually explain to a non-ML audience. Visual classification is opaque by comparison.&lt;/p&gt;

&lt;p&gt;On latency: don't expect sub-50ms per image from this pipeline at full precision. Even with the visual encoder cached and the classification head warmed, inference on a single graph image runs in the hundreds of milliseconds under realistic load. If your pipeline needs real-time scoring on a transaction stream, this architecture requires either aggressive batching, AWQ quantization of the fine-tuned checkpoint, or rethinking whether you need the VLM at all. The ROCm constraint is also a hard floor — below ROCm 6.1, the Flash Attention 2 path for Qwen2-VL doesn't work cleanly, and the fallback attention implementation makes VRAM usage unpredictable enough that training stability suffers on long sequences.&lt;/p&gt;

&lt;p&gt;What the Qwen2-VL visual encoder actually does well here — better than CLIP-based models on structured diagrams — is handling the higher-resolution input without aggressive center-crop assumptions. Graph images with small peripheral nodes survive the encoding step with enough spatial fidelity that the fine-tuned classification head can learn meaningful topology signals. CLIP-derived encoders tend to smear those peripheral features. For a broader picture of where vision-capable models fit alongside code-generation and agent tooling, the &lt;a href="https://techdigestor.com/best-ai-coding-tools-2026/" rel="noopener noreferrer"&gt;AI Coding Tools in 2026: Cloud Copilots vs Local Models&lt;/a&gt; guide covers the tradeoff surface in more depth.&lt;/p&gt;

&lt;p&gt;Two follow-on experiments are worth running before calling this pipeline production-ready. First, quantize the fine-tuned checkpoint to AWQ-4bit — &lt;code&gt;autoawq&lt;/code&gt; handles Qwen2-VL without patching as of version 0.2.x, and the accuracy drop on a balanced classification task is usually small enough to accept in exchange for roughly 2× throughput improvement and a meaningful VRAM reduction. Second, and more interesting: the soft-label outputs from the VLM classifier can be used as training signal for a lightweight GNN. You label your unlabeled graph corpus with the VLM, train a GNN on those soft labels, and end up with a fast, interpretable model that inherited the visual encoder's structural intuitions. That distillation path is underexplored in the blockchain analytics space and likely to outperform either model alone on out-of-distribution transaction patterns.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/fine-tuning-qwen2-vl-for-blockchain-graph-classification-on-amd-mi300x-what-the-docs-dont-tell-you/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>productivity</category>
      <category>tools</category>
    </item>
    <item>
      <title>Fine-Tuning Qwen2-VL on AMD MI300X for Blockchain Graph Classification: What the Docs Don't Tell You</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Tue, 14 Jul 2026 18:35:00 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/fine-tuning-qwen2-vl-on-amd-mi300x-for-blockchain-graph-classification-what-the-docs-dont-tell-you-2nck</link>
      <guid>https://dev.to/ericwoooo_kr/fine-tuning-qwen2-vl-on-amd-mi300x-for-blockchain-graph-classification-what-the-docs-dont-tell-you-2nck</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Graph visualizations of blockchain transactions contain structural signals that are essentially invisible to embedding-based classifiers.  A peel-chain mixer produces a distinctive fan-out pattern — one input, many outputs at near-identical amounts — that shows up immediately as &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~23 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem: Blockchain Security Needs Visual Reasoning, Not Just Embeddings&lt;/li&gt;
&lt;li&gt;Hardware and Software Stack: What You Actually Need Before Starting&lt;/li&gt;
&lt;li&gt;Preparing the Blockchain Graph Dataset for Vision Fine-Tuning&lt;/li&gt;
&lt;li&gt;Fine-Tuning with LoRA on MI300X: Config, Commands, and Failure Modes&lt;/li&gt;
&lt;li&gt;Inference: Serving the Fine-Tuned Checkpoint with vLLM on ROCm&lt;/li&gt;
&lt;li&gt;Real Results and Honest Trade-offs&lt;/li&gt;
&lt;li&gt;Operationalizing the Pipeline: Keeping It Running&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem: Blockchain Security Needs Visual Reasoning, Not Just Embeddings
&lt;/h2&gt;

&lt;p&gt;Graph visualizations of blockchain transactions contain structural signals that are essentially invisible to embedding-based classifiers. A peel-chain mixer produces a distinctive fan-out pattern — one input, many outputs at near-identical amounts — that shows up immediately as a visual motif but gets washed out when you flatten the graph to a feature vector. Loop patterns indicating layering attacks, tight clusters suggesting coordinated wallet activity, the asymmetric star shapes of certain exchange deposit flows: these read as geometry. Text models process token sequences; they have no native concept of spatial adjacency. If your rendered transaction graphs look different from each other in ways a human analyst can spot in two seconds, a vision model will likely exploit exactly those differences.&lt;/p&gt;

&lt;p&gt;The reason to reach for Qwen2-VL rather than a dedicated Graph Neural Network comes down to operational pragmatism. A GNN requires a separate graph construction pipeline, its own training loop, and it produces a classification label — nothing else. You cannot ask it to explain &lt;em&gt;why&lt;/em&gt; a transaction cluster matches a known mixing pattern. Qwen2-VL gives you classification and a natural-language rationale from a single checkpoint, which matters when you need audit trails or when an analyst needs to review borderline cases. The other factor is that rendered images already exist in many blockchain analytics pipelines — Gephi exports, custom NetworkX renders, even screenshots from block explorers. Reusing those artifacts means skipping graph serialization entirely and going straight to fine-tuning on data you already have.&lt;/p&gt;

&lt;p&gt;The MI300X changes the constraint calculus significantly. The 192 GB HBM3 unified memory pool means Qwen2-VL-7B (roughly 16–18 GB in BF16) and even the 72B variant (around 145 GB in BF16) load without tensor offloading to system RAM or NVMe. That matters because offloading kills inference latency in ways that are hard to tune around — you stop fighting VRAM budgets and start focusing on actual throughput. The catch is ROCm. AMD's ROCm stack has improved substantially, but before committing this architecture to production you need to audit whether your specific ROCm version (6.x as of mid-2025) has stable support for the flash-attention kernels and vision encoder ops that Qwen2-VL depends on. The &lt;code&gt;transformers&lt;/code&gt; integration works, but flash-attention-2 on ROCm requires the &lt;code&gt;flash-attn&lt;/code&gt; ROCm fork, not the CUDA build, and that distinction is not prominently documented in the Qwen2-VL model card.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Verify ROCm sees your MI300X before anything else&lt;/span&gt;
rocm-smi &lt;span class="nt"&gt;--showproductname&lt;/span&gt;
&lt;span class="c"&gt;# Expected: MI300X with 192 GB listed under VRAM&lt;/span&gt;

&lt;span class="c"&gt;# Check that PyTorch ROCm build is actually using HIP, not falling back&lt;/span&gt;
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import torch; print(torch.version.hip); print(torch.cuda.get_device_name(0))"&lt;/span&gt;
&lt;span class="c"&gt;# If hip version is None, you have the wrong torch wheel installed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A common failure mode here is installing the standard &lt;code&gt;torch&lt;/code&gt; CUDA wheel via pip on an MI300X host and having it silently "work" through ROCm's CUDA compatibility shim — until you hit an op that the shim doesn't cover and get a cryptic kernel launch error mid-training. Install the ROCm-specific wheel explicitly: &lt;code&gt;torch==2.3.0+rocm6.0&lt;/code&gt; from AMD's index at &lt;code&gt;https://download.pytorch.org/whl/rocm6.0&lt;/code&gt;. Verify HIP is non-null before touching any model code. For readers evaluating where vision-language fine-tuning fits in a broader local AI stack, see our guide on &lt;a href="https://techdigestor.com/best-ai-coding-tools-2026/" rel="noopener noreferrer"&gt;AI Coding Tools in 2026: Cloud Copilots vs Local Models&lt;/a&gt; — particularly the section on inference hardware, which covers the local-vs-cloud API dependency tradeoff that makes self-hosted fine-tuned checkpoints worth the setup cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardware and Software Stack: What You Actually Need Before Starting
&lt;/h2&gt;

&lt;p&gt;The ROCm version check should happen before you create a Python environment, before you pull any model weights, before anything. Qwen2-VL has a hard dependency on flash-attention-2, and the ROCm-compatible flash-attn wheel only became reliably usable on MI300X with ROCm 6.1. Earlier versions either failed to compile or produced silently incorrect attention outputs — not a crash you'd catch immediately, just degraded training that looks like a hyperparameter problem. Run this first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Verify before touching your Python env&lt;/span&gt;
rocm-smi &lt;span class="nt"&gt;--showdriverversion&lt;/span&gt;

&lt;span class="c"&gt;# Expected output on a correctly configured MI300X:&lt;/span&gt;
&lt;span class="c"&gt;# ROCm version: 6.1.x&lt;/span&gt;
&lt;span class="c"&gt;# If you see 5.7 or earlier, stop and update the ROCm stack first&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PyTorch install is not the standard CUDA path with a different flag — the ROCm ABI matters. The nightly ROCm 6.1 build is what gives you working &lt;code&gt;torch.bfloat16&lt;/code&gt; on MI300X memory controllers and the correct HIP kernel dispatch for flash-attn. The full stack that actually holds together for Qwen2-VL fine-tuning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install PyTorch with ROCm 6.1 ABI — do not use the default index&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;torch torchvision &lt;span class="nt"&gt;--index-url&lt;/span&gt; https://download.pytorch.org/whl/rocm6.1

&lt;span class="c"&gt;# Transformers must be &amp;gt;= 4.45.0; earlier builds are missing Qwen2-VL processor logic&lt;/span&gt;
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"transformers&amp;gt;=4.45.0"&lt;/span&gt; accelerate

&lt;span class="c"&gt;# Install ROCm flash-attn from the AMD fork, not the Tri Dao original&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;flash-attn &lt;span class="nt"&gt;--no-build-isolation&lt;/span&gt;
&lt;span class="c"&gt;# If build fails, confirm HIP_PATH is set: echo $HIP_PATH should return /opt/rocm&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On VRAM: Qwen2-VL-7B weights in bf16 land around 15 GB. That sounds comfortable on MI300X's 192 GB HBM3, but the training footprint is a different number entirely. Optimizer states under AdamW add roughly 2× the parameter footprint, and a batch of graph images at 448×448 — especially multi-frame inputs with the dynamic resolution preprocessing Qwen2-VL uses — pushes total allocation to 40–60 GB per process easily. This is the range where MI300X runs without drama, but a 32 GB consumer card would need aggressive gradient checkpointing, probably &lt;code&gt;activation_checkpointing=True&lt;/code&gt; in your accelerate config, and batch size 1 with gradient accumulation. Not impossible, but expect 40–50% throughput loss versus running it uncompressed.&lt;/p&gt;

&lt;p&gt;The Docker baseline that keeps ROCm from polluting your host Python is non-negotiable in a shared or iterative setup. AMD's official image gives you a known-good starting point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Dockerfile excerpt for reproducible MI300X fine-tuning environment&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; rocm/pytorch:rocm6.1_ubuntu22.04_py3.10_pytorch_2.3.0&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /workspace&lt;/span&gt;

&lt;span class="c"&gt;# Pin everything — loose requirements.txt will bite you when transformers releases break Qwen2-VL processor&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="c"&gt;# Mount your dataset volume at runtime, not baked into the image&lt;/span&gt;
&lt;span class="c"&gt;# docker run --device=/dev/kfd --device=/dev/dri \&lt;/span&gt;
&lt;span class="c"&gt;#   -v /your/dataset/path:/workspace/data \&lt;/span&gt;
&lt;span class="c"&gt;#   -v /your/checkpoints:/workspace/checkpoints \&lt;/span&gt;
&lt;span class="c"&gt;#   your-image:tag&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# requirements.txt — pin these or accept breakage
&lt;/span&gt;&lt;span class="py"&gt;transformers&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;=4.45.2&lt;/span&gt;
&lt;span class="py"&gt;accelerate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;=0.34.2&lt;/span&gt;
&lt;span class="py"&gt;datasets&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;=2.20.0&lt;/span&gt;
&lt;span class="py"&gt;peft&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;=0.12.0&lt;/span&gt;
&lt;span class="py"&gt;Pillow&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;=10.4.0&lt;/span&gt;
&lt;span class="py"&gt;einops&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;=0.8.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing the AMD documentation glosses over: the &lt;code&gt;--device=/dev/kfd&lt;/code&gt; and &lt;code&gt;--device=/dev/dri&lt;/code&gt; flags are both required for GPU access inside the container — omitting either produces an unhelpful "no GPU found" error that looks like a ROCm install problem. Also mount &lt;code&gt;/dev/shm&lt;/code&gt; with a generous size limit if you're doing multi-worker DataLoader prefetching of graph images; the default 64 MB will cause silent hangs on larger batches. Use &lt;code&gt;--shm-size=16g&lt;/code&gt; as a starting point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preparing the Blockchain Graph Dataset for Vision Fine-Tuning
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The rendering pipeline is where most fine-tuning attempts quietly fail
&lt;/h3&gt;

&lt;p&gt;Before a single gradient updates your weights, the visual encoding decisions you make here will determine whether the model learns blockchain topology or learns your Matplotlib defaults. The core insight: Qwen2-VL processes images as 448×448 tile patches, so rendering at exactly that resolution eliminates any interpolation artifact that would otherwise become a spurious training signal. Every subgraph gets rendered to a PNG at 448×448 — no upscaling, no padding, no "close enough." The model will learn whatever is consistent across your training images, and if your rendering pipeline has any non-determinism (random layout seeds, auto-scaling axes), it will learn that instead of the graph structure you care about.&lt;/p&gt;

&lt;p&gt;Node color and edge weight are your two semantic channels, and they need to be locked down rigidly. Here's the rendering function I use as a baseline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;networkx&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;matplotlib&lt;/span&gt;
&lt;span class="n"&gt;matplotlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Agg&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# no display needed, avoids threading issues on headless servers
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;matplotlib.pyplot&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;matplotlib.cm&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cm&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="n"&gt;ENTITY_COLORS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;exchange&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#2196F3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# blue — high-volume custodial
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mixer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#F44336&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# red — obfuscation node
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;contract&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#FF9800&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# orange — smart contract
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#9E9E9E&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# grey — unclassified
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render_subgraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DiGraph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subplots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;figsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;4.48&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.48&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 448x448 at 100 DPI
&lt;/span&gt;    &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_aspect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;equal&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;off&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Fixed seed is non-negotiable — layout drift = phantom training signal
&lt;/span&gt;    &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spring_layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;node_colors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ENTITY_COLORS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;entity_type&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#9E9E9E&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                   &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;

    &lt;span class="c1"&gt;# Normalize edge weights to [0.5, 4.0] px width — raw BTC values span too many orders of magnitude
&lt;/span&gt;    &lt;span class="n"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;volume_btc&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;widths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;3.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log1p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log1p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;widths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;draw_networkx_nodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node_color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;node_colors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;draw_networkx_edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;widths&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;arrows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="n"&gt;arrowsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edge_color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#555555&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;savefig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bbox_inches&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tight&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;facecolor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;white&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pad_inches&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;log1p&lt;/code&gt; normalization on edge weights matters more than it looks. Raw BTC transaction volumes span around six orders of magnitude — a dust attack sending 0.000001 BTC versus an exchange moving 500 BTC. Without log scaling, the visual difference between a 1 BTC and a 2 BTC edge is invisible, but a 0.001 BTC edge versus a 500 BTC edge would produce wildly different line widths and visually dominate the layout. Log-normalize, then linearly scale into a visible pixel range, and keep those bounds fixed across your entire dataset.&lt;/p&gt;

&lt;h3&gt;
  
  
  Label schema: resist multiclass until you have the data to support it
&lt;/h3&gt;

&lt;p&gt;Binary classification — suspicious vs. benign — is the right starting point, and not just because it's simpler. The more important reason is data budgeting. Multiclass with four categories (mixer cluster, exchange cluster, normal transfer, dusting attack) means you need enough samples per class that the model sees meaningful intra-class variance, not just memorizes a handful of examples. The practical floor before loss curves stop oscillating erratically is roughly 500 labeled images per class. Below that, you'll see validation loss flatten early and then diverge — a sign the model is pattern-matching on class-specific rendering quirks rather than topology. If you're sourcing labeled data from a public blockchain analytics dataset or labeling it yourself from known addresses, expect to hit that 500/class floor to be expensive. Start binary, validate the pipeline works, then expand.&lt;/p&gt;

&lt;h3&gt;
  
  
  Formatting samples for Qwen2-VL's chat template
&lt;/h3&gt;

&lt;p&gt;Qwen2-VL expects a specific messages structure, and the processor does the heavy lifting — but only if you hand it the right shape. Each training example is a JSON object where the user turn embeds the image via &lt;code&gt;&amp;lt;|image_pad|&amp;gt;&lt;/code&gt; placeholder and the assistant turn contains the label plus a one-sentence rationale. The rationale isn't just label smoothing theater — it forces the model to produce an intermediate token sequence that anchors the classification to specific visual evidence, which measurably reduces confident-wrong predictions on out-of-distribution graphs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"file:///data/graphs/subgraph_0041.png"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Classify this blockchain transaction subgraph as SUSPICIOUS or BENIGN. Respond with the label followed by a one-sentence rationale."&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SUSPICIOUS: The subgraph contains a high-degree red mixer node receiving inputs from multiple low-value unknown nodes in a fan-in pattern consistent with coin mixing."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;AutoProcessor&lt;/code&gt; from &lt;code&gt;transformers&lt;/code&gt; handles tokenization and image patch embedding when you call &lt;code&gt;processor(messages, images=..., return_tensors='pt')&lt;/code&gt;. One gotcha: if you pass image paths as file URIs in the &lt;code&gt;image&lt;/code&gt; field (as above), the processor resolves them at call time — make sure your training script runs from a context where those paths are valid, or pass PIL Image objects directly to sidestep path resolution entirely. The latter is safer in Docker environments where working directories shift.&lt;/p&gt;

&lt;h3&gt;
  
  
  Train/val split and augmentation strategy
&lt;/h3&gt;

&lt;p&gt;An 80/20 split is fine, but split at the &lt;em&gt;subgraph&lt;/em&gt; level, not the address level — if two subgraphs share a prominent exchange hub node, having one in train and one in val leaks structural information across the split. Group subgraphs by their highest-degree node's address and split the groups. For augmentation: horizontal flip and small rotations (±15°) are safe because the classification signal is topological and orientation-independent. A mixer hub with a fan-in pattern looks equally suspicious upside down. What you must not do is apply color jitter. Node color is your entity-type encoding — randomly shifting hue during augmentation teaches the model that color is noise, which is the opposite of what you want. Keep augmentation structural-only, and keep it light. The graph images are already information-dense; you don't need aggressive augmentation to prevent overfitting as much as you need enough labeled samples per class.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fine-Tuning with LoRA on MI300X: Config, Commands, and Failure Modes
&lt;/h2&gt;

&lt;p&gt;The most counterintuitive finding from running this on the MI300X: freezing the vision encoder and applying LoRA only to the language model layers looks great on the loss curve for the first epoch, then falls apart when the eval set contains graph layouts the encoder hasn't been forced to adapt to. The faster convergence is real — you're not backpropping through the vision tower — but the accuracy ceiling on novel transaction graph topologies is noticeably lower. Fine the vision encoder too. Add the attention projections from both the vision encoder and the LM to your target modules list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;peft&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LoraConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;get_peft_model&lt;/span&gt;

&lt;span class="n"&gt;lora_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoraConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lora_alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# Cover both vision encoder and language model attention layers
&lt;/span&gt;    &lt;span class="c1"&gt;# Skipping vision encoder target modules kills accuracy on novel graph layouts
&lt;/span&gt;    &lt;span class="n"&gt;target_modules&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q_proj&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v_proj&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;k_proj&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;o_proj&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;lora_dropout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bias&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;task_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CAUSAL_LM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_peft_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lora_config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print_trainable_parameters&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;# Expect ~2-4% trainable params of total — if it's &amp;lt;1%, you're missing the vision encoder modules
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The training launch itself uses &lt;code&gt;accelerate&lt;/code&gt; with a DeepSpeed ZeRO-2 config. ZeRO-3 sounds appealing given the MI300X's 192GB HBM3, but it causes tensor shape mismatches with Qwen2-VL's vision encoder under ROCm 6.1 — specifically during the dynamic resolution patching step where the encoder splits images into variable-length token sequences. ZeRO-3's parameter partitioning and that dynamic shape don't agree with each other. Stay on ZeRO-2 until AMD or the Qwen team explicitly documents otherwise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ds_zero2.yaml&lt;/span&gt;
&lt;span class="na"&gt;compute_environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LOCAL_MACHINE&lt;/span&gt;
&lt;span class="na"&gt;deepspeed_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deepspeed_multinode_launcher&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;standard&lt;/span&gt;
  &lt;span class="na"&gt;zero_optimization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
    &lt;span class="na"&gt;allgather_partitions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;reduce_scatter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;overlap_comm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;contiguous_gradients&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;bf16&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;# MI300X handles bf16 natively; fp16 causes overflow on long sequences&lt;/span&gt;
&lt;span class="na"&gt;distributed_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DEEPSPEED&lt;/span&gt;
&lt;span class="na"&gt;num_processes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;  &lt;span class="c1"&gt;# single-node; adjust if you have multi-GPU&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;accelerate launch &lt;span class="nt"&gt;--config_file&lt;/span&gt; ds_zero2.yaml train_qwen2vl.py &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model_name_or_path&lt;/span&gt; Qwen/Qwen2-VL-7B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--lora_r&lt;/span&gt; 64 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--per_device_train_batch_size&lt;/span&gt; 4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--gradient_accumulation_steps&lt;/span&gt; 4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--learning_rate&lt;/span&gt; 2e-4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--num_train_epochs&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;RuntimeError: CUDA error: an illegal memory access was encountered&lt;/code&gt; on MI300X is almost never what the message implies. The actual cause, in every case I've hit it, is a flash-attention binary compiled against CUDA that got pulled into the environment alongside the ROCm wheel — usually because pip's cache has a CUDA-compiled &lt;code&gt;flash_attn&lt;/code&gt; from a previous environment and silently reuses it. Check before you waste an hour staring at NCCL logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import flash_attn; print(flash_attn.__version__)"&lt;/span&gt;
&lt;span class="c"&gt;# Then cross-reference against the ROCm-specific wheel source&lt;/span&gt;
&lt;span class="c"&gt;# The ROCm wheel will have a +rocm suffix in the build metadata&lt;/span&gt;
pip show flash-attn | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; location
&lt;span class="c"&gt;# If the path points to a shared cache dir, nuke it and reinstall explicitly:&lt;/span&gt;
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; flash-attn &lt;span class="nt"&gt;--extra-index-url&lt;/span&gt; https://download.pytorch.org/whl/rocm6.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For monitoring, don't rely on &lt;code&gt;eval_loss&lt;/code&gt; alone. Loss on a classification task where the model outputs a single token label (e.g., &lt;code&gt;"phishing"&lt;/code&gt;, &lt;code&gt;"mixer"&lt;/code&gt;, &lt;code&gt;"normal"&lt;/code&gt;) can drop steadily while accuracy on novel classes stagnates — the model learns to be confidently wrong. Log a custom &lt;code&gt;classification_accuracy&lt;/code&gt; metric by decoding only the first generated token from each eval sample and comparing it against the label token. If accuracy is still below 70% after epoch 1, resist the instinct to tune the learning rate — that's not the bottleneck. The two actual culprits are input image resolution (Qwen2-VL's dynamic resolution means low-res graph images get fewer visual tokens than the graph complexity warrants) and LoRA rank being too low to capture the visual feature shifts. Try bumping to &lt;code&gt;r=128&lt;/code&gt; or increasing the minimum image resolution before touching &lt;code&gt;learning_rate&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inference: Serving the Fine-Tuned Checkpoint with vLLM on ROCm
&lt;/h2&gt;

&lt;p&gt;The merge step before serving is where most people waste an afternoon. vLLM does not support dynamic LoRA adapter injection for multimodal models — the image encoder path bypasses the standard adapter hooks that work fine for text-only models. Merge your LoRA weights into the base model first using &lt;code&gt;peft&lt;/code&gt;'s &lt;code&gt;merge_and_unload()&lt;/code&gt;, then point vLLM at the merged directory. Trying to pass &lt;code&gt;--lora-modules&lt;/code&gt; to a Qwen2-VL checkpoint will either silently ignore the adapter or throw a shape mismatch on the visual encoder weights, depending on which vLLM patch level you're on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Merge LoRA into base weights before serving
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;peft&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PeftModel&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Qwen2VLForConditionalGeneration&lt;/span&gt;

&lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Qwen2VLForConditionalGeneration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Qwen/Qwen2-VL-7B-Instruct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;torch_dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bfloat16&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;device_map&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cpu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# merge on CPU to avoid VRAM pressure during export
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PeftModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./lora_checkpoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge_and_unload&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./merged_checkpoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Then serve — vLLM &amp;gt;= 0.5.0 required for Qwen2-VL support
&lt;/span&gt;&lt;span class="n"&gt;vllm&lt;/span&gt; &lt;span class="n"&gt;serve&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;merged_checkpoint&lt;/span&gt; \
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="n"&gt;qwen2_vl&lt;/span&gt; \
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;dtype&lt;/span&gt; &lt;span class="n"&gt;bfloat16&lt;/span&gt; \
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;gpu&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;utilization&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt; \
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="mi"&gt;8000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--gpu-memory-utilization 0.85&lt;/code&gt; flag matters on MI300X because the default 0.90 leaves too little headroom for the KV cache when you're batching image tokens. Qwen2-VL at 448×448 resolution expands to a non-trivial token count before the text decoder even sees it, and the MI300X's 192 GB HBM is generous but the allocator isn't magic — push past 0.88 and you'll hit OOM on batch size 8 mid-request rather than at startup, which is harder to debug.&lt;/p&gt;

&lt;p&gt;Inference requests go to the standard OpenAI-compatible endpoint. The content array format is the part that's underspecified in most vLLM examples — for Qwen2-VL you need &lt;code&gt;image_url&lt;/code&gt; with a base64 data URI, not a remote URL, if you're running air-gapped or want deterministic latency without an outbound fetch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tx_graph.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./merged_checkpoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data:image/png;base64,&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;b64&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Classify this transaction graph. Reply with exactly one label.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# hard cap — model will ramble into explanation text otherwise
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8000/v1/chat/completions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;max_tokens=50&lt;/code&gt; ceiling is non-negotiable for classification use cases. Without it, the fine-tuned model occasionally appends confidence reasoning or chain-of-thought fragments that your downstream parser wasn't built to handle — especially on graphs that visually resemble two different fraud patterns. Setting temperature to 0.0 also eliminates the label-boundary ambiguity where the model outputs "mixer" followed by " (possibly layering)" on the same pass.&lt;/p&gt;

&lt;p&gt;For latency: single-image classification at 448×448 runs 80–120 ms end-to-end on MI300X including image encoding, which is fast enough for real-time mempool screening if you're selective about which transactions get visual analysis. Batching 8 images simultaneously drops per-image latency to 30–45 ms — that's the regime worth targeting when processing historical chain data offline. For fallback, I keep an Ollama instance running the unmodified &lt;code&gt;Qwen2-VL-7B-Instruct&lt;/code&gt; base on the same box. It won't produce domain-specific labels with the same precision as the fine-tuned checkpoint, but it won't invent graph categories either — the base model's vocabulary doesn't include labels like "peel-chain" or "CoinJoin-variant" so it defaults to conservative descriptions rather than hallucinated classifications, which is the safer degradation mode for a security pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real Results and Honest Trade-offs
&lt;/h2&gt;

&lt;p&gt;The most telling improvement after fine-tuning isn't accuracy on clean examples — it's consistency on ambiguous ones. Base Qwen2-VL-7B-Instruct will look at a transaction graph and say something like "this appears to be a network diagram showing interconnected nodes, possibly representing financial flows" — technically correct, practically useless. After fine-tuning on domain-labeled blockchain graph data, the same image gets "mixing_service_pattern: hub-and-spoke topology with high fan-out from central coordinator nodes." That's a structured label plus a structural rationale in one pass. The rationale quality correlates tightly with how many examples of that class the model saw — classes with 500+ samples produce tight, accurate descriptions; classes with 100–200 samples produce correct labels but generic reasoning that could apply to multiple graph types.&lt;/p&gt;

&lt;p&gt;Rare pattern classes are the real pain point. Anything under 50 training examples gets misclassified at a noticeably higher rate, and the failure mode is specific: the model tends to pull the nearest high-confidence class and backfill a rationale that almost fits. A peeling-chain pattern (common in certain coin-join implementations) looks structurally close enough to a mixer topology that the model will confidently label it wrong and write a plausible explanation for why it made that call. This isn't random noise — it's directional bias toward whatever dominated training. The other inherited failure is Qwen2-VL's tendency to describe inferred features rather than visible ones. If your training set had inconsistent graph rendering density — some samples with edge weights normalized, some not — the model will occasionally produce "densely connected subgraph" descriptions for graphs that are visually sparse. It learned the label correlation, not the visual ground truth.&lt;/p&gt;

&lt;p&gt;On the MI300X versus rented A100 question: the hardware capital cost only makes sense if you're running classification continuously against a live data source. A mempool monitoring pipeline that classifies thousands of transaction graphs per hour, 24/7, hits the break-even point much faster than a batch job you run weekly. The practical advantages of on-prem beyond cost are rate limits and latency — no API throttling, no per-token egress fees, and you can co-locate the inference endpoint with the graph rendering pipeline so the round-trip is local. If you're evaluating this right now, check current pricing on AMD's cloud partner pages directly; the numbers move. The real question to ask is: what's your sustained daily volume? If it's bursty or exploratory, rent. If it's a production pipeline with a predictable floor, owned hardware starts looking rational faster than the sticker price suggests.&lt;/p&gt;

&lt;p&gt;The clearest signal to abandon this approach entirely: if your total labeled graph dataset sits under 2,000 samples, a classical GNN with hand-engineered features will outperform a fine-tuned VLM on classification accuracy and cost a fraction of the compute. PyTorch Geometric with a 3-layer GCN on a CPU machine will train in minutes and generalize better on small datasets because it operates on actual graph structure — not a rasterized image of it. VLM fine-tuning earns its place in two specific scenarios: first, when you need the natural-language explanation alongside the label as part of the output (analyst tooling, audit trails, anything that gets read by a human); second, when your graph rendering pipeline already exists and you want a single model that can handle multiple graph types — transaction graphs, contract call graphs, token flow diagrams — without maintaining separate classifiers per type. One fine-tuned VLM covering three graph schemas beats three purpose-built GNNs in operational complexity, assuming your dataset is large enough to make the fine-tuning worth running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operationalizing the Pipeline: Keeping It Running
&lt;/h2&gt;

&lt;p&gt;Most fine-tuning write-ups stop at "the model performs well on the validation set." That's where the real work starts. The checkpoint strategy is the first thing that bites you when a new Qwen2-VL base release drops and you've already merged your LoRA weights into a single blob — you've lost the ability to re-merge cleanly against the updated base. Save adapters only, never merged weights, and name them so you can reconstruct exactly what produced them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;./checkpoints/
  qwen2vl-7b-blockchain-v1/
    adapter_config.json
    adapter_model.safetensors
    dataset_hash.txt        # SHA256 of the training manifest
    training_args.json      # full hyperparams logged at run start
  qwen2vl-7b-blockchain-v2/
    ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;dataset_hash.txt&lt;/code&gt; file is a simple SHA256 over your training manifest — a sorted list of image paths and label files. One command generates it before every run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# run from your data root before launching training&lt;/span&gt;
find ./train_data &lt;span class="nt"&gt;-type&lt;/span&gt; f | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;sha256sum&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ./checkpoints/qwen2vl-7b-blockchain-v&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;/dataset_hash.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When v3 of the base model ships, you pull the new weights, re-merge using the saved adapter, and diff behavior against v2's merged output on your held-out eval set. Without the adapter file, that diff is impossible — you're either re-fine-tuning from scratch or running a base model that's months behind. On the AMD MI300X this re-merge takes under ten minutes for the 7B, so there's no reason to skip it.&lt;/p&gt;

&lt;p&gt;The monitoring signal most people overlook is confidence distribution drift, not accuracy drift. Accuracy requires ground truth; you rarely have that in real-time production. What you &lt;em&gt;do&lt;/em&gt; have is the model's own softmax output. A healthy deployed classifier on blockchain transaction graphs produces a bimodal confidence distribution — most classifications land near 0.9+ (clearly benign) or 0.85+ (clearly suspicious), with a thin middle. When that distribution flattens — when you're seeing a disproportionate volume of scores between 0.45 and 0.65 — the model is genuinely uncertain. That's not a numerical curiosity; it means new graph topologies are arriving that nothing in your training set resembled. Track this with a rolling histogram query against your results table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PostgreSQL 16: check for confidence drift over last 6 hours&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;width_bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;confidence_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;graph_classifications&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;classified_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'6 hours'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If buckets 4–6 (scores 0.3–0.6) start accumulating faster than your baseline, that's your label-collection trigger — not a threshold you set once and forget, but a ratio to watch relative to total volume through the window. The integration itself wires together without much ceremony. The vLLM server exposing the Qwen2-VL endpoint takes a standard OpenAI-compatible POST, so the n8n HTTP Request node needs only a JSON body with the base64-encoded graph image and your classification prompt. A Node.js process upstream reads from the blockchain indexer, renders the transaction graph to PNG using a D3-based layout, and drops the file path into a queue. The n8n flow picks it up, POSTs to vLLM, parses the confidence score out of the response, writes the row to PostgreSQL, and — if suspicious classifications in the current 15-minute window exceed your threshold — fires a webhook to your alerting system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// n8n Function node: extract confidence and check window threshold&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/confidence&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;([\d&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// write result — upstream HTTP Request node handles the INSERT&lt;/span&gt;
&lt;span class="c1"&gt;// this node gates the alert branch&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SUSPICIOUS_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.72&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// tune per your FP tolerance&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isSuspicious&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;SUSPICIOUS_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isSuspicious&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One practical gotcha: vLLM's vision input pipeline on the MI300X occasionally returns a malformed response when the image byte count exceeds a certain size — not an error code, just a truncated generation. Guard against it by capping your PNG renders at a fixed resolution (1024×1024 works well for graph layouts with up to a few hundred nodes) and logging any response where no confidence pattern matches so you can inspect those cases separately rather than silently dropping them into the benign bucket.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/fine-tuning-qwen2-vl-on-amd-mi300x-for-blockchain-graph-classification-what-the-docs-dont-tell-you/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>productivity</category>
      <category>tools</category>
    </item>
    <item>
      <title>nginx Active Health Checks Send Pod IPs as Host Headers — Here's Why Your Backend Returns 502s</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Tue, 14 Jul 2026 14:32:19 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/nginx-active-health-checks-send-pod-ips-as-host-headers-heres-why-your-backend-returns-502s-3506</link>
      <guid>https://dev.to/ericwoooo_kr/nginx-active-health-checks-send-pod-ips-as-host-headers-heres-why-your-backend-returns-502s-3506</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The maddening part of this failure mode is that your backend looks healthy by every obvious metric.  You curl the pod directly, you hit the service endpoint, everything returns 200.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~19 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Symptom: Intermittent 502s That Only Appear on Health-Check Traffic&lt;/li&gt;
&lt;li&gt;What nginx's Active Health Check Module Actually Does&lt;/li&gt;
&lt;li&gt;Diagnosing the Root Cause: Tracing the Bad Host Header&lt;/li&gt;
&lt;li&gt;The Fix: Forcing a Correct Host Header on Health-Check Requests&lt;/li&gt;
&lt;li&gt;Config Examples: nginx Open-Source vs nginx Plus vs Ingress-nginx&lt;/li&gt;
&lt;li&gt;What to Monitor After the Fix&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Symptom: Intermittent 502s That Only Appear on Health-Check Traffic
&lt;/h2&gt;

&lt;p&gt;The maddening part of this failure mode is that your backend looks healthy by every obvious metric. You curl the pod directly, you hit the service endpoint, everything returns 200. Real user traffic flows through fine — until nginx quietly decides the upstream is down and starts dropping requests entirely. The health check is failing, the pod gets ejected from the upstream pool, and nginx has no way to distinguish between "the pod crashed" and "the pod rejected a request with the wrong &lt;code&gt;Host&lt;/code&gt; header". Both look like an unavailable upstream from nginx's perspective.&lt;/p&gt;

&lt;p&gt;The specific pattern: active health checks fire against the pod IP directly — something like &lt;code&gt;10.244.3.17:8000&lt;/code&gt; — and nginx sends that raw IP as the &lt;code&gt;Host&lt;/code&gt; header. Your backend (uvicorn, vLLM, Ollama's HTTP layer) sees a request for host &lt;code&gt;10.244.3.17&lt;/code&gt;, matches it against its configured virtual host list, finds nothing, and either returns a 400/444/503 or closes the connection. Nginx logs this as upstream failure, marks the peer down, and your legitimate traffic hits the dead-peer list.&lt;/p&gt;

&lt;p&gt;The nginx error log will tell you exactly which variant of failure you're dealing with if you read it carefully. Three messages to watch for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;connect() failed (111: Connection refused)&lt;/code&gt;&lt;/strong&gt; — the pod is genuinely unreachable. Network issue, container crashed, port not open. This is not the host-header problem.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;upstream sent invalid header&lt;/code&gt;&lt;/strong&gt; — the upstream accepted the TCP connection and responded with something HTTP-shaped but malformed or with a status nginx considers fatal for health checks. A 400 Bad Request from a backend that rejected the unknown &lt;code&gt;Host&lt;/code&gt; value lands here. This &lt;em&gt;is&lt;/em&gt; the host-header problem.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;recv() failed (104: Connection reset by peer)&lt;/code&gt;&lt;/strong&gt; — the upstream connected, then slammed the socket shut before completing an HTTP response. Some uvicorn configurations do exactly this when a request hits no matching route: connect, read headers, reject at the routing layer, RST the connection. Also the host-header problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last two are frequently misread as pod instability. Engineers restart pods, increase resource limits, file tickets about flaky networking — none of it helps because the pod is working perfectly. It's rejecting a request it was never configured to accept.&lt;/p&gt;

&lt;p&gt;Self-hosted AI backends are particularly exposed to this because they tend to be opinionated about hostname matching in a way that managed cloud load balancers paper over. A vLLM server started with &lt;code&gt;--host 0.0.0.0&lt;/code&gt; but serving via a named endpoint, an Ollama instance behind a reverse proxy chain, a FastAPI app with uvicorn where &lt;code&gt;--root-path&lt;/code&gt; and host validation are configured explicitly — these all commonly validate the &lt;code&gt;Host&lt;/code&gt; header as a first-pass filter. Cloud teams using AWS ALB or GCP Cloud Load Balancing usually have that header rewritten automatically upstream before it ever reaches the backend. If you're running your own nginx-to-pod path without a managed layer in between, the raw pod IP lands in the &lt;code&gt;Host&lt;/code&gt; header, and strict backends refuse the connection on principle.&lt;/p&gt;

&lt;h2&gt;
  
  
  What nginx's Active Health Check Module Actually Does
&lt;/h2&gt;

&lt;p&gt;The behavior that causes most of the confusion: when nginx fires an active health check probe, it constructs a synthetic HTTP request from scratch — no client, no original headers, nothing inherited from real traffic. The &lt;code&gt;Host&lt;/code&gt; header in that synthetic request gets populated with whatever address nginx stored when it resolved the upstream server entry. For a Kubernetes pod backing a service, that's almost always a raw pod IP: &lt;code&gt;10.244.3.17&lt;/code&gt;, &lt;code&gt;172.20.8.4&lt;/code&gt;, something in that class. Your backend may work perfectly for every real client request and still fail health checks because it validates the &lt;code&gt;Host&lt;/code&gt; header on arrival and a bare IP doesn't match any expected virtual host.&lt;/p&gt;

&lt;p&gt;This is the fundamental split between active and passive checks. Passive health checks don't generate any requests — they ride along with real traffic and mark upstreams as failed when enough real responses come back with errors or timeouts. Those real requests already carry a &lt;code&gt;Host&lt;/code&gt; header set by the client (or by an earlier proxy stage), so they pass backend validation naturally. Active checks get none of that context. They're fired by nginx's internal health check timer, built from the upstream block configuration alone, and they carry only what nginx synthesizes — which by default means a &lt;code&gt;Host&lt;/code&gt; of the literal server address string.&lt;/p&gt;

&lt;p&gt;The relevant directive stack looks like this in the nginx Plus / &lt;code&gt;ngx_http_upstream_hc_module&lt;/code&gt; model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;my_backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;zone&lt;/span&gt; &lt;span class="s"&gt;backend_zone&lt;/span&gt; &lt;span class="mi"&gt;64k&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# required for active checks in nginx Plus&lt;/span&gt;

    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.244.3.17&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# pod IP — this string becomes the default Host header&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.244.3.22&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;keepalive&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# persistent connections to upstreams; does NOT affect synthetic request headers&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://my_backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kn"&gt;health_check&lt;/span&gt; &lt;span class="s"&gt;interval=5s&lt;/span&gt; &lt;span class="s"&gt;fails=2&lt;/span&gt; &lt;span class="s"&gt;passes=3&lt;/span&gt; &lt;span class="s"&gt;uri=/healthz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;# ^ fires GET /healthz HTTP/1.1 with Host: 10.244.3.17 — no override unless you add one&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;keepalive&lt;/code&gt; directive is worth calling out specifically because it's a common red herring during troubleshooting. Operators see a connection-reuse setting and assume it might affect how requests are formatted — it doesn't. &lt;code&gt;keepalive&lt;/code&gt; controls whether nginx maintains idle connections to upstream workers; it has zero influence on the headers placed inside those connections. The directives that &lt;em&gt;do&lt;/em&gt; influence what the synthetic request looks like are &lt;code&gt;health_check&lt;/code&gt;'s own parameters (particularly &lt;code&gt;uri&lt;/code&gt; and the match block it can reference), and any &lt;code&gt;proxy_set_header&lt;/code&gt; directives in the location block — though whether those apply to health check traffic depends on the module version and configuration scope, which is exactly where the 502s start hiding.&lt;/p&gt;

&lt;p&gt;One thing the nginx Plus docs understate: the &lt;code&gt;server&lt;/code&gt; line inside &lt;code&gt;upstream&lt;/code&gt; can be a hostname instead of an IP, and if it is, the resolved hostname becomes the default &lt;code&gt;Host&lt;/code&gt; value. That sounds like an escape hatch, but in Kubernetes it rarely helps — pod IPs get registered directly in endpoint slices, so whatever feeds your upstream block (a DNS-based service discovery integration, manual config generated from the Endpoints API, or something like nginx Ingress Controller's templating) typically emits raw IPs. The mismatch between what the health checker sends and what strict virtual-host validation expects is structural, not a misconfiguration you can fix by tweaking one knob.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosing the Root Cause: Tracing the Bad Host Header
&lt;/h2&gt;

&lt;p&gt;The deceptive part of this failure mode is that nginx's default &lt;code&gt;combined&lt;/code&gt; log format makes a backend returning &lt;code&gt;400 Bad Request&lt;/code&gt; look identical to a TCP connection failure. Both show up as &lt;code&gt;502&lt;/code&gt; in your access log. To actually see what's happening, you need to add &lt;code&gt;$upstream_status&lt;/code&gt; to your log format so the backend's real response code survives into the nginx logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;log_format&lt;/span&gt; &lt;span class="s"&gt;upstream_debug&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="nv"&gt;$remote_addr&lt;/span&gt; &lt;span class="s"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$remote_user&lt;/span&gt; &lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$time_local&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
                          &lt;span class="s"&gt;'"&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="nv"&gt;$body_bytes_sent&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
                          &lt;span class="s"&gt;'upstream_status=&lt;/span&gt;&lt;span class="nv"&gt;$upstream_status&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
                          &lt;span class="s"&gt;'upstream_addr=&lt;/span&gt;&lt;span class="nv"&gt;$upstream_addr&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;access_log&lt;/span&gt; &lt;span class="n"&gt;/var/log/nginx/access.log&lt;/span&gt; &lt;span class="s"&gt;upstream_debug&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After reloading nginx, a strict backend rejecting the IP-valued Host header will show &lt;code&gt;upstream_status=400&lt;/code&gt; (or &lt;code&gt;421&lt;/code&gt;) alongside the &lt;code&gt;502&lt;/code&gt; your client sees. That single field is the difference between "investigate the network" and "investigate the HTTP layer." Without it, you can spend an hour chasing phantom pod connectivity issues that don't exist.&lt;/p&gt;

&lt;p&gt;Once you suspect the Host header, replay exactly what nginx sends using &lt;code&gt;curl&lt;/code&gt; from inside the cluster. Either &lt;code&gt;kubectl exec&lt;/code&gt; into a debug pod or use &lt;code&gt;tcpdump&lt;/code&gt; on the pod's veth interface to capture the actual bytes. The curl replay is faster to iterate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Simulate what nginx active health check sends by default
&lt;span class="go"&gt;curl -v -H 'Host: 10.244.3.17' http://10.244.3.17:8080/health

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Expected output from a strict backend:
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;lt; HTTP/1.1 400 Bad Request
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;lt; content-type: text/plain
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;lt; x-reason: invalid host header
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that curl returns &lt;code&gt;400&lt;/code&gt; or &lt;code&gt;421&lt;/code&gt; and &lt;code&gt;curl -v http://10.244.3.17:8080/health&lt;/code&gt; (which sends the IP as Host anyway via curl's default behavior) also fails, try explicitly passing the correct virtual hostname: &lt;code&gt;curl -v -H 'Host: my-service.internal' http://10.244.3.17:8080/health&lt;/code&gt;. If that succeeds, you've isolated the exact problem — the backend cares about the Host value, and nginx is sending the pod IP.&lt;/p&gt;

&lt;p&gt;For vLLM and Ollama specifically: vLLM's OpenAI-compatible server started with &lt;code&gt;--host 0.0.0.0&lt;/code&gt; doesn't enforce Host headers in its vanilla configuration — it'll respond to health checks regardless of what Host value nginx sends. The failure reappears if someone wraps vLLM behind a FastAPI middleware layer or adds request validation. Ollama's built-in HTTP server is similarly permissive. The dangerous scenario is when a platform team adds an API gateway or auth proxy in front of either, and that proxy enforces Host header validation without anyone updating the nginx health check config. To confirm the hypothesis without modifying nginx config at all, temporarily add a debug header on the backend side and inspect what comes back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# In a FastAPI middleware or nginx location block on the backend side:
add_header X-Received-Host $http_host always;

# Then hit the health endpoint directly through nginx's upstream path
# and check the response headers — if you see:
# X-Received-Host: 10.244.3.17
# the pod IP is confirmed as the Host value nginx is sending
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That echo technique is useful when you can't easily capture traffic but you can modify backend config temporarily. The &lt;code&gt;always&lt;/code&gt; flag matters here — without it, nginx won't include the header on &lt;code&gt;4xx&lt;/code&gt; responses, which is exactly the response class you're trying to inspect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Forcing a Correct Host Header on Health-Check Requests
&lt;/h2&gt;

&lt;p&gt;The instinct most people reach for first is wrong, and it's worth knowing exactly why before you write any config. &lt;code&gt;proxy_set_header Host $upstream_addr;&lt;/code&gt; placed in the same location block as your traffic proxy does nothing for nginx Plus active health checks — those requests are generated internally by the nginx worker, not routed through your location's proxy directive chain. The header override simply doesn't apply. You need a different mechanism depending on whether you're running nginx Plus or open-source nginx, and they diverge significantly.&lt;/p&gt;

&lt;h4&gt;
  
  
  nginx Plus: Match Blocks + Dedicated Probe Location
&lt;/h4&gt;

&lt;p&gt;nginx Plus active health checks use a &lt;code&gt;health_check&lt;/code&gt; directive that optionally references a &lt;code&gt;match&lt;/code&gt; block for response validation. The correct pattern for injecting a Host header is to route the synthetic probe through a &lt;strong&gt;separate internal location&lt;/strong&gt; that sets its own &lt;code&gt;proxy_set_header&lt;/code&gt;, then point the health check at that location via the &lt;code&gt;uri&lt;/code&gt; parameter. The &lt;code&gt;headers&lt;/code&gt; block some people assume exists inside &lt;code&gt;health_check&lt;/code&gt; — it doesn't. Here's the working structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;http&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# Define what a healthy response looks like&lt;/span&gt;
    &lt;span class="kn"&gt;match&lt;/span&gt; &lt;span class="s"&gt;ai_backend_healthy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;status&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;header&lt;/span&gt; &lt;span class="s"&gt;Content-Type&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;"application/json";&lt;/span&gt;
        &lt;span class="s"&gt;body&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;'"status"\s*:\s*"ok"';&lt;/span&gt;
    &lt;span class="err"&gt;}&lt;/span&gt;

    &lt;span class="s"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;ai_inference&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;zone&lt;/span&gt; &lt;span class="s"&gt;ai_inference&lt;/span&gt; &lt;span class="mi"&gt;64k&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.1.10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.1.11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;# Active health check fires against /internal-health location below&lt;/span&gt;
        &lt;span class="kn"&gt;health_check&lt;/span&gt; &lt;span class="s"&gt;uri=/internal-health&lt;/span&gt; &lt;span class="s"&gt;interval=5s&lt;/span&gt; &lt;span class="s"&gt;fails=2&lt;/span&gt; &lt;span class="s"&gt;passes=1&lt;/span&gt; &lt;span class="s"&gt;match=ai_backend_healthy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;gateway.internal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;# Real traffic — Host header comes from the client or your override&lt;/span&gt;
        &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://ai_inference&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="s"&gt;api.internal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;# Synthetic probe location — nginx Plus health checker hits this internally&lt;/span&gt;
        &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/internal-health&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# blocks external access entirely&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://ai_inference/health&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="c1"&gt;# This is the override that actually reaches the backend&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="s"&gt;api.internal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;internal&lt;/code&gt; directive on the probe location means no external client can call &lt;code&gt;/internal-health&lt;/code&gt; directly — only nginx's internal health checker and &lt;code&gt;rewrite&lt;/code&gt;/&lt;code&gt;try_files&lt;/code&gt; chains can reach it. Without that flag you've just exposed your raw health endpoint to the internet.&lt;/p&gt;

&lt;h4&gt;
  
  
  Open-Source nginx: Split the Location, Own the Header
&lt;/h4&gt;

&lt;p&gt;Open-source nginx has no active health checks — the module was stripped to Plus long ago. What you get is passive health checks via &lt;code&gt;proxy_next_upstream&lt;/code&gt;, which marks a peer down after real traffic failures. That's actually useful for the Kubernetes case (more below), but if you're self-hosting and genuinely need synthetic probes, the cleanest workaround is a dedicated location block that handles only health traffic and sets its own headers independently of the main traffic path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;ai_inference&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.1.10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.1.11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Passive only — marks peer down after proxy_next_upstream triggers&lt;/span&gt;
    &lt;span class="c1"&gt;# No active health_check directive available in open-source builds&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;gateway.internal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://ai_inference&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="s"&gt;api.internal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_next_upstream&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt; &lt;span class="s"&gt;timeout&lt;/span&gt; &lt;span class="s"&gt;http_502&lt;/span&gt; &lt;span class="s"&gt;http_503&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_next_upstream_tries&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# External health probe endpoint — called by your load balancer's health check&lt;/span&gt;
    &lt;span class="c1"&gt;# or a cron/watchdog process, NOT by nginx itself&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/probe/health&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;# Proxy to the upstream group, but force the Host header the backends expect&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://ai_inference/health&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="s"&gt;api.internal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# critical — overrides the default $proxy_host&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_connect_timeout&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;access_log&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're triggering this probe from an external system (an ALB, a Kubernetes liveness check against nginx itself, or a cron hitting &lt;code&gt;/probe/health&lt;/code&gt;), add an &lt;code&gt;allow&lt;/code&gt;/&lt;code&gt;deny&lt;/code&gt; ACL or move it behind a different &lt;code&gt;listen&lt;/code&gt; port so it's not publicly reachable. The &lt;code&gt;proxy_connect_timeout&lt;/code&gt; and &lt;code&gt;proxy_read_timeout&lt;/code&gt; values here are tight by design — a health probe that hangs for 30 seconds is worse than a fast failure.&lt;/p&gt;

&lt;h4&gt;
  
  
  Kubernetes: Skip nginx Active Checks Entirely
&lt;/h4&gt;

&lt;p&gt;If you control the Pod spec — and in most self-hosted Kubernetes setups you do — the cleanest resolution to the Host header problem is to stop using nginx active health checks at all for readiness signaling. Let Kubernetes own that responsibility. Configure a &lt;code&gt;readinessProbe&lt;/code&gt; directly on the container, and switch nginx to passive-only health checking. The kubelet sends HTTP probes directly to the Pod's port using the Pod IP, but critically, Kubernetes sets the &lt;code&gt;Host&lt;/code&gt; header to the Pod IP by default too — so if your backend validates Host, you still need to handle this at the probe level. The better move is to expose a separate probe port that skips Host validation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Pod spec fragment — your inference container&lt;/span&gt;
&lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-inference&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-registry/ai-server:1.4.2&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;   &lt;span class="c1"&gt;# main traffic&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9090&lt;/span&gt;   &lt;span class="c1"&gt;# probe port, bypasses Host validation in app config&lt;/span&gt;
  &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/healthz&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9090&lt;/span&gt;           &lt;span class="c1"&gt;# hits the permissive probe port directly&lt;/span&gt;
    &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
    &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
    &lt;span class="na"&gt;successThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/healthz&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9090&lt;/span&gt;
    &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
    &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the nginx side in this topology, configure only passive health checks and let Kubernetes endpoints controller remove unready pods from the Service before nginx ever tries to proxy to them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;ai_inference&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;ai-inference-svc.default.svc.cluster.local&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Passive: nginx marks a peer down after observed failures in real traffic&lt;/span&gt;
    &lt;span class="c1"&gt;# Kubernetes readiness probes keep unhealthy pods out of the Service endpoints&lt;/span&gt;
    &lt;span class="c1"&gt;# so nginx rarely sees those pods at all&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://ai_inference&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="s"&gt;api.internal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;# Retry on genuine failures, but don't hammer a pod that's mid-startup&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_next_upstream&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt; &lt;span class="s"&gt;timeout&lt;/span&gt; &lt;span class="s"&gt;http_502&lt;/span&gt; &lt;span class="s"&gt;http_503&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_next_upstream_timeout&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_next_upstream_tries&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Kubernetes approach sidesteps the Host header problem structurally — you're not fighting nginx's health check behavior, you're removing the layer that causes it. The trade-off is that passive-only checking in nginx means the first failed real request to a degraded-but-still-listed pod gets eaten before failover triggers. For stateless inference endpoints where a retry is cheap, that's acceptable. For anything with side effects or strict SLOs, you want nginx Plus active checks with the explicit Host injection shown above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Config Examples: nginx Open-Source vs nginx Plus vs Ingress-nginx
&lt;/h2&gt;

&lt;p&gt;The fastest way to confirm you're hitting the Host-header 502 — before touching any config — is to curl the backend directly with an explicit &lt;code&gt;Host&lt;/code&gt; header set to the IP vs. the expected hostname. If the IP gets a 4xx or immediate close and the hostname gets a 200, you've isolated it. Now here's exactly how to fix it in each deployment model.&lt;/p&gt;

&lt;h4&gt;
  
  
  Open-Source nginx (&lt;code&gt;ngx_http_proxy_module&lt;/code&gt;)
&lt;/h4&gt;

&lt;p&gt;The fix belongs in a dedicated location block scoped only to health checks. Putting &lt;code&gt;proxy_set_header Host&lt;/code&gt; in the upstream-wide server block will override the header for all proxied traffic, which is usually not what you want — your application requests should still carry the original client Host. The pattern is to split health-check traffic into its own &lt;code&gt;location&lt;/code&gt; and set the header only there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;api_backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# Use keepalive so health-check connections don't thrash sockets&lt;/span&gt;
    &lt;span class="kn"&gt;keepalive&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.1.10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.1.11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Normal application traffic — Host header passes through from client&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://api_backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Health-check location nginx polls internally&lt;/span&gt;
    &lt;span class="c1"&gt;# Active checks require nginx Plus; open-source uses this as a passive&lt;/span&gt;
    &lt;span class="c1"&gt;# probe target or a dedicated poller location behind an internal allow&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/healthz&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;127.0&lt;/span&gt;&lt;span class="s"&gt;.0.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://api_backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;# This is the fix: send the hostname the backend validates,&lt;/span&gt;
        &lt;span class="c1"&gt;# not the upstream IP nginx defaults to&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;# If the backend speaks HTTPS internally, these two lines must&lt;/span&gt;
        &lt;span class="c1"&gt;# travel together — see the HTTPS section below&lt;/span&gt;
        &lt;span class="c1"&gt;# proxy_ssl_server_name on;&lt;/span&gt;
        &lt;span class="c1"&gt;# proxy_ssl_name api.example.com;&lt;/span&gt;

        &lt;span class="kn"&gt;proxy_connect_timeout&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;allow 127.0.0.1; deny all;&lt;/code&gt; guard. Without it, you're exposing an unthrottled health endpoint to the public, which is a separate problem. If you're using the commercial nginx Plus active health check directive (&lt;code&gt;health_check&lt;/code&gt; inside the upstream block), the &lt;code&gt;proxy_set_header Host&lt;/code&gt; in the matching location still applies — nginx Plus inherits it from the location context that owns the &lt;code&gt;health_check&lt;/code&gt; directive.&lt;/p&gt;

&lt;h4&gt;
  
  
  Ingress-nginx (Kubernetes)
&lt;/h4&gt;

&lt;p&gt;Ingress-nginx doesn't let you edit the generated nginx config directly — that file is owned by the controller and gets overwritten on every reconcile. The correct lever is the &lt;code&gt;nginx.ingress.kubernetes.io/proxy-set-headers&lt;/code&gt; annotation, which points at a ConfigMap that lists headers to inject. Pair that with &lt;code&gt;custom-http-errors&lt;/code&gt; to surface backend validation rejections distinctly from infrastructure 502s, otherwise you'll spend time chasing the wrong layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Step 1: ConfigMap with the header override&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ConfigMap&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-headers&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ingress-nginx&lt;/span&gt;   &lt;span class="c1"&gt;# must match controller namespace&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# Key is the header name, value is the desired value&lt;/span&gt;
  &lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api.example.com"&lt;/span&gt;

&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="c1"&gt;# Step 2: Ingress resource referencing that ConfigMap&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-ingress&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Points at namespace/configmap-name&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/proxy-set-headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ingress-nginx/custom-headers"&lt;/span&gt;

    &lt;span class="c1"&gt;# 421 and 400 are common status codes strict backends return on&lt;/span&gt;
    &lt;span class="c1"&gt;# Host mismatch — listing them here routes them to a custom error&lt;/span&gt;
    &lt;span class="c1"&gt;# page instead of silently returning generic 502 to clients&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/custom-http-errors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;400,421,502,503"&lt;/span&gt;

    &lt;span class="c1"&gt;# For HTTPS backends — controller won't negotiate SNI correctly without this&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/backend-protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTTPS"&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/proxy-ssl-server-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on"&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;
      &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
            &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
            &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-service&lt;/span&gt;
                &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing the ingress-nginx docs understate: the ConfigMap must exist before the Ingress is created, or the controller will log a sync error and silently skip the header override entirely. The Ingress object will show as &lt;code&gt;Synced&lt;/code&gt; in &lt;code&gt;kubectl get ingress&lt;/code&gt;, but the header won't be applied. Check controller logs with &lt;code&gt;kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx --tail=50&lt;/code&gt; to confirm the ConfigMap was actually loaded.&lt;/p&gt;

&lt;h4&gt;
  
  
  When the Backend Uses HTTPS Internally
&lt;/h4&gt;

&lt;p&gt;This is where two separate bugs compound into one opaque 502. Fixing the &lt;code&gt;Host&lt;/code&gt; header alone is not enough if the upstream listens on HTTPS, because TLS SNI is negotiated before the HTTP headers are sent. nginx will use the upstream IP as the SNI hostname during the TLS handshake unless you explicitly override it. The backend's TLS library rejects the handshake (cert CN doesn't match an IP), and you get a 502 before your &lt;code&gt;Host&lt;/code&gt; header fix ever comes into play:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Open-source nginx — these two directives must appear together&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/healthz&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;https://api_backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# note https://&lt;/span&gt;

    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Tells nginx to use the value below as the SNI hostname in the TLS handshake&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_ssl_server_name&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Explicitly set the SNI name — defaults to proxy_pass host, which for&lt;/span&gt;
    &lt;span class="c1"&gt;# named upstreams resolves to a peer IP at connection time&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_ssl_name&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Only disable verification if the backend uses a self-signed cert AND&lt;/span&gt;
    &lt;span class="c1"&gt;# you've verified the host via another mechanism (mTLS, VPN, etc.)&lt;/span&gt;
    &lt;span class="c1"&gt;# proxy_ssl_verify off;  # avoid unless you have a real reason&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For ingress-nginx with HTTPS backends, &lt;code&gt;nginx.ingress.kubernetes.io/proxy-ssl-server-name: "on"&lt;/code&gt; does the same job. Without it, even after setting the Host header ConfigMap correctly, you'll see TLS handshake failures in controller debug logs (&lt;code&gt;SSL_do_handshake() failed&lt;/code&gt;) that look indistinguishable from the pod being down. The operational takeaway: any time you're chasing a 502 against an HTTPS upstream, check both the HTTP &lt;code&gt;Host&lt;/code&gt; header and the TLS SNI hostname as two separate failure points.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Monitor After the Fix
&lt;/h2&gt;

&lt;p&gt;The fix you applied to the host header is not self-verifying — the most insidious failure mode is a backend that accepts the corrected header for normal traffic but still rejects health checks through a second validation layer you haven't found yet. Before you declare the issue resolved, instrument the logging layer first. Add &lt;code&gt;$upstream_status&lt;/code&gt; and &lt;code&gt;$upstream_response_time&lt;/code&gt; to your &lt;code&gt;log_format&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;log_format&lt;/span&gt; &lt;span class="s"&gt;upstream_detail&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="nv"&gt;$remote_addr&lt;/span&gt; &lt;span class="s"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$remote_user&lt;/span&gt; &lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$time_local&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
    &lt;span class="s"&gt;'"&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="nv"&gt;$body_bytes_sent&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
    &lt;span class="s"&gt;'ups_status=&lt;/span&gt;&lt;span class="nv"&gt;$upstream_status&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;       &lt;span class="c1"&gt;# actual response code from backend, not the 502 nginx synthesizes&lt;/span&gt;
    &lt;span class="s"&gt;'ups_rt=&lt;/span&gt;&lt;span class="nv"&gt;$upstream_response_time&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;    &lt;span class="c1"&gt;# latency per upstream; comma-separated if retried&lt;/span&gt;
    &lt;span class="s"&gt;'"&lt;/span&gt;&lt;span class="nv"&gt;$http_referer&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$http_user_agent&lt;/span&gt;&lt;span class="s"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;access_log&lt;/span&gt; &lt;span class="n"&gt;/var/log/nginx/access.log&lt;/span&gt; &lt;span class="s"&gt;upstream_detail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;$upstream_status&lt;/code&gt;, a backend returning 400 or 421 looks identical in the access log to a refused TCP connection — both surface as 502 to the client. With it, you'll see &lt;code&gt;ups_status=421&lt;/code&gt; in the line, which tells you immediately that the backend is still enforcing strict host validation on the health check path even after you patched the &lt;code&gt;proxy_set_header Host&lt;/code&gt; directive. The 421 (Misdirected Request) code is particularly common with HTTP/2 backends and TLS-terminating upstreams that check SNI against the Host header.&lt;/p&gt;

&lt;p&gt;For the health check thresholds themselves, be conservative with AI inference backends. Ollama loading a model cold can hold a connection for several seconds before responding — a default &lt;code&gt;fails=1 passes=1&lt;/code&gt; configuration will evict that upstream almost immediately and then immediately re-admit it, causing flapping rather than a clean recovery. A saner baseline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;ollama_backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;zone&lt;/span&gt; &lt;span class="s"&gt;ollama&lt;/span&gt; &lt;span class="mi"&gt;64k&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;11434&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;health_check&lt;/span&gt; &lt;span class="s"&gt;interval=10s&lt;/span&gt; &lt;span class="s"&gt;fails=3&lt;/span&gt; &lt;span class="s"&gt;passes=2&lt;/span&gt; &lt;span class="s"&gt;uri=/api/tags&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;# fails=3: tolerate two slow model-load responses before marking down&lt;/span&gt;
    &lt;span class="c1"&gt;# passes=2: require two consecutive successes before marking back up&lt;/span&gt;
    &lt;span class="c1"&gt;# /api/tags is lightweight — doesn't trigger a model load&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have Prometheus scraping via &lt;code&gt;nginx-prometheus-exporter&lt;/code&gt;, the metric to watch is &lt;code&gt;nginx_upstream_peers_health&lt;/code&gt; with its &lt;code&gt;state&lt;/code&gt; label. A peer toggling between &lt;code&gt;healthy&lt;/code&gt; and &lt;code&gt;unhealthy&lt;/code&gt; faster than your &lt;code&gt;interval&lt;/code&gt; cadence should allow is a hard signal that the host header fix is incomplete or there's a load balancer or WAF further upstream doing its own header validation. Rapid flapping here will look like random transient errors at the application layer, which makes root cause diagnosis painful without this metric in view.&lt;/p&gt;

&lt;p&gt;For pipelines wired through n8n or a TypeScript automation engine, upstream flapping doesn't produce clean error messages — it produces failed webhook deliveries, timed-out HTTP nodes, and n8n executions that error with generic fetch failures rather than anything that points at nginx. Cross-reference the timestamps of &lt;code&gt;nginx_upstream_peers_health&lt;/code&gt; state transitions directly against n8n's execution error log. If they correlate, the nginx layer is still the problem. If n8n errors persist after upstream state stabilizes, the issue has moved downstream — check your n8n credential timeouts and HTTP Request node retry settings. The &lt;a href="https://techdigestor.com/ultimate-productivity-guide-2026/" rel="noopener noreferrer"&gt;Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines&lt;/a&gt; guide covers how these components interact across a full self-hosted stack, which is useful context if you're still mapping which failure belongs to which layer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/nginx-active-health-checks-send-pod-ips-as-host-headers-heres-why-your-backend-returns-502s/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
