<?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>AWS vs. Home Lab for a DevOps Startup: When Cloud Costs More Than the Hardware</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Mon, 10 Aug 2026 08:11:04 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/aws-vs-home-lab-for-a-devops-startup-when-cloud-costs-more-than-the-hardware-1elj</link>
      <guid>https://dev.to/ericwoooo_kr/aws-vs-home-lab-for-a-devops-startup-when-cloud-costs-more-than-the-hardware-1elj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The moment that forces the AWS-vs-homelab decision usually isn't a planning meeting — it's a billing alert at the end of month three.  A single `t3.&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 Bill That Forces the Decision&lt;/li&gt;
&lt;li&gt;What You're Actually Comparing (Constraints First)&lt;/li&gt;
&lt;li&gt;Running the DevOps Stack on Home Lab Hardware&lt;/li&gt;
&lt;li&gt;Running the Same Stack on AWS&lt;/li&gt;
&lt;li&gt;Side-by-Side: Where Each Option Wins and Loses&lt;/li&gt;
&lt;li&gt;Hybrid Architecture: The Practical Middle Ground&lt;/li&gt;
&lt;li&gt;When to Pick What: Decision Rules Per Situation&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Bill That Forces the Decision
&lt;/h2&gt;

&lt;p&gt;The moment that forces the AWS-vs-homelab decision usually isn't a planning meeting — it's a billing alert at the end of month three. A single &lt;code&gt;t3.xlarge&lt;/code&gt; running continuously costs roughly $120/month in compute alone, but that's the floor, not the ceiling. Stack a 100GB gp3 EBS volume (~$8), a NAT gateway sitting idle most of the day (~$32 base + $0.045/GB processed), and even modest data transfer out, and you're looking at $200–250/month for one instance doing staging work. Now add a second runner for CI, a small RDS instance for your staging database, and a container registry with frequent pulls, and the number doubles without a single production user touching the system.&lt;/p&gt;

&lt;p&gt;A real DevOps lab has a predictable surface area: GitLab or Gitea runners pulling jobs, a private container registry (Harbor or a plain registry:2 container), at least one staging environment that mirrors prod, a secrets backend (Vault or something lighter), and a monitoring stack — Prometheus, Grafana, Loki, or whatever subset you trust. Every single one of those has a corresponding AWS line item. ECR charges per GB stored and per GB transferred. Secrets Manager charges per secret per month plus per 10,000 API calls. CloudWatch log ingestion is $0.50/GB and retrieval is priced separately. None of these amounts individually sounds alarming, which is exactly why the composite bill surprises people.&lt;/p&gt;

&lt;p&gt;Egress is the cost category that almost no one budgets correctly at the start. A staging environment that pulls a 2GB Docker image on every runner warmup, ships structured logs to an external sink, and calls a third-party API through a NAT gateway is generating egress charges on multiple vectors simultaneously. AWS charges $0.09/GB for data transferred out to the internet (in us-east-1 as of current pricing). A CI pipeline that builds and pushes a 1.5GB image, then pulls it into a staging cluster, then ships 500MB of logs — run that 20 times a day across a small team and the egress line alone clears $80–100/month. That number isn't on any pricing calculator because nobody types "20 CI runs × 2GB" into the estimator.&lt;/p&gt;

&lt;p&gt;The amortization math on owned hardware is simpler than people expect once you force yourself to do it. A used Dell PowerEdge R730 with 64GB RAM and a pair of decent SSDs runs $400–700 on eBay. A new mini-PC like a Beelink SER7 (Ryzen 9 7940HS, 32GB RAM, 1TB NVMe) is about $350 retail. Either machine runs your entire DevOps lab — runners, registry, staging containers, Vault, Prometheus — at a fixed electricity cost of roughly $15–30/month depending on load and local rates. The cloud equivalent of that lab costs $400–600/month on AWS with no ceiling. The hardware pays for itself in under two months. The honest counter-argument is ops burden and the time cost of maintaining the box, which is real — but for teams already running self-hosted automation stacks, that overhead is already priced in. The broader tooling picture for that kind of setup is covered in our &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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You're Actually Comparing (Constraints First)
&lt;/h2&gt;

&lt;p&gt;The framing of "cloud vs. home lab" obscures the real trade-off, which is this: do you want your budget risk to show up as a one-time capital hit you can plan for, or as a variable invoice that spikes whenever something goes wrong or traffic does something unexpected? AWS doesn't punish you for building — it punishes you for forgetting. A runaway Lambda, a CloudWatch log group with no retention policy, an NAT Gateway sitting in front of three t3.micro instances — these are the actual cost vectors, and none of them are obvious until you've already paid for them.&lt;/p&gt;

&lt;p&gt;The home lab baseline is more concrete than people expect. A used enterprise tower — something like a Dell PowerEdge R740 or a Supermicro workstation with a Ryzen 9 5950X or Xeon Silver, 64–128 GB ECC RAM, and a pair of NVMe drives in a software RAID — runs between $800 and $2,000 depending on what you're willing to spec. That machine runs Proxmox with no licensing fee, hosts a full GitLab instance (CI runners included), a Docker Swarm or single-node Kubernetes cluster, and a local container registry — all simultaneously, all without per-minute billing. The disk I/O on NVMe is faster than most EBS gp3 volumes at equivalent workloads. Power draw is real (roughly 80–150W under moderate load), but at residential electricity rates that's $7–15/month, not a line item that changes your runway.&lt;/p&gt;

&lt;p&gt;The AWS baseline for a comparable DevOps lab isn't one service — it's a composition. You're looking at EC2 (t3.medium to m6i.large depending on what you're running), ECR for storing container images (cheap per GB, but egress adds up), S3 for build artifacts and Terraform state, CloudWatch for logs with retention you have to configure manually or pay for indefinitely, and IAM roles you'll spend a non-trivial afternoon debugging. Each of those is priced reasonably in isolation. Together, without deliberate cost management, a three-person startup running daily CI builds, staging environments, and some observability tooling can hit $400–800/month before they've shipped anything to production.&lt;/p&gt;

&lt;p&gt;The constraint that actually forces the decision isn't technical — it's risk tolerance. A home lab failure (dead drive, power event, network hiccup) means downtime you have to fix yourself, on your schedule, with your hands. An AWS failure (or more commonly, an AWS misconfiguration) means an invoice you can't reverse, or an IAM policy that silently breaks your pipeline at 2am. Startups that are pre-revenue and have someone technical on-call 24/7 can absorb the former. Startups with investors, SLAs, or a team that needs to ship and not babysit infrastructure lean toward the latter — not because AWS is better engineering, but because managed failure recovery has a dollar cost that's easier to budget than a time cost that lands on whoever is available. Neither answer is wrong; they're just answers to different questions about where you want your operational pain to live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the DevOps Stack on Home Lab Hardware
&lt;/h2&gt;

&lt;p&gt;The part most cloud-first engineers underestimate is how complete a home lab stack can actually be. Gitea is about 80MB of RAM at idle and gives you webhooks, pull requests, and fine-grained access tokens — everything a team of one to five needs from a Git host. Self-hosted GitLab CE is the heavier option: expect 3-4GB RAM minimum before you add runners, but you get built-in container registry, CI, and merge request pipelines in a single deployment. For most home lab workloads, Gitea paired with a separate CI tool is the better call on constrained hardware.&lt;/p&gt;

&lt;p&gt;For pipelines, Woodpecker CI connects directly to Gitea or GitHub via OAuth and runs jobs in Docker containers — the config is a &lt;code&gt;.woodpecker.yml&lt;/code&gt; in the repo root, and the agent is stateless, so you can run multiple agents on different machines without coordination. Forgejo Actions is worth knowing if you're already running Forgejo (the Gitea fork): it accepts GitHub Actions YAML syntax, which means migrating existing workflows costs almost nothing. Both tools use Docker socket or a Docker daemon for job isolation, which does mean a compromised pipeline step can touch the host — plan your network segmentation accordingly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`yaml&lt;/p&gt;

&lt;h1&gt;
  
  
  Woodpecker agent — minimal compose fragment
&lt;/h1&gt;

&lt;p&gt;woodpecker-agent:&lt;br&gt;
  image: woodpeckerci/woodpecker-agent:v2.7.0&lt;br&gt;
  restart: unless-stopped&lt;br&gt;
  volumes:&lt;br&gt;
    - /var/run/docker.sock:/var/run/docker.sock&lt;br&gt;
  environment:&lt;br&gt;
    - WOODPECKER_SERVER=grpc://woodpecker-server:9000&lt;br&gt;
    - WOODPECKER_AGENT_SECRET=${AGENT_SECRET}&lt;br&gt;
    - WOODPECKER_MAX_WORKFLOWS=4  # tune to CPU core count&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Harbor gives you a private OCI registry with vulnerability scanning (Trivy backend), image replication, and RBAC — deployed via Docker Compose, it lands around 600-800MB RAM across its services. The alternative is running a plain &lt;code&gt;registry:2&lt;/code&gt; container, which is leaner but gives you nothing beyond push/pull. Harbor is worth the overhead if you're doing multi-stage builds and want to catch CVEs before images reach your staging environment. Pair it with Grafana + Prometheus + Loki for observability: Prometheus scrapes your services, Loki ingests logs shipped via Promtail or the Docker logging driver, and Grafana ties it together. On modest hardware, keep Loki's retention window short and configure chunk caching or you'll watch it eat disk faster than expected.&lt;/p&gt;

&lt;p&gt;Proxmox as the hypervisor layer is where home lab genuinely beats a comparable cloud budget. Snapshot-based VM cloning means your staging environment is a full copy of production state — no AMI bake time, no S3 transfer costs. The command to clone VM 100 into VM 200 with a full independent disk copy is:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;/p&gt;

&lt;h1&gt;
  
  
  Full clone — produces a completely independent VM from template ID 100
&lt;/h1&gt;

&lt;p&gt;qm clone 100 200 --full --name staging-env-01&lt;/p&gt;

&lt;h1&gt;
  
  
  Snapshot before destructive test
&lt;/h1&gt;

&lt;p&gt;qm snapshot 200 pre-test --description "before chaos run"&lt;/p&gt;

&lt;h1&gt;
  
  
  Roll back if needed
&lt;/h1&gt;

&lt;p&gt;qm rollback 200 pre-test&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That snapshot workflow is the rough functional equivalent of launching from an EC2 AMI and terminating on failure — except the round trip is seconds, not minutes, and you're not paying per-API-call or per-GB of AMI storage. For reverse proxy and TLS, Caddy handles wildcard certs from Let's Encrypt with DNS challenge in about a dozen lines of config — no listener rules, no target group gymnastics:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`nginx&lt;/p&gt;

&lt;h1&gt;
  
  
  Caddyfile — wildcard TLS for all internal services on one IP
&lt;/h1&gt;

&lt;p&gt;*.lab.yourdomain.com {&lt;br&gt;
  tls {&lt;br&gt;
    dns cloudflare {env.CF_API_TOKEN}&lt;br&gt;
  }&lt;br&gt;
  @gitea host gitea.lab.yourdomain.com&lt;br&gt;
  handle @gitea {&lt;br&gt;
    reverse_proxy gitea:3000&lt;br&gt;
  }&lt;br&gt;
  @harbor host harbor.lab.yourdomain.com&lt;br&gt;
  handle @harbor {&lt;br&gt;
    reverse_proxy harbor-nginx:8080&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The honest failure point is Postgres. Every managed RDS feature you take for granted — point-in-time recovery, multi-AZ failover, automated minor version patching — you now own entirely. A minimal trustworthy setup requires at minimum: WAL archiving to a separate disk or NAS (configure &lt;code&gt;archive_command&lt;/code&gt; in &lt;code&gt;postgresql.conf&lt;/code&gt;), a tested restore procedure you've actually run end-to-end, and a monitoring alert on replication lag if you add a replica. Patroni or repmgr can handle automated failover, but both have real operational complexity. Budget several hours to get this right before you route anything important through it — the "just run Postgres in Docker" path works until your drive fails at 2am and you realize your last verified backup is three weeks old.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the Same Stack on AWS
&lt;/h2&gt;

&lt;p&gt;The honest case for AWS isn't that it's cheaper — it's that the first week goes smoother. RDS Multi-AZ handles failover without you writing a single line of HA logic. ECS Fargate means you describe a container and it runs, no node pool to patch, no kubelet to debug at 11pm. ECR lifecycle policies let you write one JSON block and forget about disk pressure from accumulated image layers. CloudWatch alarms are wired to IAM and SNS out of the box. None of that is magic, but it does represent real operational complexity that you're offloading. The trap is mistaking "easier at the start" for "cheaper to run long-term" — and that mistake usually surfaces around month three, when the bill arrives with line items you didn't anticipate.&lt;/p&gt;

&lt;p&gt;Terraform reproducibility is the strongest real argument for AWS, and it's worth being specific about why. On AWS, &lt;code&gt;terraform destroy &amp;amp;&amp;amp; terraform apply&lt;/code&gt; actually works as a staging reset workflow. You can blow away an entire environment — VPC, subnets, ECS services, RDS instance, load balancer — and rebuild it in 12-15 minutes. On Proxmox or bare metal, doing the same thing requires you to have invested upfront in templating discipline: cloud-init images, Ansible playbooks, and a willingness to actually run them instead of SSHing in and fixing things by hand. Most home lab operators have a Terraform state file that describes what they &lt;em&gt;intended&lt;/em&gt; to build, not what's actually running. AWS keeps that gap smaller because the API is the only way to make changes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`json&lt;/p&gt;

&lt;h1&gt;
  
  
  Real lifecycle policy that prevents ECR from eating your storage budget
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Without this, a busy CI pipeline can accumulate hundreds of GB in weeks
&lt;/h1&gt;

&lt;p&gt;aws ecr put-lifecycle-policy \&lt;br&gt;
  --repository-name my-app \&lt;br&gt;
  --lifecycle-policy '{&lt;br&gt;
    "rules": [{&lt;br&gt;
      "rulePriority": 1,&lt;br&gt;
      "description": "Expire untagged images after 7 days",&lt;br&gt;
      "selection": {&lt;br&gt;
        "tagStatus": "untagged",&lt;br&gt;
        "countType": "sinceImagePushed",&lt;br&gt;
        "countUnit": "days",&lt;br&gt;
        "countNumber": 7&lt;br&gt;
      },&lt;br&gt;
      "action": { "type": "expire" }&lt;br&gt;
    },{&lt;br&gt;
      "rulePriority": 2,&lt;br&gt;
      "description": "Keep only last 20 tagged images",&lt;br&gt;
      "selection": {&lt;br&gt;
        "tagStatus": "tagged",&lt;br&gt;
        "tagPrefixList": ["v"],&lt;br&gt;
        "countType": "imageCountMoreThan",&lt;br&gt;
        "countNumber": 20&lt;br&gt;
      },&lt;br&gt;
      "action": { "type": "expire" }&lt;br&gt;
    }]&lt;br&gt;
  }'&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The surprise line items follow a pattern. NAT Gateway is the most common one — at &lt;strong&gt;$0.045 per GB processed&lt;/strong&gt; (verify against current AWS pricing; this changes), a staging environment where your containers pull Docker images through a NAT Gateway or ship verbose logs to CloudWatch can generate meaningful data-transfer charges without anyone noticing. CloudWatch Logs ingestion runs &lt;strong&gt;$0.50 per GB ingested&lt;/strong&gt;, and if your Node.js app is logging every HTTP request at DEBUG level, a moderately trafficked service generates more log volume than most people expect. ECR storage is $0.10/GB/month — cheap until your CI runs 50 builds a day and you didn't set the lifecycle policy above. None of these are architectural problems; they're configuration problems that cost money while you figure them out.&lt;/p&gt;

&lt;p&gt;Spot and Graviton instances are where AWS actually competes on price for CI workloads specifically. A &lt;code&gt;c7g.2xlarge&lt;/code&gt; (Graviton3, 8 vCPU, 16GB RAM) on Spot can run well under half the On-Demand price during off-peak hours in most regions — useful if your build jobs run at predictable times. The catch is that Spot interruptions are not handled automatically by GitHub Actions, GitLab CI, or most other CI tools. You need either an interruption-aware runner wrapper (AWS provides one for CodeBuild; for GitHub Actions you're looking at &lt;a href="https://github.com/philips-labs/terraform-aws-github-runner" rel="noopener noreferrer"&gt;terraform-aws-github-runner&lt;/a&gt; or similar) or you accept that a 2-minute warning and a failed build is acceptable. For stateless, idempotent build jobs it usually is. For anything with shared state — a Selenium grid, a database integration test — Spot interruption mid-run is a real failure mode, not a theoretical one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side-by-Side: Where Each Option Wins and Loses
&lt;/h2&gt;

&lt;p&gt;The decision almost always gets made on the wrong axis. Teams compare sticker prices — a t3.medium vs. what electricity costs — and miss the dimensions that actually blow up months later: how long it takes a new engineer to get their first pipeline green, or what happens at 2am when your single physical host drops off the network. Here's the honest breakdown across the dimensions that matter.&lt;/p&gt;

&lt;p&gt;Dimension&lt;/p&gt;

&lt;p&gt;Home Lab&lt;/p&gt;

&lt;p&gt;AWS&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monthly cost floor&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Hardware already amortized → electricity + ISP only. Predictable.&lt;/p&gt;

&lt;p&gt;~$50–200/mo minimum for anything resembling a real CI stack. Scales with mistakes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disaster recovery complexity&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Single site. No automatic failover. Manual recovery unless you've built a second node yourself.&lt;/p&gt;

&lt;p&gt;Multi-AZ is a config option. RDS automated backups, EBS snapshots — all managed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment reproducibility&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Identical if you're disciplined with NixOS or Ansible. Drifts badly if you're not.&lt;/p&gt;

&lt;p&gt;AMI + Terraform = repeatable by default. New region spins up identically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compliance audit readiness&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You write every control doc yourself. Auditors want evidence trails you have to build.&lt;/p&gt;

&lt;p&gt;CloudTrail, IAM, GuardDuty, and existing SOC 2 reports from AWS cover most control gaps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internet dependency&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Outbound only for external pulls. Internal pipelines survive ISP outages.&lt;/p&gt;

&lt;p&gt;Everything routes through AWS endpoints. ISP down = engineers blocked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time-to-first-pipeline (new engineer)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;VPN config + SSH key + runner registration. 30–90 minutes if docs exist.&lt;/p&gt;

&lt;p&gt;IAM console + CodeBuild or GitHub Actions OIDC. 15–30 minutes with decent IaC.&lt;/p&gt;

&lt;p&gt;Home lab wins in two specific scenarios and loses everywhere else. The first is sustained compute-heavy workloads where the cost-per-hour gap becomes obscene at scale — if you're running embedding pipelines against a corpus all day, every day, a local bge-m3 instance on a 32GB VRAM card costs you electricity while the equivalent p3 instance on AWS bills by the second. The second is anything requiring full network layer control: custom routing, promiscuous mode for packet capture, GPU passthrough configs that EC2 won't expose. You own the hardware, you own the kernel, you own the NIC — and that matters when your internal tooling does LLM inference on sensitive data that cannot leave the building.&lt;/p&gt;

&lt;p&gt;AWS wins cleanly on burst capacity and compliance story. A startup with a monorepo that occasionally triggers 40 parallel test runners needs elastic capacity it genuinely cannot predict. Spot instances absorb that burst without you buying hardware that sits idle 90% of the time. On the compliance side: if a SOC 2 Type II auditor is asking for evidence of least-privilege access controls and audit logging, you can point at CloudTrail and IAM policy attachments. Reproducing that paper trail from a self-hosted setup requires building it yourself — a Gitea instance plus Vault plus structured syslog plus a policy-as-code layer — which is doable but is a significant chunk of engineering time that most early-stage teams don't have.&lt;/p&gt;

&lt;p&gt;The dealbreaker for each side is predictable once you've run both long enough. Home lab's dealbreaker is physical single-site failure. A PSU dies, a switch port fails, ISP goes dark for six hours — there's no automatic failover. You can mitigate with a Tailscale-connected second node at a co-lo or a friend's rack, but that's still manual topology work. AWS's dealbreaker is cost unpredictability from human error: a junior engineer ships verbose CloudWatch logging on a high-throughput service, forgets to scope the log retention, and the bill for that month arrives with a line item that looks like a typo. Or a p3.2xlarge gets left running over a holiday weekend because the auto-shutdown Lambda wasn't wired up. Neither scenario is AWS's fault, but the blast radius is a bill you can't negotiate down. On a home lab, a runaway process costs you CPU cycles and maybe a thermal event — not a four-figure invoice.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Choose home lab when:&lt;/strong&gt; workloads are sustained and predictable, data sensitivity demands air-gap-adjacent control, or VRAM-heavy inference is a daily operational need — not a burst use case.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Choose AWS when:&lt;/strong&gt; CI load is spiky and unpredictable, a compliance framework is an active customer requirement, or onboarding speed matters more than cost optimization right now.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Hybrid is often the honest answer:&lt;/strong&gt; home lab hosts the always-on services (Gitea, registry, monitoring, local LLM), AWS absorbs burst CI runners via OIDC-connected ephemeral instances that terminate on job completion.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Hybrid Architecture: The Practical Middle Ground
&lt;/h2&gt;

&lt;p&gt;The false binary — cloud vs. home lab — dissolves the moment you stop treating each as a complete answer and start treating them as layers. The actual constraint is &lt;em&gt;type of workload&lt;/em&gt;: stateful services, GPU compute, and internal tooling have a fundamentally different cost profile than public-facing endpoints or short-lived burst jobs. Once you wire them together properly, you stop making emotional infrastructure decisions and start making economic ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  WireGuard as the Backbone
&lt;/h3&gt;

&lt;p&gt;A site-to-site WireGuard tunnel between your home lab and a small AWS VPC (a &lt;code&gt;t3.micro&lt;/code&gt; NAT instance works fine as the peer) is what makes the hybrid model operational rather than theoretical. You bring up the tunnel with &lt;code&gt;wg-quick up wg0&lt;/code&gt; on both sides, add a static route in the VPC route table pointing your home lab's subnet (say &lt;code&gt;10.10.0.0/24&lt;/code&gt;) at the WireGuard peer's private IP, and from that point on your Terraform-managed cloud resources can reach internal services by private IP — no public exposure, no bastion theater. A minimal home lab &lt;code&gt;wg0.conf&lt;/code&gt; looks like this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`ini&lt;br&gt;
[Interface]&lt;br&gt;
Address = 10.10.0.1/24&lt;br&gt;
PrivateKey = &lt;br&gt;
ListenPort = 51820&lt;/p&gt;

&lt;p&gt;[Peer]&lt;/p&gt;

&lt;h1&gt;
  
  
  AWS WireGuard peer (t3.micro in the VPC)
&lt;/h1&gt;

&lt;p&gt;PublicKey = &lt;br&gt;
Endpoint = :51820&lt;br&gt;
AllowedIPs = 172.16.0.0/24   # VPC private subnet&lt;br&gt;
PersistentKeepalive = 25&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;On the AWS side, add &lt;code&gt;172.16.0.0/24 → eni-xxxx&lt;/code&gt; in your VPC route table and make sure the WireGuard instance's security group allows UDP 51820 inbound from your home IP. The latency over this tunnel is real — expect 10–30ms depending on your ISP — but for internal service calls (hitting a local Postgres, a local model inference endpoint, a private registry) that's irrelevant. What matters is that the traffic never touches the public internet and your home lab doesn't need an open inbound port beyond the WireGuard UDP port.&lt;/p&gt;

&lt;h3&gt;
  
  
  Routing CI Jobs by Workload Type, Not by Convention
&lt;/h3&gt;

&lt;p&gt;The most immediate cost win in a hybrid setup is splitting CI traffic by job character rather than running everything in one place. Commit-triggered unit tests belong on self-hosted &lt;a href="https://woodpecker-ci.org/" rel="noopener noreferrer"&gt;Woodpecker&lt;/a&gt; runners: they hit the local Docker cache, finish in seconds, and cost nothing per run. Release builds and security scans are a different shape entirely — they need clean environments, they're infrequent enough that keeping a warm instance for them is waste, and security scan tools (Trivy, Semgrep, OWASP dependency-check) pull large DBs you don't want polluting your local cache. Route those to AWS Spot &lt;code&gt;c6i&lt;/code&gt; instances via a cloud-hosted Woodpecker agent or GitHub Actions with a self-hosted runner registered against an EC2 Spot Fleet. The Woodpecker pipeline split looks like this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`yaml&lt;br&gt;
steps:&lt;br&gt;
  unit-tests:&lt;br&gt;
    image: node:20-alpine&lt;br&gt;
    # runs on local runner — fast cache, no AWS cost&lt;br&gt;
    when:&lt;br&gt;
      event: push&lt;/p&gt;

&lt;p&gt;security-scan:&lt;br&gt;
    image: aquasec/trivy:latest&lt;br&gt;
    commands:&lt;br&gt;
      - trivy fs --exit-code 1 --severity HIGH,CRITICAL .&lt;br&gt;
    # runs on AWS Spot runner — clean env, burst capacity&lt;br&gt;
    when:&lt;br&gt;
      event: tag&lt;br&gt;
    environment:&lt;br&gt;
      RUNNER_LABELS: aws-spot&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The practical gotcha: Spot interruptions during a release build will fail your pipeline at the worst moment. Use &lt;code&gt;--interruption-behavior terminate&lt;/code&gt; with a retry policy, or use Spot with an On-Demand fallback via a mixed instances policy. For builds that run less than 15 minutes, interruption rates on &lt;code&gt;c6i&lt;/code&gt; in &lt;code&gt;us-east-1&lt;/code&gt; are low enough that a single retry covers almost every case.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cost Monitoring on Both Sides Without Exceptions
&lt;/h3&gt;

&lt;p&gt;A hybrid setup doubles the surface area for cost surprises — AWS bills you for things you forgot to deprovision, and the home lab quietly runs workloads that should have been stopped months ago. Treat monitoring as mandatory infrastructure, not a nice-to-have. On the AWS side, set an SNS-backed Budget alert at 80% of your monthly threshold — this is three CLI commands:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;/p&gt;

&lt;h1&gt;
  
  
  Create the budget (replace ACCOUNT_ID and EMAIL)
&lt;/h1&gt;

&lt;p&gt;aws budgets create-budget \&lt;br&gt;
  --account-id ACCOUNT_ID \&lt;br&gt;
  --budget '{"BudgetName":"monthly-cap","BudgetLimit":{"Amount":"150","Unit":"USD"},"TimeUnit":"MONTHLY","BudgetType":"COST"}' \&lt;br&gt;
  --notifications-with-subscribers '[{"Notification":{"NotificationType":"ACTUAL","ComparisonOperator":"GREATER_THAN","Threshold":80,"ThresholdType":"PERCENTAGE"},"Subscribers":[{"SubscriptionType":"EMAIL","Address":"&lt;a href="mailto:you@example.com"&gt;you@example.com&lt;/a&gt;"}]}]'&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;On the home lab side, a cron job that runs every 15 minutes and dumps &lt;code&gt;docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"&lt;/code&gt; plus &lt;code&gt;df -h /&lt;/code&gt; output to a local log file — and alerts you if any container is above a CPU threshold for more than three consecutive checks — catches the runaway container that's been eating 100% of a core since Tuesday. Keep both alert systems independent: if AWS goes over budget, you should know regardless of whether your home lab monitoring is healthy, and vice versa. The goal isn't dashboards — it's no surprises on either invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Pick What: Decision Rules Per Situation
&lt;/h2&gt;

&lt;p&gt;The mistake most small teams make is treating this as a philosophical question about cloud vs. on-prem when it's actually an operational capacity question. You don't pick AWS because it's "more professional" or home lab because you're "scrappy." You pick based on three things: your team's Linux tolerance, your workload's variance, and what your contracts or customers require of you. Everything else is noise.&lt;/p&gt;

&lt;p&gt;Go home lab first if your team is one to three people and your workloads are predictable. Predictable means you can answer "how much compute do we need at 2pm on a Tuesday?" with reasonable confidence. If you're already running Docker Compose services locally — a Postgres container, an n8n instance, maybe a local registry — you already understand the operational surface. The jump to running those same containers on a bare-metal server under your desk or in a rack at a colocation facility is smaller than it looks. The overhead is real: disk failures happen, UPS batteries die, you will eventually lose a Saturday to a kernel panic. But it's &lt;em&gt;bounded&lt;/em&gt; overhead. You know all the failure modes after a few months, and they stop surprising you. The one hard requirement: someone on the team needs to be genuinely comfortable with Linux — not "can Google a command" comfortable, but "can diagnose why a container won't start after a reboot without a Stack Overflow session" comfortable.&lt;/p&gt;

&lt;p&gt;Go AWS first under three specific conditions. First, if your customers or investors require SOC 2 Type II or HIPAA controls. The audit trail, the managed IAM, the compliance documentation that AWS generates automatically — reproducing that on home lab hardware costs more in engineering time than just paying AWS. Second, if your CI workload is spiky and unpredictable. Batch processing that runs fine on eight cores most of the time but occasionally needs 64 cores for a model training run is exactly what AWS spot instances exist for. Home lab hardware is sized for your median load; AWS lets you pay for your peaks. Third, if your team cannot tolerate a failed build server during a demo or a customer call. The emotional cost of that moment is real, and if your organization doesn't have the culture to shrug it off, don't build the system that creates it.&lt;/p&gt;

&lt;p&gt;Go hybrid immediately if you're running local LLM inference for internal developer tooling. This is a genuinely useful workload to keep on owned hardware: embedding-based semantic search over internal docs, code review assistance, changelog summarization — all of these run fine on a 32GB VRAM box with bge-m3 for embeddings and a mid-size Ollama model for generation. The inference cost on AWS would make these tools economically stupid to operate. But if those same developers need public-facing APIs, a CDN, or anything that requires a stable IP and uptime SLA for external users, that piece belongs on AWS or a VPS. Split the workload at the network boundary: internal tools stay local, external surface goes cloud. The routing overhead is minimal if you're already on Tailscale.&lt;/p&gt;

&lt;p&gt;The clearest migration signal is financial, and it's not ambiguous: if your AWS bill exceeds the annualized cost of equivalent home lab hardware for three consecutive months, the arithmetic has already answered the question. A workstation with a mid-range GPU, 128GB of RAM, and 8TB of NVMe runs somewhere between $3,000 and $6,000 depending on specs. If you're paying more than $250–500/month on AWS for equivalent compute capacity — and staying there month after month — you're past the crossover point. The remaining variable isn't financial, it's operational: is your team willing to own the hardware? If yes, migrate. If no, you're paying a management tax and at least you're paying it consciously.&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/aws-vs-home-lab-for-a-devops-startup-when-cloud-costs-more-than-the-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>aws</category>
      <category>cloud</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>5 Self-Hosted Analytics Tools You Can Actually Run on Your Own Server</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Fri, 07 Aug 2026 08:10:51 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/5-self-hosted-analytics-tools-you-can-actually-run-on-your-own-server-128m</link>
      <guid>https://dev.to/ericwoooo_kr/5-self-hosted-analytics-tools-you-can-actually-run-on-your-own-server-128m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; GA4's data sampling kicks in at surprisingly low traffic levels — and the threshold isn't based on what would be statistically useful for you, it's based on what's computationally cheap for Google.  A small e-commerce site running a conversion funnel report over a 90-day window w&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 Problem With Sending Your Traffic Data Somewhere Else&lt;/li&gt;
&lt;li&gt;How to Read This Comparison&lt;/li&gt;
&lt;li&gt;The Five Tools: Setup Snapshots and Real Trade-offs&lt;/li&gt;
&lt;li&gt;Deployment Configs Worth Copying&lt;/li&gt;
&lt;li&gt;Comparison Table: What Matters at the Operator Level&lt;/li&gt;
&lt;li&gt;When to Pick Which Tool&lt;/li&gt;
&lt;li&gt;Keeping These Running: Backup and Upgrade Notes&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem With Sending Your Traffic Data Somewhere Else
&lt;/h2&gt;

&lt;p&gt;GA4's data sampling kicks in at surprisingly low traffic levels — and the threshold isn't based on what would be statistically useful for you, it's based on what's computationally cheap for Google. A small e-commerce site running a conversion funnel report over a 90-day window will hit sampled results long before a high-traffic publisher does, which means the sites with the fewest data points to begin with are the ones getting their data thinned out further. The 14-month retention limit compounds this: any cohort or funnel analysis that crosses that window is just gone, not archived somewhere you could pay to access — gone.&lt;/p&gt;

&lt;p&gt;The tracker-blocking problem is worse than most analytics dashboards will tell you. uBlock Origin, Brave's built-in shields, Firefox's Enhanced Tracking Protection, and DNS-level blockers like Pi-hole all drop &lt;code&gt;google-analytics.com&lt;/code&gt; and &lt;code&gt;gtag&lt;/code&gt; calls by default. What you see in GA4 is an undercount, and the undercount is skewed: technical audiences, privacy-conscious users, and anyone on a managed corporate network are disproportionately invisible. Beyond the measurement gap, every pageview that does get through is a data point handed to an advertising platform with its own interests in how that data gets used and retained.&lt;/p&gt;

&lt;p&gt;Running your own analytics instance changes the data ownership model entirely. Your events land in a Postgres or ClickHouse table that you control — no sampling algorithm between collection and query, no retention policy you didn't write, and in many jurisdictions no cookie consent banner required at all when visitor data never leaves your infrastructure. The EU's GDPR guidance, and similar frameworks, generally treat server-side collection that doesn't involve cross-site tracking or external processors differently from GA4, which explicitly routes data through Google's servers. That's not legal advice, but it's a real architectural difference worth understanding before you dismiss self-hosted analytics as overkill.&lt;/p&gt;

&lt;p&gt;What follows covers five tools that actually run in Docker without heroic effort: &lt;strong&gt;Plausible&lt;/strong&gt;, &lt;strong&gt;Umami&lt;/strong&gt;, &lt;strong&gt;Matomo&lt;/strong&gt;, &lt;strong&gt;PostHog&lt;/strong&gt;, and &lt;strong&gt;Fathom Lite&lt;/strong&gt;. For each one, you'll get an honest read on RAM and disk growth over time — because "lightweight" means different things at 10k monthly visits versus 500k — along with the specific use case it fits best and where it breaks down. Some of these are genuinely impressive. Some have sharp edges that only show up after a few weeks of real traffic. The goal is to give you enough to make the right call for your setup, not to sell you on self-hosting as a philosophy.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Read This Comparison
&lt;/h2&gt;

&lt;p&gt;Every comparison like this has hidden assumptions baked in. Here they're explicit: all five tools were evaluated under operator constraints that match a typical solo or small-team deployment — a single VPS or home-lab host, Docker Compose for orchestration, and a reverse proxy handling TLS (either Caddy with automatic certs or Nginx with manual config). No managed RDS, no cloud-hosted Postgres, no autoscaling. If a tool's architecture assumes it can phone home to a managed service or spin up additional nodes, that counts against it here.&lt;/p&gt;

&lt;p&gt;The resource numbers you'll see reflect steady-state behavior at moderate traffic — roughly 50k pageviews per month. That's not a high-traffic site, but it's enough to expose tools that bloat their write queues or hold too much in memory between flushes. Where cold-start behavior is meaningfully different (some tools take 30–90 seconds before they'll accept ingestion), or where a traffic spike causes visible degradation, those are called out explicitly rather than buried in a footnote.&lt;/p&gt;

&lt;p&gt;The comparison table uses five columns that actually matter for operational decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Minimum RAM&lt;/strong&gt; — what the container actually consumes under the load profile above, not the optimistic number from the README&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Database backend&lt;/strong&gt; — because your backup strategy, query performance, and migration path all depend on this&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Data export format&lt;/strong&gt; — CSV, JSON, and Parquet are not equivalent; one of these tools gives you neither CSV nor JSON by default&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Multi-site support&lt;/strong&gt; — whether you can track multiple domains under one install, or need separate deployments&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Single biggest operational gotcha&lt;/strong&gt; — the thing that isn't in the README but will find you after two weeks of running it in production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One framing note: this comparison covers pure analytics tools — event tracking, pageviews, funnels, session data. It doesn't cover self-hosted experimentation platforms, full observability stacks, or AI-assisted content pipelines. If you're thinking about how local-model tooling fits into the broader self-hosted picture, 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 those pieces connect in a real stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Five Tools: Setup Snapshots and Real Trade-offs
&lt;/h2&gt;

&lt;p&gt;The most common mistake when evaluating these tools is treating them as interchangeable. They aren't. The gap between Ackee and PostHog isn't just features — it's three orders of magnitude difference in infrastructure complexity. Pick based on what you're actually tracking, not what sounds impressive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plausible Analytics (Community Edition)
&lt;/h3&gt;

&lt;p&gt;The Docker Compose setup is genuinely one command, and the &lt;code&gt;plausible/analytics&lt;/code&gt; image bundles everything including the ClickHouse dependency. The gotcha that isn't in the README: ClickHouse has no memory cap by default and will cheerfully consume available RAM on a shared VPS as your event volume grows. You need to drop a config file into the container before this bites you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;# clickhouse-config.xml — mount this at /etc/clickhouse-server/config.d/
&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;512000000&lt;span class="nt"&gt;&amp;lt;/max_memory_usage&amp;gt;&lt;/span&gt;          &lt;span class="c"&gt;&amp;lt;!-- 512MB hard cap --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;max_memory_usage_for_all_queries&amp;gt;&lt;/span&gt;800000000&lt;span class="nt"&gt;&amp;lt;/max_memory_usage_for_all_queries&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;Without that, a 2GB droplet will get into OOM territory after a few weeks of normal traffic. The floor is officially ~1GB RAM, but plan for 1.5GB minimum if ClickHouse is sharing the host with anything else. The feature ceiling is also real: no funnels, no session replay, no custom event properties beyond a single goal URL. That's not a bug — Plausible is deliberately a pageview counter with a clean UI. Marketers who need conversion path analysis will hit that wall within a week.&lt;/p&gt;

&lt;h3&gt;
  
  
  Umami
&lt;/h3&gt;

&lt;p&gt;The lightest option in this list by a significant margin. The Node.js app image sits under 200MB pulled, and at idle with a Postgres 16 backend you're looking at roughly 256MB RSS. That makes it viable on the smallest cloud instances or alongside other services on a 1GB box. The v2.x release added custom event properties, which matters — v1.x custom events were just named pings with no payload, which is nearly useless for anything beyond "button clicked". Make sure you're actually on v2 before assuming properties work.&lt;/p&gt;

&lt;p&gt;The silent failure mode that will waste your afternoon: the &lt;code&gt;DATABASE_URL&lt;/code&gt; environment variable must be properly URL-encoded. If your Postgres password contains &lt;code&gt;@&lt;/code&gt;, &lt;code&gt;#&lt;/code&gt;, or &lt;code&gt;!&lt;/code&gt;, the container starts, logs nothing obviously wrong, and then fails auth on every request. Encode the password component with &lt;code&gt;encodeURIComponent()&lt;/code&gt; in Node or just use a password without special characters in dev. The error surface is bad enough that it reads like a network issue, not a config issue.&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;# Wrong — will silently fail auth if password contains special chars
&lt;/span&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;=&lt;span class="n"&gt;postgresql&lt;/span&gt;://&lt;span class="n"&gt;umami&lt;/span&gt;:&lt;span class="n"&gt;p&lt;/span&gt;@&lt;span class="n"&gt;ss&lt;/span&gt;&lt;span class="c"&gt;#word@localhost:5432/umami
&lt;/span&gt;
&lt;span class="c"&gt;# Correct
&lt;/span&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;=&lt;span class="n"&gt;postgresql&lt;/span&gt;://&lt;span class="n"&gt;umami&lt;/span&gt;:&lt;span class="n"&gt;p&lt;/span&gt;%&lt;span class="m"&gt;40&lt;/span&gt;&lt;span class="n"&gt;ss&lt;/span&gt;%&lt;span class="m"&gt;23&lt;/span&gt;&lt;span class="n"&gt;word&lt;/span&gt;@&lt;span class="n"&gt;localhost&lt;/span&gt;:&lt;span class="m"&gt;5432&lt;/span&gt;/&lt;span class="n"&gt;umami&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Matomo
&lt;/h3&gt;

&lt;p&gt;The most feature-complete tool here, and it earns that reputation: funnels, heatmaps, A/B testing, and GDPR consent tooling are all included without a SaaS upsell. The catch is operational complexity. The recommended split-container setup — &lt;code&gt;php-fpm&lt;/code&gt; for processing, Nginx as the reverse proxy, MySQL or MariaDB for storage — has more moving parts than the others, and the Nginx config needs to correctly proxy to the PHP socket or you get a blank screen with no useful error.&lt;/p&gt;

&lt;p&gt;The single most common operational failure on Matomo is skipping the archiving cron. Reports don't generate in real-time — they're computed from raw logs on a schedule. If you don't set this up, your dashboard freezes at the last archived date and looks like tracking broke:&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 crontab — run as the web server user, not root&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt;/5 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /usr/bin/php /var/www/html/console core:archive &lt;span class="nt"&gt;--url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://your-matomo-domain.com &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--url&lt;/code&gt; flag is required and must match the Matomo install URL exactly, including protocol. Get it wrong and the archiver exits silently. Matomo is the right call when you need a full analytics suite that a non-technical marketer can use without training — it's the closest self-hosted equivalent to Google Analytics in terms of UI familiarity.&lt;/p&gt;

&lt;h3&gt;
  
  
  PostHog (Open-Source)
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;docker-compose.hobby.yml&lt;/code&gt; that PostHog ships for self-hosters is not a lightweight deployment. It pulls in ClickHouse, Kafka, Redis, a plugin server, a Celery worker, and the main Django app. On a fresh 4GB RAM machine, the stack consumes roughly 3–3.5GB at idle before you've tracked a single event. That's not a complaint — PostHog is solving a fundamentally different problem than pageview counting. Feature flags, session replay, funnel analysis, and cohort tracking in one open-source stack is genuinely remarkable. But running it on a $6/month VPS to replace Google Analytics for a brochure site is the wrong trade-off. Use PostHog when you're tracking logged-in user behavior in a SaaS product and need the full product analytics loop. Use anything else on this list for marketing site traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ackee
&lt;/h3&gt;

&lt;p&gt;Ackee's privacy model is different from the others: no cookies, no fingerprinting, and unique visitor counts are derived by hashing IP + User-Agent once per day and discarding the raw values. You cannot reconstruct individual sessions from what Ackee stores. The architecture is Node.js talking to MongoDB, and the footprint is minimal. The sharp edge: Ackee doesn't have a marketer-facing dashboard worth showing to a client. The UI is sparse, and the primary interface for anything beyond the basic view is a GraphQL API. That's actually the point — if you want to pipe raw visit counts into your own reporting layer, Notion embed, or n8n workflow, the GraphQL endpoint is clean and easy to work with. If a non-technical user needs to check traffic independently, send them to Plausible or Umami instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment Configs Worth Copying
&lt;/h2&gt;

&lt;p&gt;The most expensive lesson with self-hosted analytics isn't picking the wrong tool — it's losing months of data because a named volume wasn't declared before running &lt;code&gt;docker compose down&lt;/code&gt;. Every config below is structured around the failure modes I've actually hit, not the happy-path examples in the official docs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plausible
&lt;/h3&gt;

&lt;p&gt;Plausible's compose setup has two non-negotiable variables. &lt;code&gt;BASE_URL&lt;/code&gt; must be the exact public URL your tracking script will report to — get this wrong and events silently 404. &lt;code&gt;SECRET_KEY_BASE&lt;/code&gt; must be a real random value, not a placeholder:&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 once, paste into .env, never rotate without migrating sessions&lt;/span&gt;
openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 64
&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="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;plausible&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/plausible/community-edition:v2.1.0&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env&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;db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;clickhouse&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;8000:8000"&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.3-alpine&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;clickhouse_data:/var/lib/clickhouse&lt;/span&gt;  &lt;span class="c1"&gt;# omit this and a compose down wipes all event history&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;clickhouse_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# named volume — required, not optional&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After you've created the first admin account, add &lt;code&gt;DISABLE_REGISTRATION=true&lt;/code&gt; to your &lt;code&gt;.env&lt;/code&gt; and restart. Without it, anyone who finds your hostname can register. There's no rate limiting on the signup endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Umami
&lt;/h3&gt;

&lt;p&gt;Umami's silent failure mode: if you skip the Prisma migration on first boot, the database schema is never initialized, but the container starts cleanly with no errors in logs. The UI just… doesn't work, and you'll spend time checking DNS before realizing the schema is empty.&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 once after first boot — not in an entrypoint, do it manually&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;umami npx prisma migrate deploy
&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;# Minimal .env for Umami
&lt;/span&gt;&lt;span class="py"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;postgresql://umami:password@db:5432/umami&lt;/span&gt;
&lt;span class="py"&gt;HASH_SALT&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;any-long-random-string-you-generate  # salts session fingerprints for privacy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;HASH_SALT&lt;/code&gt; isn't just a security formality — it's what prevents Umami from storing raw IP-derived identifiers. Change it after data is collected and all historical session groupings break. Set it once and treat it like a private key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Matomo
&lt;/h3&gt;

&lt;p&gt;Matomo's archiving step is the single most common reason people think their tracking is broken when it isn't. Raw visits land in the database fine, but the report UI queries pre-aggregated archive tables. Without a cron job running the archiver, every date range shows "No data for this period" — even while events are being recorded in real time.&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;matomo&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;matomo:5.0-apache&lt;/span&gt;

  &lt;span class="na"&gt;cron&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;matomo:5.0-apache&lt;/span&gt;
    &lt;span class="c1"&gt;# same image, different entrypoint — shares the same mounted config&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;matomo_data:/var/www/html&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/bin/sh -c "echo '0 * * * * www-data php /var/www/html/console core:archive --url=https://your-domain.tld &amp;gt;&amp;gt; /var/log/matomo-archive.log 2&amp;gt;&amp;amp;1' | crontab - &amp;amp;&amp;amp; cron -f"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--url&lt;/code&gt; flag must match the Matomo general settings URL exactly, including scheme. A mismatch causes the archiver to authenticate against the wrong host and exit silently. Pipe output to a log file you actually check — the default is to discard it.&lt;/p&gt;

&lt;h3&gt;
  
  
  PostHog
&lt;/h3&gt;

&lt;p&gt;PostHog's hobby compose stack bundles MinIO for object storage because session recordings need somewhere to put binary blobs. The gotcha: if you set &lt;code&gt;OBJECT_STORAGE_ENABLED=false&lt;/code&gt; to reclaim the ~300–400 MB of RAM MinIO holds, session recordings don't throw an error. They just silently drop. The UI shows the recording list, users appear in it, but playback is a spinner.&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;# Before assuming recordings work, check the worker logs&lt;/span&gt;
docker compose logs worker &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"recording&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;object_storage&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;minio"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're RAM-constrained enough to disable MinIO, turn off session recording entirely in PostHog's project settings — that way you're intentionally missing data, not accidentally missing it. The worker log is the canonical truth here; the frontend gives you no signal either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison Table: What Matters at the Operator Level
&lt;/h2&gt;

&lt;p&gt;The gap between "it boots" and "it runs reliably for six months" is where most self-hosted analytics projects fall apart. These numbers and gotchas come from documented behavior, official resource requirements, and the kinds of failure modes that show up in GitHub issues after week two of production use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory Footprint at Idle
&lt;/h3&gt;

&lt;p&gt;Idle RAM is what determines whether this fits on a $6/month VPS or needs its own box. Ackee is the clear winner here — roughly 128MB at idle, because it's a Node process backed by MongoDB with almost no in-process caching. Umami sits around 256MB idle, which is reasonable for a Next.js app with a Postgres connection pool. Matomo at ~512MB is acceptable if you're already running a LAMP-adjacent stack. Then the cliff: Plausible's ClickHouse requirement means you're committing roughly 1GB before a single pageview lands, because ClickHouse doesn't release its memory reservation between queries. PostHog at ~4GB idle isn't a typo — that's Postgres, ClickHouse, Kafka, Redis, and the Django app server all running simultaneously. Deploying PostHog on anything under 8GB RAM is an exercise in OOM frustration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# PostHog's own docker-compose uses these service minimums:
# postgres:      512MB
# clickhouse:    1GB+
# kafka:         512MB
# redis:         128MB
# web/worker:    1-2GB
# Total floor:   ~4GB — and that's before actual traffic load
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Database Backends and What They Actually Mean
&lt;/h3&gt;

&lt;p&gt;Every extra database in the stack is another thing to back up, tune, and potentially watch die at 2am. Ackee's MongoDB dependency is its most controversial design choice — you're adding a document store just to track page views, which feels heavy for what it does. Umami's Postgres-or-MySQL flexibility is genuinely useful; if you already have a managed Postgres instance, Umami slots in cleanly. Plausible's dual-database architecture (Postgres for account data, ClickHouse for event storage) is the right call at scale, but ClickHouse has a known memory creep behavior: without explicit &lt;code&gt;max_memory_usage&lt;/code&gt; and &lt;code&gt;max_memory_usage_for_user&lt;/code&gt; limits set in &lt;code&gt;clickhouse-server/config.d/&lt;/code&gt;, it will gradually consume available RAM over days or weeks, especially with frequent aggregation queries. PostHog adds Kafka to that Postgres + ClickHouse combination, which means you now have a message queue to manage — useful for buffering ingestion spikes, but a source of delayed data when Kafka consumer lag builds up under traffic bursts.&lt;/p&gt;

&lt;p&gt;# Plausible ClickHouse memory cap — add to your config.d/override.xml:&lt;br&gt;
&lt;br&gt;
  &lt;br&gt;
    &lt;br&gt;
      2000000000       &amp;lt;!-- 2GB hard cap --&amp;gt;&lt;br&gt;
      1500000000&lt;br&gt;
    &lt;br&gt;
  &lt;br&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  The Gotcha That Will Actually Burn You
&lt;/h3&gt;

&lt;p&gt;Umami's silent auth failure on a malformed database URL is probably the most dangerous because it's invisible — the container starts, health checks pass, but no events are being stored. The log output doesn't always surface a clear connection error; you find out when your dashboard shows a flatline two days later. Matomo's stale reports issue is well-known but still catches people: without the archiving cron running on schedule, the UI will serve cached aggregate data that's hours or days old while appearing current. The fix is straightforward but not obvious from the default install docs:&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;# Matomo archiving cron — add to crontab on the host running PHP&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt;/5 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; www-data /usr/bin/php /var/www/html/matomo/console core:archive &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://your-matomo-domain.com &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostHog's Kafka lag under traffic spikes is an operational reality rather than a bug. Events get buffered in Kafka, the consumer workers process them asynchronously, and during a spike you can have a 10–30 minute delay between a user action and that event appearing in the PostHog UI. For most small business use cases this is fine — but if you're watching a product launch in real-time, it will feel broken when it isn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-Site Support: Free vs. Gated
&lt;/h3&gt;

&lt;p&gt;All five tools support tracking multiple domains, but the licensing situation on Matomo deserves a callout. The multi-site feature is completely free and well-implemented in self-hosted Matomo — you manage a global view across all properties from one interface. The cloud version charges for it. If you're evaluating Matomo cloud pricing and wondering why multi-site is a paid add-on while the self-hosted docs treat it as a default feature, that's intentional product segmentation. PostHog's approach is structurally different: sites are "projects" under an organization, each with their own API key and isolated event stream, but sharing a single Kafka/ClickHouse backend. That means one PostHog deployment handles multi-tenant traffic efficiently, but schema changes or ClickHouse maintenance affect all your projects simultaneously.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Pick Which Tool
&lt;/h2&gt;

&lt;p&gt;The fastest way to make the wrong choice here is to pick the tool with the most GitHub stars or the prettiest landing page. The right call depends almost entirely on your existing infrastructure, your ops tolerance, and whether you're tracking &lt;em&gt;sessions&lt;/em&gt; or &lt;em&gt;events&lt;/em&gt; — those are genuinely different problems with different right answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Umami&lt;/strong&gt; if you run a personal site or small blog and already have a Postgres instance sitting around. The migration path is a single &lt;code&gt;docker-compose up&lt;/code&gt;, the schema is dead simple, and the dashboard gives you what 90% of non-technical stakeholders actually want to see. I've had Umami running for months without touching it — no log rotation surprises, no memory creep, no ClickHouse vacuum jobs. It's the only tool on this list where "near-zero ops" is actually true rather than aspirational marketing copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Plausible Community Edition&lt;/strong&gt; if you're migrating a client or marketing team off GA4 and need the UI to sell itself without a 20-minute onboarding call. The catch nobody mentions upfront: ClickHouse is not optional, and if you skip the memory config before your first traffic spike, you will hit OOM kills. Add this to your ClickHouse &lt;code&gt;config.xml&lt;/code&gt; from day one:&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="nt"&gt;&amp;lt;profiles&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;default&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="nt"&gt;&amp;lt;/default&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/profiles&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Budget 1–2GB RAM above what you'd normally allocate just for the ClickHouse process, and don't run this on a 1GB VPS. Plausible is polished enough that non-technical stakeholders will actually trust the numbers, which is worth the extra infra overhead in client-facing contexts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Matomo&lt;/strong&gt; if your use case is GDPR compliance documentation, funnel analysis, or heatmaps — and you're genuinely comfortable running a PHP application backed by MySQL long-term. The feature depth is real: Matomo's funnel and goal tracking is legitimately GA360-tier, and the consent management tooling is the most defensible of anything on this list for regulated industries. But the ops surface is the highest here. You're maintaining PHP runtime versions, MySQL slow query logs, and a plugin ecosystem that varies wildly in quality. Go in with eyes open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick PostHog&lt;/strong&gt; if you need to track product behavior — button clicks, form completions, feature flag exposure, session replays — alongside pageviews. The pageview tracking is almost incidental to what PostHog is actually built for. Using it purely as a hit counter is genuinely wasteful: it requires at least 4GB RAM to run the full stack without constant swap pressure, and that overhead only makes sense when you're using the event pipeline, cohort analysis, or the feature flag system. &lt;strong&gt;Pick Ackee&lt;/strong&gt; at the opposite end: no sessions, no funnels, no retention graphs — just a clean GraphQL API that returns raw aggregated numbers. If you're piping analytics data into a custom dashboard or an n8n workflow that does its own aggregation, Ackee's minimalism is a deliberate architectural choice, not a missing feature. On my n8n setup I can query Ackee's API directly in an HTTP Request node and reshape the data however the downstream step needs it, without fighting an opinionated data model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping These Running: Backup and Upgrade Notes
&lt;/h2&gt;

&lt;p&gt;The ClickHouse gotcha will ruin your week if you're not ready for it. Plausible and PostHog both use ClickHouse under the hood, and ClickHouse does &lt;strong&gt;not&lt;/strong&gt; support naive major-version upgrades — pulling a new image tag and running &lt;code&gt;docker compose up&lt;/code&gt; will either refuse to start or silently corrupt your data depending on the version gap. The fix is simple but easy to skip: pin your image tags explicitly in your compose file and read the ClickHouse changelog before you touch anything. A &lt;code&gt;latest&lt;/code&gt; tag in production is how you find this out the hard way.&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 — pin everything, never use :latest for stateful services&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;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:24.3.3.102&lt;/span&gt;  &lt;span class="c1"&gt;# pin to exact patch&lt;/span&gt;
    &lt;span class="c1"&gt;# before upgrading: read https://clickhouse.com/docs/en/whats-new/changelog&lt;/span&gt;
    &lt;span class="c1"&gt;# major version jumps require running the migration step first:&lt;/span&gt;
    &lt;span class="c1"&gt;# docker exec -it clickhouse clickhouse-client --query "SELECT version()"&lt;/span&gt;
    &lt;span class="c1"&gt;# then follow the upgrade guide for your specific version delta&lt;/span&gt;
  &lt;span class="na"&gt;plausible&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/plausible/community-edition:v2.1.1&lt;/span&gt;  &lt;span class="c1"&gt;# match to ClickHouse compat matrix&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Matomo's backup requirements are slightly non-obvious. The MySQL database is the obvious thing to dump, but the file that actually controls your instance state is &lt;code&gt;config/config.ini.php&lt;/code&gt;. That file holds your salted password hash configuration, your plugin enable/disable state, and your database credentials. If you restore the DB without it, Matomo either refuses to run or walks you through a fresh install as if no data exists. Back up both, keep them together, and test the restore. A minimal working backup script:&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;# matomo-backup.sh — run nightly via cron&lt;/span&gt;
&lt;span class="nv"&gt;TIMESTAMP&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; +%Y%m%d_%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/opt/backups/matomo"&lt;/span&gt;

&lt;span class="c"&gt;# DB dump&lt;/span&gt;
mysqldump &lt;span class="nt"&gt;-u&lt;/span&gt; matomo_user &lt;span class="nt"&gt;-p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MATOMO_DB_PASS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; matomo_db | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;gzip&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;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;/db_&lt;/span&gt;&lt;span class="nv"&gt;$TIMESTAMP&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt;

&lt;span class="c"&gt;# Config file — small but critical&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; /opt/matomo/config/config.ini.php &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;/config_&lt;/span&gt;&lt;span class="nv"&gt;$TIMESTAMP&lt;/span&gt;&lt;span class="s2"&gt;.ini.php"&lt;/span&gt;

&lt;span class="c"&gt;# Prune backups older than 14 days&lt;/span&gt;
find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; +14 &lt;span class="nt"&gt;-delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Umami's backup story is simpler because it's pure Postgres — no auxiliary config files, no proprietary state. A nightly &lt;code&gt;pg_dump&lt;/code&gt; piped to Backblaze B2 via &lt;code&gt;rclone&lt;/code&gt; covers the entire restore path. Backblaze B2's S3-compatible API means no custom tooling required, and the restore is a single &lt;code&gt;psql&lt;/code&gt; command. On my setup this runs as a cron job on the host, not inside the container, so a crashed container doesn't also kill the backup process.&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;# umami-backup.sh&lt;/span&gt;
&lt;span class="nv"&gt;TIMESTAMP&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; +%Y%m%d_%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DUMP_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/tmp/umami_&lt;/span&gt;&lt;span class="nv"&gt;$TIMESTAMP&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt;

&lt;span class="c"&gt;# pg_dump directly from host; adjust connection string to match your compose env&lt;/span&gt;
&lt;span class="nv"&gt;PGPASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UMAMI_DB_PASS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; pg_dump &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-h&lt;/span&gt; localhost &lt;span class="nt"&gt;-p&lt;/span&gt; 5432 &lt;span class="nt"&gt;-U&lt;/span&gt; umami_user umami_db | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;gzip&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;$DUMP_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# rclone must be configured with your B2 credentials: rclone config&lt;/span&gt;
rclone copy &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DUMP_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; b2:your-bucket-name/umami/
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DUMP_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every tool covered here exposes some form of health endpoint — Plausible at &lt;code&gt;/api/health&lt;/code&gt;, PostHog at &lt;code&gt;/health&lt;/code&gt;, Umami at &lt;code&gt;/api/health&lt;/code&gt;, Matomo via its status page. Wire all of them to Uptime Kuma, which runs cleanly in the same Docker network and can hit internal hostnames directly without exposing anything to the internet. The one alert that actually matters beyond basic uptime: Matomo's archiving cron. When it fails, the dashboard shows stale aggregated data with no visible error — users just notice that today's numbers look wrong. Add a cron job that writes a heartbeat file on success, and monitor that file's mtime from Uptime Kuma using its "Keyword" check type or a simple shell monitor.&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-you-can-actually-run-on-your-own-server/" 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>Self-Hosted Analytics Without Google: Plausible, Umami, and Matomo on Your Own Hardware</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Wed, 05 Aug 2026 08:11:28 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/self-hosted-analytics-without-google-plausible-umami-and-matomo-on-your-own-hardware-mg1</link>
      <guid>https://dev.to/ericwoooo_kr/self-hosted-analytics-without-google-plausible-umami-and-matomo-on-your-own-hardware-mg1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; GA4's event model is the first thing that breaks trust.  The old Universal Analytics had one job: tell you how many people read a page.&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;Why Self-Hosted Operators Stop Trusting Google Analytics&lt;/li&gt;
&lt;li&gt;The Three Contenders: What Each Tool Actually Is&lt;/li&gt;
&lt;li&gt;Comparison Table: Hardware Requirements and Operational Reality&lt;/li&gt;
&lt;li&gt;Setup to Production: Docker Compose Configs for Each&lt;/li&gt;
&lt;li&gt;Non-Obvious Behaviors That Cost Time in Production&lt;/li&gt;
&lt;li&gt;When to Pick What: Matching the Tool to the Actual Situation&lt;/li&gt;
&lt;li&gt;Ongoing Ops: Backups, Updates, and Staying Out of Trouble&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why Self-Hosted Operators Stop Trusting Google Analytics
&lt;/h2&gt;

&lt;p&gt;GA4's event model is the first thing that breaks trust. The old Universal Analytics had one job: tell you how many people read a page. GA4 replaced that with a flexible event schema that's genuinely powerful for e-commerce funnels — and genuinely annoying if you just want a pageview count you can trust. Getting that number now means navigating the Explorations interface, picking the right event, filtering by &lt;code&gt;page_location&lt;/code&gt;, and hoping you didn't hit the sampling threshold that kicks in on date ranges longer than a few weeks. A self-hosted blog operator shouldn't need to reverse-engineer a BI tool to answer "did anyone read Tuesday's post?"&lt;/p&gt;

&lt;p&gt;The data residency problem is more fundamental than the UX complaints. If you're already running your own Nginx, your own Postgres, your own monitoring stack — routing your visitors' behavior through Google's infrastructure is architecturally incoherent. It's not paranoia; it's consistency. GDPR makes this concrete: any EU visitor whose data hits Google's servers requires a consent mechanism, which in practice means a cookie banner, a Consent Management Platform, or both. The CMP itself adds another 20–80 KB of JavaScript depending on vendor, it fires before your content loads, and it tanks your Lighthouse scores on mobile. You're paying a performance penalty to collect data you don't own.&lt;/p&gt;

&lt;p&gt;The real cost accounting looks like this: the GA4 loader script ships around 45 KB minified (closer to 17 KB gzipped, but it also triggers additional async fetches). That's not catastrophic in isolation, but stack it against a consent banner blocking render, the CMP payload, and the fact that GA4 stores aggregated data with no raw event export on the free tier, and the "free" label stops being accurate. You don't get a SQL table. You get a dashboard that Google controls, with sampling on queries that touch more than a few months of history, and no way to backfill if you change your event schema. Compare that to running Plausible or Umami in Docker: a single container, raw data in Postgres or ClickHouse, zero third-party calls, and no consent banner required under most GDPR guidance because no personal data leaves your server.&lt;/p&gt;

&lt;p&gt;The build-vs-rent tension here is identical to what comes up with AI tooling — you can rent Google's analytics infrastructure the same way you can rent a cloud copilot, and both choices come with the same hidden costs: rate limits you don't control, data you can't fully export, and a pricing/feature surface that can change under you. For operators already thinking through that tradeoff in other contexts, see our breakdown of &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; — the reasoning transfers directly. Once you've run your own LLM inference or your own monitoring stack, the question isn't whether to self-host analytics; it's which tool fits your traffic volume and query patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Contenders: What Each Tool Actually Is
&lt;/h2&gt;

&lt;p&gt;Plausible v2.x surprises most people with its stack choice: it's written in Go with an Elixir layer (the Elixir/Phoenix frontend, Go for the ingestion path), backed by ClickHouse for event storage rather than PostgreSQL — which is where its query speed comes from on high-traffic sites. The ~1 KB script isn't marketing copy; load it and inspect it yourself in DevTools. There's no cookie set, no fingerprinting, no personal data written to disk. That's not just a privacy stance — it means you genuinely don't need a consent banner under GDPR or ePrivacy, which removes an entire category of compliance work. The trade-off is that Plausible's data model is intentionally coarse: sessions are approximated, not tracked across requests, so you won't get per-user journey data. That's a feature if you don't want it, a blocker if you do.&lt;/p&gt;

&lt;p&gt;Umami v2.x runs on Next.js 14+ with either PostgreSQL 15+ or MySQL 8+, and it's the most developer-friendly of the three to modify. The dashboard is a React app you can fork, restyle, or embed. Event tracking is a dead-simple API call:&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;// Send a custom event from any JS context&lt;/span&gt;
&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://your-umami-host/api/send&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&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="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;document.title,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;website&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;YOUR_WEBSITE_UUID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button_click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// event name&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c1"&gt;// arbitrary JSON payload&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cookies are off by default in recent versions, though you can enable session persistence if you need it. The honest limitation: Umami's query layer is straightforward SQL — fine for most sites, but if you're pushing millions of events per day, you'll feel the difference versus ClickHouse-backed tools. It also doesn't ship with funnel analysis, heatmaps, or e-commerce tracking out of the box.&lt;/p&gt;

&lt;p&gt;Matomo v5.x is the only one of these three that can genuinely replace Google Analytics feature-for-feature. PHP 8.2+ and MySQL 8+ (or MariaDB 10.6+) are the current requirements. You get funnels, goal tracking, e-commerce revenue attribution, campaign tagging, and raw SQL access to your own database — no data locked in a vendor's warehouse. Heatmaps and session recordings exist but are paid plugins through the Matomo Marketplace, which is a real cost to factor in. The operational weight is also real: Matomo runs a cron job for log processing, the archive process hammers your database on high-traffic sites, and the plugin ecosystem means you're managing PHP dependencies. Expect to allocate at least 2 GB RAM to a Matomo instance under any meaningful load, versus Plausible and Umami both running comfortably under 512 MB for most self-hosted scenarios.&lt;/p&gt;

&lt;p&gt;The practical split: Plausible if you want the minimum viable analytics footprint with zero compliance friction. Umami if you're already in a Next.js ecosystem or want to customize the tool itself. Matomo if you're replacing GA3 and need feature parity — funnels, e-commerce, or a client who expects a specific report that the lighter tools simply can't produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison Table: Hardware Requirements and Operational Reality
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Hardware Requirements and Operational Reality
&lt;/h3&gt;

&lt;p&gt;The gap between "runs on my machine" and "runs reliably at 3am when nobody's watching" is mostly a resource-sizing problem. These three tools have genuinely different operational profiles — not just different RAM numbers, but different failure modes when those numbers get tight. The worst surprises come from components that aren't the main application: ClickHouse for Plausible, the archiving queue for Matomo, MySQL tuning for Umami under write pressure.&lt;/p&gt;

&lt;p&gt;Dimension&lt;/p&gt;

&lt;p&gt;Plausible CE&lt;/p&gt;

&lt;p&gt;Umami&lt;/p&gt;

&lt;p&gt;Matomo&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimum RAM (container stack)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;~512 MB app + ~1 GB ClickHouse = &lt;strong&gt;~1.5 GB realistic floor&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;~256 MB&lt;/strong&gt; app + whatever your existing Postgres/MySQL uses&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;~1 GB&lt;/strong&gt; PHP-FPM + MySQL under any real traffic&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idle disk write rate&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;ClickHouse merges parts in background; expect non-trivial I/O even at idle — not friendly to NVMe write-endurance budgets on small VPS&lt;/p&gt;

&lt;p&gt;Low — standard DB writes, no background compaction engine; quiet on low-traffic sites&lt;/p&gt;

&lt;p&gt;Archiving cron writes aggregates to MySQL; can spike I/O heavily during catch-up runs on busy sites&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tracker script payload&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;~1 KB&lt;/p&gt;

&lt;p&gt;~2 KB&lt;/p&gt;

&lt;p&gt;~22 KB default (configurable; a minimal build gets it lower, but requires deliberate effort)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database engine support&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;ClickHouse only (no swapping it out)&lt;/p&gt;

&lt;p&gt;PostgreSQL 12+ or MySQL 5.7+ — reuse existing infrastructure&lt;/p&gt;

&lt;p&gt;MySQL / MariaDB (primary); experimental Postgres support exists but isn't production-recommended&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single biggest operational dealbreaker&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ClickHouse overhead on a low-RAM VPS.&lt;/strong&gt; On a 1 GB or 2 GB node, ClickHouse competes with the Plausible app itself and will OOM under aggregation pressure. The community edition moved aggregations to ClickHouse — you can't opt out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reporting depth.&lt;/strong&gt; Umami gives you pageviews, referrers, devices, and custom events — but no funnel analysis, no goal tracking UI, no segmentation engine. If you outgrow the basics, you're either querying the DB directly or moving tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The archiving cron job.&lt;/strong&gt; Matomo doesn't aggregate in real-time — it batches via &lt;code&gt;core:archive&lt;/code&gt;. If the cron falls behind (high traffic, slow disk, misconfigured PHP memory limit), your dashboards show stale data and the unprocessed queue grows until you manually intervene or the next cron window opens.&lt;/p&gt;

&lt;p&gt;The archiving problem with Matomo deserves more attention than it usually gets. The cron command is:&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 as www-data or the PHP user, not root&lt;/span&gt;
/usr/bin/php /var/www/matomo/console core:archive &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://your-matomo-instance.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--php-cli-options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-d memory_limit=2048M"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;memory_limit&lt;/code&gt; is too low in the CLI context (separate from the web php.ini), the archiver silently exits mid-run. Dashboards look fine until you notice the "last processed" timestamp hasn't moved in six hours. The fix is trivial once you know it, but the failure is quiet — no alert, no error page, just stale numbers. Set up an external check on that timestamp or you will miss it.&lt;/p&gt;

&lt;p&gt;Umami's low RAM floor is the real differentiator for shared or budget hardware. If you're already running Postgres 15 or MySQL 8 for something else on the same host, Umami adds essentially nothing to your resource budget — just another schema in an existing instance. Plausible's ClickHouse dependency is non-negotiable in the current self-hosted CE, and ClickHouse behaves badly under memory pressure in ways that are hard to tune without deep ClickHouse knowledge. For anything under 4 GB total host RAM, Plausible CE becomes a risky choice unless you dedicate the box to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup to Production: Docker Compose Configs for Each
&lt;/h2&gt;

&lt;p&gt;The gap between "it runs" and "it runs correctly" is almost entirely in the config details the official docs bury or skip. Here's what each stack actually needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plausible CE: Three Services, One Footgun
&lt;/h3&gt;

&lt;p&gt;The official &lt;code&gt;docker-compose.yml&lt;/code&gt; spins up three containers — &lt;code&gt;plausible&lt;/code&gt;, &lt;code&gt;plausible_db&lt;/code&gt; (Postgres 16), and &lt;code&gt;clickhouse&lt;/code&gt; — and the ClickHouse one will silently OOM on a 1 GB VPS if you don't constrain it. The fix lives in a mounted XML config, not in compose env vars.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;# clickhouse-config.xml — mount this into the clickhouse container
&lt;span class="cp"&gt;&amp;lt;?xml version="1.0"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;clickhouse&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;max_server_memory_usage_to_ram_ratio&amp;gt;&lt;/span&gt;0.4&lt;span class="nt"&gt;&amp;lt;/max_server_memory_usage_to_ram_ratio&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/clickhouse&amp;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;# docker-compose.yml (Plausible CE — trimmed to the non-obvious bits)&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;plausible&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/plausible/community-edition:v2.1.1&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;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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;clickhouse&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;BASE_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://stats.yourdomain.com"&lt;/span&gt;
      &lt;span class="na"&gt;SECRET_KEY_BASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REPLACE_WITH_64_CHAR_HEX"&lt;/span&gt;   &lt;span class="c1"&gt;# openssl rand -hex 64&lt;/span&gt;
      &lt;span class="na"&gt;TOTP_VAULT_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REPLACE_WITH_BASE64_KEY"&lt;/span&gt;      &lt;span class="c1"&gt;# openssl rand -base64 32&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;127.0.0.1:8000:8000"&lt;/span&gt;

  &lt;span class="na"&gt;plausible_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;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;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;plausible_db&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;plausible&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;REPLACE_ME&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;pg_data:/var/lib/postgresql/data&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:24.3-alpine&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;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;./clickhouse-config.xml:/etc/clickhouse-server/config.d/memory.xml:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ch_data:/var/lib/clickhouse&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;pg_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ch_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SECRET_KEY_BASE&lt;/code&gt; and &lt;code&gt;TOTP_VAULT_KEY&lt;/code&gt; are both required on first run — omitting &lt;code&gt;TOTP_VAULT_KEY&lt;/code&gt; doesn't throw an obvious error, it just breaks TOTP enrollment silently. Generate them before you start the stack, not after. The &lt;code&gt;0.4&lt;/code&gt; ratio on ClickHouse caps it at roughly 400 MB on a 1 GB host; tune it up on bigger boxes but never skip it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Umami: The Lean Two-Service Option
&lt;/h3&gt;

&lt;p&gt;Umami's compose is genuinely simple — one app container, one database. The entire database config lives in a single &lt;code&gt;DATABASE_URL&lt;/code&gt; env var, which makes environment swaps trivial. It works with either Postgres or MySQL; Postgres is the better call if you're already running it for other services.&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;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-v2.13.2&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;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;umami_db&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://umami:REPLACE_ME@umami_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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REPLACE_WITH_RANDOM_STRING"&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;127.0.0.1:3000:3000"&lt;/span&gt;

  &lt;span class="na"&gt;umami_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;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;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;REPLACE_ME&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_pg:/var/lib/postgresql/data&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_pg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tracking script endpoint is &lt;code&gt;/script.js&lt;/code&gt; and the event collection endpoint is &lt;code&gt;/api/send&lt;/code&gt;. That second one is useful: you can POST custom events directly from an n8n HTTP Request node without touching the browser SDK at all. The payload is straightforward JSON — &lt;code&gt;{"type":"event","payload":{"website":"YOUR_WEBSITE_ID","url":"/","name":"custom_event"}}&lt;/code&gt; — which means any workflow that reaches an HTTP node can push telemetry into Umami. On my n8n flows I use this to track non-browser actions like newsletter sends or RSS fetch completions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Matomo: The Cron Container You Can't Skip
&lt;/h3&gt;

&lt;p&gt;Matomo's official images are &lt;code&gt;matomo:5-apache&lt;/code&gt; (simpler) and &lt;code&gt;matomo:5-fpm&lt;/code&gt; (pairs with a separate nginx container, slightly lower memory per request). The less-documented requirement is the archiver — without it, your reports are raw unprocessed logs and the UI shows stale or empty data. The exact command is:&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 every hour via cron or a dedicated compose service&lt;/span&gt;
php /var/www/html/console core:archive &lt;span class="nt"&gt;--url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://your-matomo-domain
&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="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;matomo&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;matomo:5-apache&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;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;matomo_db&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;MATOMO_DATABASE_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;matomo_db&lt;/span&gt;
      &lt;span class="na"&gt;MATOMO_DATABASE_DBNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;matomo&lt;/span&gt;
      &lt;span class="na"&gt;MATOMO_DATABASE_USERNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;matomo&lt;/span&gt;
      &lt;span class="na"&gt;MATOMO_DATABASE_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;REPLACE_ME&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;matomo_html:/var/www/html&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;127.0.0.1:8080:80"&lt;/span&gt;

  &lt;span class="na"&gt;matomo_cron&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;matomo:5-apache&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;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;matomo&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;matomo_html:/var/www/html&lt;/span&gt;   &lt;span class="c1"&gt;# shared volume so cron sees same config&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;sh -c "while true; do&lt;/span&gt;
        &lt;span class="s"&gt;php /var/www/html/console core:archive --url=https://your-matomo-domain;&lt;/span&gt;
        &lt;span class="s"&gt;sleep 3600;&lt;/span&gt;
      &lt;span class="s"&gt;done"&lt;/span&gt;

  &lt;span class="na"&gt;matomo_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;mariadb:11.4&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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;matomo&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;matomo&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;REPLACE_ME&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;REPLACE_ROOT_ME&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;matomo_db_data:/var/lib/mysql&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;matomo_html&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;matomo_db_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shared &lt;code&gt;matomo_html&lt;/code&gt; volume between the main container and the cron container is the key detail here — the archiver needs access to &lt;code&gt;config/config.ini.php&lt;/code&gt; which is written on first-run setup. If the cron container mounts a separate or empty volume, it finds no config and exits silently with a non-zero code that's easy to miss. MariaDB 11.4 is the current LTS and what Matomo's installer validates against; Postgres support in Matomo is officially experimental as of v5.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reverse Proxy: Where Each Tool Has Its Own Quirk
&lt;/h3&gt;

&lt;p&gt;All three sit cleanly behind Nginx or Caddy on &lt;code&gt;127.0.0.1&lt;/code&gt; ports, but each has a config requirement that trips people up in production. Plausible needs accurate &lt;code&gt;X-Forwarded-For&lt;/code&gt; headers or every visitor logs as your proxy's IP. Matomo has the same problem but fixes it in PHP config rather than relying purely on the proxy headers.&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;# Nginx — Plausible block (X-Forwarded-For is the critical line)&lt;/span&gt;
&lt;span class="k"&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://127.0.0.1:8000&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-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&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-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&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="p"&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 ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# Matomo: config/config.ini.php — add under [General]
# Without this, Matomo logs 127.0.0.1 as every visitor's IP
&lt;/span&gt;&lt;span class="nn"&gt;[General]&lt;/span&gt;
&lt;span class="err"&gt;trusted_proxies&lt;/span&gt;&lt;span class="nn"&gt;[]&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="err"&gt;"127.0.0.1"&lt;/span&gt;
&lt;span class="err"&gt;proxy_client_headers&lt;/span&gt;&lt;span class="nn"&gt;[]&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="err"&gt;"HTTP_X_FORWARDED_FOR"&lt;/span&gt;
&lt;span class="err"&gt;proxy_host_headers&lt;/span&gt;&lt;span class="nn"&gt;[]&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="err"&gt;"HTTP_X_FORWARDED_HOST"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caddy handles &lt;code&gt;X-Forwarded-For&lt;/code&gt; automatically when you use the &lt;code&gt;reverse_proxy&lt;/code&gt; directive — it's one of the reasons I prefer Caddy for new self-hosted stacks. With Nginx you have to be explicit every time. For Umami there's no special proxy config required; it reads client IPs from the forwarded headers without extra configuration as long as the header reaches the app container. One Nginx-specific gotcha with Plausible: if you're behind a CDN that also adds &lt;code&gt;X-Forwarded-For&lt;/code&gt;, the header can contain a comma-separated chain of IPs — Plausible reads the leftmost one, which is correct behavior, but confirm your CDN isn't mangling the header order before blaming geolocation accuracy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Non-Obvious Behaviors That Cost Time in Production
&lt;/h2&gt;

&lt;p&gt;The one that catches the most operators off guard with Plausible: ClickHouse will silently accept write requests and return HTTP 200s while Plausible is mid-write — but if the ClickHouse container restarts at that moment, you end up with an inconsistent state where Plausible's dashboard returns empty charts for that period. No error in the Plausible logs. No obvious failure. Just a gap. The fix is straightforward but non-obvious if you haven't been burned by it: set &lt;code&gt;restart: always&lt;/code&gt; on &lt;em&gt;both&lt;/em&gt; the Plausible &lt;em&gt;and&lt;/em&gt; ClickHouse containers in your Compose file, and add a real health check to ClickHouse that uses &lt;code&gt;clickhouse-client&lt;/code&gt; rather than a generic TCP ping:&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;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:24.3&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;always&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"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clickhouse-client"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--query"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&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;10s&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;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;

  &lt;span class="na"&gt;plausible&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/plausible/community-edition:v2.1.4&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;always&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;clickhouse&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_healthy&lt;/code&gt; is the part most Docker Compose tutorials skip. Without it, Plausible starts, tries to connect before ClickHouse is actually ready to serve queries, and you get a subtler version of the same gap problem — especially after host reboots.&lt;/p&gt;

&lt;p&gt;Umami's event tracking API is deceptively permissive. You can POST arbitrary JSON to the &lt;code&gt;/api/send&lt;/code&gt; endpoint and the server will accept it without complaint. The trap is that the default Umami dashboard UI only surfaces &lt;code&gt;string&lt;/code&gt; and &lt;code&gt;number&lt;/code&gt; property values — anything else either gets coerced silently or ignored at render time. If you're trying to replicate GA's custom dimensions behavior and you want to filter or segment by those properties, you'll hit a wall in the UI almost immediately. The actual data is in the PostgreSQL &lt;code&gt;website_event&lt;/code&gt; and &lt;code&gt;event_data&lt;/code&gt; tables, and querying it directly is the only real path to GA-style segmentation. A query that surfaces custom string properties for a given event name 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;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;we&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;we&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;number_value&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;website_event&lt;/span&gt; &lt;span class="n"&gt;we&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;event_data&lt;/span&gt; &lt;span class="n"&gt;ed&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;website_event_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;we&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;we&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;website_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your-website-uuid'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;we&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'purchase'&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;we&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Matomo's archiving cron is the most production-hostile behavior of the three. The archiving process is stateful — Matomo tracks which periods have been archived in the database, and if a cron run fails partway through (the most common cause being PHP hitting its default &lt;code&gt;memory_limit&lt;/code&gt; of 128M on sites with any real traffic), the next scheduled run re-attempts the same period from scratch. Two back-to-back failures on a busy day means the third run tries to process three periods simultaneously. The load compounds fast. The fix requires two things: bump &lt;code&gt;memory_limit = 256M&lt;/code&gt; in your &lt;code&gt;php.ini&lt;/code&gt; (512M if you have months of historical data being re-archived), and route cron output to a file you can actually grep:&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;# in crontab -e&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt;/15 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; www-data /usr/bin/php /var/www/matomo/console core:archive &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://matomo.yourdomain.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/matomo/archive.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that log file, you're flying blind. Matomo's UI will show "archiving in progress" indefinitely if the cron process dies mid-run, and there's no dashboard alert for it. Tail that log during the first week after any Matomo upgrade — the memory pressure almost always spikes after schema migrations.&lt;/p&gt;

&lt;p&gt;All three tools share one unavoidable problem: Firefox with Enhanced Tracking Protection enabled and Brave with default shield settings will block the tracking scripts before they reach the browser. This isn't a minor edge case — on a developer or privacy-conscious audience, a meaningful chunk of your actual visitors simply won't register. Plausible's official docs include a working Nginx proxy snippet that routes the script and event endpoint through your own domain:&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;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/js/script.js&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://plausible.io/js/script.js&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;plausible.io&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/api/event&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://plausible.io/api/event&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;plausible.io&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_buffering&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&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;Umami sidesteps the proxy complexity entirely if you're self-hosting: since Umami runs on your own infrastructure, you can mount it at a path on your primary domain (e.g., &lt;code&gt;yourdomain.com/stats/&lt;/code&gt;) and serve the tracking script from there. Browsers treating first-party paths as tracking domains are rare enough that this approach works in practice. Matomo also supports first-party cookie mode and a self-hosted script path, but the configuration is buried in the Tag Manager settings and the documentation assumes you already know where to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Pick What: Matching the Tool to the Actual Situation
&lt;/h2&gt;

&lt;p&gt;The actual decision between these tools comes down to operational cost and data model requirements, not feature checklists. Running any of these for a few days reveals the real constraints faster than any comparison table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Plausible&lt;/strong&gt; when you want the shortest path from "deployed" to "trustworthy data" — no cookie banner, no consent dialog, no GDPR checkbox engineering. The ClickHouse container is the honest constraint: expect the pair (Plausible + ClickHouse) to sit comfortably inside 2 GB RAM once warmed up, but plan for spiky memory during ClickHouse compaction if you're on a shared VPS. Once stable, it's the least operationally demanding of the three — ClickHouse rarely needs tuning at typical blog-scale traffic and the Plausible UI has no configuration surface to break. If your audience is technical or privacy-aware enough that they run uBlock or Brave, you're also going to see fewer gaps in your data than with anything cookie-dependent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Umami&lt;/strong&gt; if you already have a running Postgres 14+ or MySQL 8 instance and don't want to add another database engine to your stack. The setup is genuinely fast — single Docker image, one &lt;code&gt;DATABASE_URL&lt;/code&gt; env var, done. The bigger reason to pick Umami over Plausible is the &lt;code&gt;/api/send&lt;/code&gt; endpoint: it accepts a plain POST with a JSON payload, which means your n8n flows or any server-side automation can push custom events without a browser involved. The footgun is real though — by default that endpoint has no authentication, so anyone who finds your tracker URL can inject events. Set &lt;code&gt;DISABLE_TELEMETRY=1&lt;/code&gt; and put a reverse-proxy auth layer in front before you expose this publicly:&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;# nginx snippet — drop requests to /api/send that don't carry your shared secret&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/send&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# only allow your own automation; block browsers you don't control&lt;/span&gt;
    &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$http_x_tracker_secret&lt;/span&gt; &lt;span class="s"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"your-secret-here")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&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://umami:3000&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;&lt;strong&gt;Pick Matomo&lt;/strong&gt; when the data you need simply doesn't fit into a page-view counter — goal funnels, e-commerce revenue rows, session recordings, or raw SQL access to the &lt;code&gt;log_visit&lt;/code&gt; table for custom reporting. The honest trade-off: you're not running a container, you're running a PHP application with a MySQL/MariaDB backend, a cron job for archiving, and a plugin ecosystem that can drift. Migrating from Universal Analytics is the strongest case for Matomo — the goal and funnel data model maps closely enough that you don't have to redesign your measurement strategy from scratch. Accept the operational weight and you get the most complete local data store of the three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip all three&lt;/strong&gt; and route to GoAccess on your Nginx access logs if your situation is any of: you have zero JavaScript budget (AMP pages, email-linked landing pages, API-only services), you're trying to measure traffic that a JS snippet would miss entirely (RSS fetches, direct API consumers, bot traffic you actually want to count), or you just need referrer and page-view counts without standing up a database. GoAccess parses compressed log archives, outputs a self-contained HTML report, and adds nothing to your request path:&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 real-time HTML report from rotated Nginx logs&lt;/span&gt;
zcat /var/log/nginx/access.log.&lt;span class="k"&gt;*&lt;/span&gt;.gz | &lt;span class="se"&gt;\&lt;/span&gt;
  goacccess /var/log/nginx/access.log - &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;COMBINED &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/www/html/stats/report.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that in a PM2-managed cron and you have a zero-dependency analytics page that survives anything the other three tools won't — database crashes, container OOM kills, schema migrations that break on PHP version bumps. The data is coarser, but for a mostly-server-side or API workload it's more accurate than anything that relies on a browser firing a JavaScript beacon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ongoing Ops: Backups, Updates, and Staying Out of Trouble
&lt;/h2&gt;

&lt;p&gt;The part that catches most people is assuming analytics data is recoverable after a bad upgrade. It usually isn't — not cleanly. By the time you notice the graphs stopped updating, the window for a clean rollback is already closed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plausible: Two Databases, Two Failure Modes
&lt;/h3&gt;

&lt;p&gt;Plausible splits its state across Postgres and ClickHouse, and losing either one breaks the stack in completely different ways. Postgres holds user accounts, site configs, and API keys — lose it and the app won't boot cleanly. ClickHouse holds every pageview event — lose it and your dashboards are blank forever. Back up both, separately, on different schedules if you have to.&lt;/p&gt;

&lt;p&gt;For Postgres, standard &lt;code&gt;pg_dump&lt;/code&gt; is fine:&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 cron or an n8n schedule node&lt;/span&gt;
pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; plausible &lt;span class="nt"&gt;-d&lt;/span&gt; plausible_db | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /backups/plausible-pg-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For ClickHouse, the cleanest option if you're running it in Docker is a volume snapshot at the host level, or &lt;code&gt;clickhouse-backup&lt;/code&gt; if you want something scriptable:&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;# clickhouse-backup create — backs up to /var/lib/clickhouse/backup/&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;clickhouse clickhouse-backup create plausible-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# Then rsync or rclone that directory off the host&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;clickhouse-backup&lt;/code&gt; tool requires a config file at &lt;code&gt;/etc/clickhouse-backup/config.yml&lt;/code&gt; inside the container. The docs skip that step and the binary just silently exits if the config is missing — check &lt;code&gt;docker logs&lt;/code&gt; before trusting that the backup ran.&lt;/p&gt;

&lt;h3&gt;
  
  
  Umami: Simpler Stack, But Pin Your Image Tag
&lt;/h3&gt;

&lt;p&gt;Umami is fully stateless on the app side — everything lives in Postgres, so a daily &lt;code&gt;pg_dump&lt;/code&gt; is genuinely sufficient. What will burn you isn't the backup strategy, it's the image tag. The &lt;code&gt;latest&lt;/code&gt; tag on &lt;code&gt;ghcr.io/umami-software/umami&lt;/code&gt; has shipped breaking schema changes on minor bumps without warning. Pin to a specific release in your &lt;code&gt;docker-compose.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;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-v2.12.0&lt;/span&gt;
    &lt;span class="c1"&gt;# Do NOT use :latest in production&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;When you want to upgrade, pull the changelog manually, bump the tag explicitly, and let Umami's built-in migration run on first boot with the new image. Rolling back means just reverting the tag and restoring the Postgres dump — the stateless app layer makes this fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  Matomo: The Silent Migration Corruption Problem
&lt;/h3&gt;

&lt;p&gt;Matomo is the most operationally fragile of the three on upgrades. Its database migration on version bumps touches archive tables, and if the migration fails partway through — which happens more often than the docs admit — it won't tell you loudly. The dashboards will load, the data will look present, and two weeks later you'll notice your historical reports are returning zeros or throwing SQL errors on aggregation queries.&lt;/p&gt;

&lt;p&gt;Before any Matomo version upgrade, two things are non-negotiable. First, dump both the database and the config directory:&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;# MariaDB/MySQL dump&lt;/span&gt;
mysqldump &lt;span class="nt"&gt;-u&lt;/span&gt; matomo &lt;span class="nt"&gt;-p&lt;/span&gt; matomo_db | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /backups/matomo-db-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql.gz

&lt;span class="c"&gt;# Config dir — holds local.php with DB credentials and installed plugin state&lt;/span&gt;
&lt;span class="nb"&gt;tar &lt;/span&gt;czf /backups/matomo-config-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.tar.gz /var/www/html/config/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, hit the pre-update check before touching the image tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://your-matomo-host/index.php?module=CoreUpdater
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That endpoint will tell you if the pending migration has any prerequisites it can't meet. Skip it and you're gambling. The Matomo upgrade docs mention it in passing; treat it as mandatory.&lt;/p&gt;

&lt;h3&gt;
  
  
  All Three: You Need an Uptime Monitor on the Analytics Endpoint
&lt;/h3&gt;

&lt;p&gt;Analytics data gaps are invisible until they're not. A container that OOMed at 3am, a ClickHouse writer that deadlocked and stopped accepting events, a Matomo cron that quietly stopped archiving — none of these will throw an obvious error. Your site keeps loading, users keep visiting, and the data just stops. You'll notice days later when a graph goes flat.&lt;/p&gt;

&lt;p&gt;Uptime Kuma runs well in the same Docker network and adds negligible overhead. Point an HTTP monitor at each analytics ingestion endpoint, not just the dashboard UI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Plausible:&lt;/strong&gt; &lt;code&gt;GET /api/event&lt;/code&gt; returns a 202 on a valid payload — monitor that, not the homepage&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Umami:&lt;/strong&gt; &lt;code&gt;/api/send&lt;/code&gt; endpoint; a 400 on a malformed payload still confirms the app is alive&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Matomo:&lt;/strong&gt; &lt;code&gt;/matomo.php?idsite=1&amp;amp;rec=1&lt;/code&gt; — a 204 response means the tracker is accepting hits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set the alert interval tight — five minutes is reasonable. A 24-hour gap in analytics data is already painful to explain; a week-long gap because nobody checked is a data loss event.&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/self-hosted-analytics-without-google-plausible-umami-and-matomo-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>docker</category>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
    </item>
    <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>
  </channel>
</rss>
