Grafana + Prometheus Homelab Stack
The first time your NAS fills up and silently drops writes, or your UPS battery dies the night before a power outage takes out your server, you learn the lesson: the homelab is not a toy environment, it just doesn’t have an on-call team. The machines don’t care that you’re asleep. The disk doesn’t warn you it’s 98% full before it starts corrupting files, and the switch port that went half-duplex three weeks ago isn’t going to file a ticket about the 40% packet loss you haven’t noticed yet.
Observability is the answer, but “observability” is a word that gets thrown around in a lot of contexts. What it means practically for a homelab is this: you want to be paged when something breaks, ideally before something else breaks as a consequence. You want to open a dashboard and immediately understand the state of your infrastructure. You want to look at a graph from two weeks ago and understand what was happening when that backup job started failing.
This guide builds a full observability stack from scratch: Prometheus for metric collection and storage, a suite of exporters to instrument every layer of the stack, Alertmanager for routing notifications, Grafana for visualization and provisioned dashboards, and options for long-term storage when 15 days of retention stops being enough.
Why Metrics, Why Prometheus
The “three pillars of observability” framing — metrics, logs, traces — is accurate but can obscure priorities. For a homelab, traces are largely irrelevant: you’re not debugging distributed transaction latency across microservices. Logs are useful but reactive; you read them after something breaks. Metrics with alerting is where you get the most value per unit of operational effort. A handful of well-chosen alerts on node disk usage, memory pressure, service availability, and certificate expiry covers the failure modes you’re actually likely to hit.
Prometheus is the right tool for this job. It was built from the ground up for exactly this use case — instrumenting infrastructure and services, collecting metrics on a pull schedule, and evaluating alert conditions against those metrics. The ecosystem of exporters is unmatched. The query language (PromQL) is expressive enough to compute anything interesting from the raw data. And the entire stack runs comfortably on a single machine with a few gigabytes of RAM.
The Prometheus data model is worth understanding before you start writing scrape configs. Every piece of data in Prometheus is a time series: a unique combination of a metric name and a set of key-value label pairs, with a sequence of timestamped float64 samples. The metric node_cpu_seconds_total{cpu="0",mode="idle",instance="homeserver:9100",job="node"} is one time series. Change any label value and you have a different time series. This is powerful — it lets you write a single query that aggregates across all CPU cores, all instances, or all jobs — but it also means that labels with high cardinality (many unique values, like container IDs or request paths) can explode your time series count.
Prometheus has four metric types:
- Counter: A monotonically increasing value. Resets to zero on restart. Used for request counts, bytes transferred, errors. Always query with
rate()orincrease(), never the raw value. - Gauge: A value that can go up or down. Temperature, memory usage, queue depth. Query directly or with
avg_over_time(). - Histogram: Samples observations into configurable buckets. Used for latency and request size distributions. Enables
histogram_quantile()for percentile calculations. - Summary: Pre-computes quantiles on the client side. Less flexible than histograms for aggregation. Generally prefer histograms for new instrumentation.
The pull model is what distinguishes Prometheus from older monitoring systems like Graphite or InfluxDB (in its traditional push configuration). Prometheus scrapes HTTP /metrics endpoints on a schedule. The targets don’t send data to Prometheus; Prometheus comes to them. This means the monitoring system has a complete, authoritative list of what it’s monitoring. If a target disappears from the scrape config, Prometheus stops collecting data for it. If a target’s /metrics endpoint stops responding, Prometheus records a scrape failure. There’s no silent data gap caused by a metrics agent that crashed quietly on the monitored host.
The Full Stack
Before diving into each component, here’s the architecture we’re building:
HOMELAB OBSERVABILITY STACK
─────────────────────────────────────────────────────────────────────
Physical / VM Hosts Network Devices
┌─────────────────────┐ ┌──────────────────────┐
│ node_exporter │ │ Router / Switch │
│ :9100/metrics │ │ NAS (Synology) │
│ (CPU, mem, disk, │ │ UPS (APC) │
│ net, fs, sensors) │ │ speak SNMP only │
└──────────┬──────────┘ └──────────┬───────────┘
│ scrape │ SNMP poll
┌──────────▼──────────┐ ┌──────────▼───────────┐
│ cAdvisor │ │ snmp_exporter │
│ :8080/metrics │ │ :9116/metrics │
│ (per-container │ │ (interface stats, │
│ CPU/mem/net) │ │ UPS battery, etc.) │
└──────────┬──────────┘ └──────────┬───────────┘
│ │
│ HTTP probe targets │ scrape
│ ┌─────────────────────────┐ │
│ │ blackbox_exporter │ │
│ │ :9115/probe │ │
│ │ (HTTP, ICMP, DNS, │ │
│ │ TCP, TLS cert expiry) │ │
│ └──────────┬──────────────┘ │
│ │ │
┌──────────▼──────────────▼─────────────────────▼──────────┐
│ PROMETHEUS │
│ :9090 /metrics /-/reload │
│ │
│ scrape_interval: 15s evaluation_interval: 15s │
│ TSDB: /prometheus (15-30 day local retention) │
│ │
│ alerting rules ─────────────┐ │
└──────────────────────────────┼───────────────────────────┘
│ firing alerts
┌────────────▼────────────┐
│ ALERTMANAGER │
│ :9093 │
│ route → receivers │
│ group / dedup / silence │
└──────┬──────────────────┘
│
┌────────────┴──────────────┐
│ │
┌────▼─────┐ ┌──────▼──────┐
│ Slack │ │ Email │
│ webhook │ │ SMTP │
└──────────┘ └─────────────┘
┌─────────────────────────┐
│ GRAFANA │
│ :3000 │
│ datasource: Prometheus │
│ provisioned dashboards │
│ unified alerting │
└─────────────────────────┘
Every component in this diagram runs as a Docker container. The entire stack is defined in a single docker-compose.yml, which means you can tear it down and rebuild it in under two minutes, move it to a different host, or version-control the entire configuration in git.
Deploying with Docker Compose
Version pinning is non-negotiable for a stable homelab stack. Using latest tags means your stack can break on a Monday morning when the upstream image is updated over the weekend. Pin to specific versions and upgrade deliberately.
As of late May 2026, the relevant versions are: Prometheus 3.x (v3.11.3 is the latest patch, v3.5.x is the current LTS branch), Grafana 13.0.1, Alertmanager 0.32.1, Node Exporter 1.11.1, cAdvisor 0.57.0. The Compose file below uses these versions.
|
|
A few design decisions worth noting. Node Exporter uses network_mode: host because it needs to see the host’s network interfaces accurately — running it behind Docker’s bridge NAT gives you wrong interface metrics. The pid: host flag is needed so it can read /proc data for host processes rather than the container’s process namespace. cAdvisor requires privileged: true and /dev/kmsg to read cgroup statistics for all containers.
Grafana’s admin password is read from an environment variable, not hardcoded. Keep a .env file in the stack directory:
|
|
Prometheus is exposed on port 9090 without authentication. This is fine if you’re behind a firewall, but if Grafana is internet-facing, put both Prometheus and Alertmanager behind a reverse proxy (Caddy or Traefik) with basic auth or OAuth. Exposing an unauthenticated Prometheus endpoint to the public internet means anyone can read all your metrics, which includes hostnames, service names, and IP addresses from your internal network.
The --web.enable-lifecycle flag on Prometheus enables the /-/reload endpoint, letting you reload the configuration without restarting the container:
|
|
Always validate the config before reloading:
|
|
A failed reload leaves the old configuration running. A failed restart leaves Prometheus down. Always check before reloading.
Prometheus Configuration
The prometheus.yml is the nerve center of the stack. It defines how often to scrape, where to send alerts, and what to scrape. Here is a complete configuration for the stack above:
|
|
The multi-target pattern used by Blackbox Exporter and SNMP Exporter deserves explanation because it’s non-obvious. These exporters don’t scrape themselves — they act as HTTP-to-probe translators. When Prometheus scrapes /probe?target=https://grafana.yourdomain.local&module=http_2xx, the blackbox exporter performs that HTTP probe and returns the result as a metrics response. The relabel configuration in the scrape config rewrites __address__ (which Prometheus uses as the scrape URL host) to point at the exporter, while copying the original target address into __param_target. The instance label ends up as the probed URL, not the exporter address, which is what you want for dashboards and alerts.
Recording rules let you pre-compute expensive queries. Instead of computing a 5-minute CPU rate across all cores every time Grafana loads a dashboard panel, you compute it once per evaluation interval and store the result as a new time series. This is especially valuable for aggregations that span many series.
|
|
Node Exporter
Node Exporter is the canonical way to get Linux host metrics into Prometheus. It exposes everything the kernel knows about the system: CPU utilization broken down by mode and core, memory in extraordinary detail (total, free, available, buffers, cached, swap), disk I/O per block device (reads, writes, IOPS, latency, utilization), network statistics per interface (bytes, packets, errors, drops), filesystem usage per mount point (bytes, inodes), system load averages, NTP clock offset, and hardware sensor readings via hwmon (temperatures, fan speeds, voltages).
Running Node Exporter as a Docker container works but requires careful volume mounts to see the host’s actual metrics rather than the container’s view. The complete set of required mounts:
/:/host:ro,rslave → rootfs, accessed via --path.rootfs=/host
/proc → (via rootfs mount)
/sys → (via rootfs mount)
network_mode: host → see actual network interfaces
pid: host → see actual host PIDs for process metrics
Running it as a systemd service is simpler and avoids these mount complications. For a homelab where the host runs a stable distro:
|
|
The textfile collector is one of Node Exporter’s most underappreciated features. Any .prom file you drop into the configured textfile directory gets included in the /metrics output. This means any script can export custom metrics without running its own HTTP server. RAID status, backup job results, disk SMART data, ZFS pool health — anything a script can determine can be a Prometheus metric:
|
|
Essential PromQL queries for Node Exporter data:
|
|
The network filter device!~"lo|veth.*|docker.*|br.*" deserves a note. On a Docker host, Node Exporter will expose metrics for every Docker bridge interface and every veth pair. A system with dozens of containers will have dozens of virtual interfaces. Filter them out at query time or with metric relabeling in the scrape config to avoid polluting your dashboards.
cAdvisor
cAdvisor (Container Advisor) is Google’s tool for collecting per-container resource metrics. It automatically discovers all running Docker containers by watching the Docker socket and reads their cgroup statistics directly. Without cAdvisor, you know your Docker host is using 80% of its memory but you don’t know which container is responsible.
The key metrics from cAdvisor:
|
|
When using Docker Compose, the container_label_com_docker_compose_service label gives you the service name, which is more readable than the container name or container ID:
|
|
Cardinality warning: cAdvisor generates a lot of time series. Each container gets metrics for each of its network interfaces, each of its filesystems, and CPU in fine-grained detail. On a host running 50 containers, cAdvisor can easily produce 5,000-10,000 time series. Most of this is legitimate data you want, but be aware that short-lived containers (CI runners, batch jobs) will create time series that sit in the TSDB until the retention window expires, consuming memory even after the container is gone. The metric_relabel_configs drop rules in the Prometheus scrape config above are the right place to prune metrics you’ll never use.
Blackbox Exporter
Blackbox Exporter shifts your monitoring perspective from inside-out to outside-in. Node Exporter tells you the CPU is fine and the process is running. Blackbox Exporter tells you whether the service is actually reachable and returning valid responses from the perspective of the network path Prometheus sits on.
This matters in the homelab because you can have a container that is running fine (good CPU, good memory, process alive) but is not serving requests because a configuration file is malformed, a dependency is down, or a reverse proxy misconfiguration is returning 502s. Node Exporter and cAdvisor won’t see that. Blackbox Exporter will.
The blackbox.yml configuration defines probe modules:
|
|
The ICMP module requires either --privileged or the CAP_NET_RAW capability. The cap_add: [NET_RAW] in the Compose file above handles this without full privilege escalation.
Key metrics from Blackbox Exporter:
|
|
The SSL certificate expiry metric is one of the most practical alerts you can configure. A certificate expiring in 14 days is not an emergency but it is a thing you want to know about before users start seeing browser warnings. The alert for this is in the Alertmanager section below.
SNMP Exporter
SNMP is the protocol that lets you monitor devices you can’t install agents on — routers, managed switches, NASes, and UPS units. These devices speak SNMP natively, and the SNMP Exporter acts as a translator: Prometheus scrapes the SNMP Exporter, which in turn polls the target device via SNMP and translates the response into Prometheus metrics.
SNMP versions: v1 and v2c use a community string (typically “public” or something custom) as the only authentication mechanism. This community string is transmitted in cleartext. Use v3 if your devices support it — v3 adds authentication (MD5/SHA) and encryption (DES/AES). Most modern devices (UniFi, Cisco, Synology) support SNMPv3. The snmp.yml configuration file handles this per-module.
The snmp.yml file is not written by hand. The SNMP Exporter ships with a generator that takes MIB files and a generator config, and produces a snmp.yml that contains the OID-to-metric mappings. For common devices, pre-generated files exist in the community or the snmp_exporter repository:
|
|
The scrape configuration follows the same multi-target pattern as Blackbox Exporter. The key metrics from the if_mib module (standard interface stats available on virtually all SNMP-capable devices):
|
|
The UPS metrics deserve special attention. An alert on upsAdvBatteryCapacity < 50 means you know when the battery is degrading before a power event reveals it by failing to provide runtime. An alert on upsAdvBatteryRunTimeRemaining < 300 (less than 5 minutes) during an actual power outage gives you time to initiate a clean shutdown.
Alertmanager
Prometheus evaluates alerting rules on each evaluation interval and sends any firing alerts to Alertmanager. Alertmanager’s job is everything that happens next: deduplication (the same alert firing from multiple sources shouldn’t generate multiple pages), grouping (ten services going down at once is one incident, not ten pages), routing (critical disk alerts go to one receiver, informational alerts go to another), silencing (mute everything for the next two hours while you’re doing planned maintenance), and inhibition (don’t send CPU high alerts for a host that’s already been paged as unreachable).
The alerting rules file:
|
|
The complete alertmanager.yml with Slack and email receivers:
|
|
Inhibition rules are the feature most people discover after getting paged fifteen times for the same underlying failure. The source_match defines the alert that suppresses, target_match defines what gets suppressed, and equal defines which label values must match between source and target. When TargetDown fires for instance="homeserver", all other alerts with instance="homeserver" are inhibited. You get one page for the root cause, not a cascade of symptom alerts.
Silences are temporary mutes that you create through the Alertmanager UI or API. During planned maintenance — OS updates, hardware swaps, network reconfiguration — create a silence for the affected instance for the duration of the maintenance window:
|
|
Grafana Configuration and Provisioning
The first mistake most people make with Grafana is configuring everything through the UI and then losing it all when they recreate the container. Grafana’s persistent volume stores everything — if you’ve mounted a volume properly, your data survives. But you want something better than a binary blob in a Docker volume: you want your configuration in version-controlled YAML files.
Grafana’s provisioning system lets you define datasources, dashboards, alert rules, and notification policies as YAML files that Grafana reads at startup. Mount these files into /etc/grafana/provisioning/ and Grafana configures itself automatically.
Datasource provisioning:
|
|
Dashboard provisioning works in two parts: a folder configuration file that defines where dashboards live, and the dashboard JSON files themselves.
|
|
Place your dashboard JSON files in config/grafana/provisioning/dashboards/json/. To use a community dashboard, download the JSON from grafana.com or use the ID import:
Node Exporter Full : https://grafana.com/grafana/dashboards/1860
cAdvisor Exporter : https://grafana.com/grafana/dashboards/14282
Prometheus Blackbox Exporter : https://grafana.com/grafana/dashboards/7587
These IDs are stable and remain valid. Download the JSON, save it to your dashboards directory, and Grafana will load it automatically.
Dashboard variables make dashboards dynamic. Instead of hardcoding instance="homeserver" in every query, define a $instance variable that populates from a Prometheus label query. Edit the dashboard settings (gear icon) → Variables → New:
Type: Query
Name: instance
Label: Instance
Data source: Prometheus
Query: label_values(node_uname_info, instance)
Refresh: On time range change
Now every panel in the dashboard can use {instance="$instance"} in its PromQL, and the dropdown at the top of the dashboard lets you switch between hosts without modifying any queries.
Grafana Unified Alerting vs Prometheus alerting rules: Grafana 8+ introduced its own alerting system that evaluates rules against any datasource. Prometheus alerting rules (evaluated by Prometheus itself) are better for infrastructure-level alerts — they fire even when Grafana is down, they’re colocated with the rest of your Prometheus config, and they’re easier to test with promtool. Grafana alerts are better for dashboard-level alerts that involve multi-datasource queries or when you want alert annotations to appear directly on panel graphs. For a homelab, keep infrastructure alerts in Prometheus and use Grafana alerts sparingly, if at all.
Exporter Reference
| Exporter | Monitors | Key Metrics | Typical Port |
|---|---|---|---|
| node_exporter | Linux host (CPU, RAM, disk, net, fs) | node_cpu_seconds_total, node_memory_MemAvailable_bytes, node_filesystem_avail_bytes |
9100 |
| cAdvisor | Docker containers (per-container resource usage) | container_cpu_usage_seconds_total, container_memory_working_set_bytes |
8080 |
| blackbox_exporter | HTTP, HTTPS, ICMP, TCP, DNS probes | probe_success, probe_duration_seconds, probe_ssl_earliest_cert_expiry |
9115 |
| snmp_exporter | Network devices, NAS, UPS via SNMP | ifHCInOctets, ifOperStatus, upsAdvBatteryCapacity |
9116 |
| process_exporter | Individual named processes | namedprocess_namegroup_cpu_seconds_total, namedprocess_namegroup_memory_bytes |
9256 |
| postgres_exporter | PostgreSQL databases | pg_stat_database_tup_fetched, pg_up, pg_stat_bgwriter_* |
9187 |
| redis_exporter | Redis instances | redis_connected_clients, redis_memory_used_bytes, redis_commands_processed_total |
9121 |
| blackbox (DNS) | DNS resolution from Prometheus’s perspective | probe_dns_lookup_time_seconds, probe_success |
9115 |
Dashboard-as-Code with Grafonnet
Once you have more than a handful of dashboards, the pain of managing them as exported JSON becomes apparent. JSON is not diffable in any meaningful sense — a single panel change produces a multi-hundred-line diff. You can’t write a function that generates a row of panels, one per host. You can’t abstract a common panel configuration and reuse it across dashboards. Grafonnet addresses this by letting you define dashboards in Jsonnet — a data templating language that is a strict superset of JSON with functions, imports, and arithmetic.
Grafonnet is generated from the Grafana Foundation SDK, which means it tracks the current Grafana schema. The original grafonnet-lib is deprecated; use the new grafonnet library:
|
|
A minimal dashboard with a time series panel and a stat panel:
|
|
Build the dashboard JSON and place it in your Grafana provisioning directory:
|
|
Alternatives to Grafonnet:
- grafana-foundation-sdk: The official Grafana SDK, available in multiple languages (Go, TypeScript, Python). Generates the same output as Grafonnet but in your language of choice. More verbose but no Jsonnet dependency.
- Grizzly (
grr): A CLI tool for pushing/pulling Grafana resources (dashboards, datasources, alert rules) as YAML or Jsonnet. Good for keeping dashboards in sync across environments. - Terraform + Grafana provider: Works well if you’re already managing infrastructure with Terraform. The
grafana_dashboardresource takes a JSON template. Less elegant for parameterization than Grafonnet but integrates naturally with IaC workflows.
Honestly, for a solo homelab, Grafonnet is overkill unless you enjoy it or have more than ~20 dashboards. The provisioning workflow — download community dashboard JSON, put it in the directory, done — covers most needs. Grafonnet pays off at the team level, when multiple people are modifying dashboards and code review is part of the workflow.
Long-Term Storage
Prometheus’s local TSDB has a hard limit: it’s designed for short-to-medium-term data on a single machine. The default retention is 15 days. You can extend this to 30, 60, or 90 days with --storage.tsdb.retention.time, but you’re still on a single node with no replication, and memory usage grows proportionally with the number of active time series multiplied by the retention window. At some point you want years of data — for capacity planning, trend analysis, or just answering “was my disk I/O this bad last winter?”
The four realistic options are local Prometheus with extended retention, Thanos, Grafana Mimir, and VictoriaMetrics.
Thanos
Thanos wraps existing Prometheus instances. You don’t replace Prometheus; you add Thanos components around it. The sidecar runs alongside each Prometheus instance, reads the local TSDB blocks that Prometheus produces every two hours, and uploads them to object storage (S3, GCS, or MinIO for self-hosting). The Store Gateway exposes those uploaded blocks via Thanos’s gRPC store API. The Querier aggregates queries across multiple Prometheus instances and the Store Gateway, providing a unified query interface that spans years of data.
THANOS ARCHITECTURE
┌─────────────────────────────────────────────────────────┐
│ Grafana / Query Frontend │
└───────────────────────────┬─────────────────────────────┘
│ PromQL queries
┌─────────▼──────────┐
│ Thanos Querier │
│ (global view, │
│ deduplication) │
└──┬────────────┬────┘
│ │
┌────────────▼──┐ ┌──────▼─────────────┐
│ Thanos Sidecar│ │ Thanos Store Gateway│
│ (recent 2h) │ │ (historical blocks) │
└────────┬──────┘ └──────────┬──────────┘
│ │ reads blocks
┌────────▼──────┐ ┌──────────▼──────────┐
│ Prometheus │ │ Object Storage │
│ (scraping, │ │ (MinIO / S3) │
│ local TSDB) │ │ │
└───────────────┘ │ [2h blocks, compacted│
│ 1d, 2h downsampled,│
│ 5m downsampled] │
┌─────────┴──────────┐
│ Thanos Compactor │
│ (offline, runs │
│ periodically) │
└────────────────────┘
The Compactor runs periodically (not continuously) and does two things: it merges small 2-hour blocks into larger 1-day blocks (compaction), and it creates downsampled versions at 5-minute and 1-hour resolution (downsampling). Downsampling means a 2-year query doesn’t have to process millions of individual 15-second samples — Grafana uses the appropriate resolution for the time range.
The Thanos Compose setup is more complex but not overwhelming:
|
|
The object store configuration:
|
|
Update Grafana’s datasource to point at the Thanos Querier (http://thanos-query:9091) instead of Prometheus directly, and you get a unified query interface with full historical access.
Grafana Mimir
Mimir is Grafana Labs’ horizontally scalable, multi-tenant, Prometheus-compatible TSDB. It’s what powers Grafana Cloud’s metrics backend. In monolithic mode (single binary, single process), it runs comfortably on a single machine and is simple enough for a homelab that wants years of retention without the sidecar complexity of Thanos.
Configure Prometheus to remote-write to Mimir:
|
|
Mimir stores blocks in object storage (MinIO works). You don’t need a separate sidecar, store gateway, or querier — Mimir handles all of this internally. The tradeoff is that you’re duplicating your storage: Prometheus keeps its local TSDB for fast recent queries, and Mimir has its own copy in object storage for long-term retention. With Thanos, Prometheus is still the source of truth and Thanos uploads its existing blocks; with Mimir remote_write, you’re writing the data twice.
VictoriaMetrics
VictoriaMetrics is the option that most people overlook and many people end up wishing they’d started with. It is a drop-in replacement for Prometheus: it accepts Prometheus remote_write, speaks PromQL (with extensions), and Grafana connects to it as a Prometheus datasource. It stores data roughly 7-10x more compactly than Prometheus and queries faster.
Single-node VictoriaMetrics is genuinely simple:
|
|
Point Prometheus remote_write at http://victoriametrics:8428/api/v1/write and point Grafana at http://victoriametrics:8428 as a Prometheus datasource. You get two years of metrics with no object storage, no sidecar, no compactor, and no Querier — just one container. The cluster version scales horizontally for multi-node deployments, but for a homelab that’s unnecessary.
Choosing Your Long-Term Storage
| Thanos | Grafana Mimir | VictoriaMetrics | |
|---|---|---|---|
| Architecture | Sidecar wraps Prometheus; uploads existing TSDB blocks | Separate TSDB; Prometheus remote_writes to it | Drop-in Prometheus replacement or remote_write target |
| Operational complexity | High — 4-5 components (sidecar, store, querier, compactor, optional ruler) | Medium — monolithic mode is single binary; microservices mode complex | Low — single binary, single container |
| Storage efficiency | 2-4x compression vs raw; downsampling for long ranges | Similar to Thanos; better compaction in Mimir 3.x | 7-10x compression vs Prometheus |
| Object storage required | Yes (S3/GCS/MinIO) | Yes (S3/GCS/MinIO) | No (local disk); cluster version uses object storage |
| Multi-tenancy | Limited (labels as tenant identifiers) | First-class (X-Scope-OrgID header) | Limited (available in enterprise) |
| PromQL compatibility | Full (via Querier) | Full + Mimir extensions | Full + MetricsQL extensions |
| Memory usage | Higher (multiple components) | Moderate (monolithic mode) | Lowest — 5x less than Mimir in benchmarks |
| Best fit | Multi-Prometheus federation; existing Prometheus investment | Grafana Cloud parity; multi-tenant requirements | Solo homelab long-term retention; simplicity |
| Homelab verdict | Worth it if you have multiple Prometheus instances | Worth it if you’re already in Grafana ecosystem | Best default choice for single-node long-term retention |
The honest recommendation: if you have a single Prometheus instance and you want years of retention, start with VictoriaMetrics. If you later add a second Prometheus instance on a different node and want a global view across both, consider Thanos at that point. Mimir makes sense if you’re running something close to production scale, need strict multi-tenancy, or want exactly the same stack as Grafana Cloud.
Retention, Cardinality, and the Costs You Don’t See Coming
A few pitfalls that will bite you if you’re not expecting them.
Cardinality explosion: Prometheus memory usage is dominated by the number of active time series, not the number of samples. Each unique label combination is a separate time series. If you accidentally scrape a metric with a high-cardinality label — request path, user ID, container image digest, Kubernetes pod UID — you can go from 50,000 time series to 5,000,000 overnight. Common culprits are cAdvisor (container IDs before they’re cleaned up), application exporters that include request parameters in labels, and anything that uses UUIDs as label values.
Use topk(10, count by(__name__)({__name__=~".+"})) to find your highest-cardinality metrics, and use metric relabeling (metric_relabel_configs in the scrape config) to drop or relabel them before they reach the TSDB.
Prometheus storage sizing: The rule of thumb is roughly 1-2 bytes per sample (after TSDB compression). At 15s scrape interval, a single time series generates 4 samples per minute, 240 per hour, 5,760 per day. With 100,000 time series at 1.5 bytes/sample over 30 days, that’s roughly 26 GB. Budget your storage accordingly, and set both --storage.tsdb.retention.time and --storage.tsdb.retention.size so you have two bounds.
Query performance on long ranges: PromQL over long time ranges (weeks, months) on a local TSDB is slow because Prometheus must scan every sample in the range. Recording rules that aggregate frequently-queried expressions into pre-computed time series are the primary mitigation. For long-term historical queries, Thanos’s downsampling or VictoriaMetrics’s efficient storage are the architectural answers.
Scrape interval selection: 15 seconds is appropriate for most homelab metrics. Some metrics (disk space, UPS battery, NTP offset) change slowly enough that 60-second intervals are fine. For per-container metrics from cAdvisor, 30 seconds is usually enough resolution. Reducing scrape intervals increases load on both the scraped targets and the Prometheus TSDB. Don’t go below 5 seconds without a specific reason.
Directory Structure
The complete configuration layout for this stack:
.
├── docker-compose.yml
├── .env (GRAFANA_ADMIN_PASSWORD=...)
└── config/
├── prometheus/
│ ├── prometheus.yml
│ └── rules/
│ ├── alerts.yml
│ └── recording.yml
├── alertmanager/
│ └── alertmanager.yml
├── blackbox/
│ └── blackbox.yml
├── snmp/
│ └── snmp.yml (generated, not handwritten)
├── node-exporter/
│ └── textfile/ (*.prom files from custom scripts)
├── thanos/ (optional)
│ └── objstore.yml
└── grafana/
└── provisioning/
├── datasources/
│ └── prometheus.yaml
└── dashboards/
├── homelab.yaml
└── json/
├── node-exporter-full.json
├── cadvisor.json
└── blackbox.json
Everything in config/ is version-controlled. The only file that stays outside git is .env. Running git clone on a new machine and docker compose up -d gives you a fully operational observability stack in minutes.
Putting It Together
The stack described in this guide covers every layer of a typical homelab: the bare metal host via Node Exporter, containers via cAdvisor, services via Blackbox Exporter probes, and network gear via SNMP Exporter. Alertmanager routes notifications with intelligent grouping and inhibition so you get paged for causes, not symptoms. Grafana displays it all with provisioned dashboards that survive container recreation. Long-term storage via VictoriaMetrics or Thanos extends retention from weeks to years.
The operational overhead after initial setup is low. The main recurring tasks are: adjusting alert thresholds as you learn what’s normal in your environment (the first few weeks will produce alerts that turn out to be false positives), rotating Grafana credentials, and occasionally updating image versions. promtool check config and amtool check-config are your friends for catching configuration errors before they become outages.
Start with Node Exporter, cAdvisor, and the basic alerting rules. Get paged for a real disk-full event or a service outage and you’ll immediately understand why this setup is worth maintaining. Add Blackbox Exporter probing once the basics are stable. Add SNMP Exporter when you get tired of logging into your router’s web UI to see whether the WAN link is saturated. Add long-term storage when you start wanting to answer questions like “how much has my NAS usage grown over the past year?”
The homelab doesn’t have an SLA or on-call rotation. This stack is the next best thing.
Comments