The Metrics Stack
The metrics foundation in the homelab is kube-prometheus-stack, a Helm chart that installs Prometheus, Grafana, Node Exporter, and kube-state-metrics in one go. On top of it, a separate Bitnami Thanos chart handles long-term storage in MinIO.
The values described here are the production configuration.
What's in the stack
| Component | What it does |
|---|---|
| Prometheus | Scrapes metrics every 60s. Local retention of 4 days or 20 GB. |
| Thanos Sidecar | Ships completed 2h Prometheus blocks to MinIO. |
| Thanos Query | Unified query across recent (sidecar) and historical (storegateway) data. |
| Thanos Query Frontend | Cache layer for range and label queries, backed by Memcached. |
| Thanos Storegateway | Serves historical blocks from MinIO. |
| Thanos Compactor | Compacts and downsamples blocks. |
| Grafana | Dashboards, ad-hoc queries, and alerting. |
| MinIO | S3-compatible object storage for Thanos blocks. |
What gets scraped
flowchart TD
P[Prometheus] -->|scrape every 60s| NE["Node Exporter<br>(DaemonSet, per node)"]
P --> KSM["kube-state-metrics<br>(cluster state)"]
P --> SM["ServiceMonitors per addon<br>(Thanos, ArgoCD, MinIO, Istio…)"]
P --> OA["o11y-apps PodMonitor<br>(label opt-in)"]
Prometheus scrapes everything on a 60-second interval:
Node Exporter runs as a DaemonSet on every node. CPU, memory, disk, network, filesystem. The basics that tell you if a node is healthy.
kube-state-metrics exposes the state of Kubernetes objects. Pod status, deployment replicas, PVC capacity, node conditions. This is where you get metrics like "how many pods are in CrashLoopBackOff" or "is this deployment fully rolled out."
ServiceMonitors from each addon. Thanos, ArgoCD, MinIO, cert-manager, Istio. Each chart creates its own ServiceMonitor, so Prometheus discovers and scrapes them automatically.
PodMonitor for applications. A generic PodMonitor called o11y-apps watches for pods with the o11y.ruiz.sh/metrics: "true" label. It scrapes ports named metrics or http-metrics. This is how app pods opt into metrics collection without needing their own ServiceMonitor.
Prometheus and the WAL
Prometheus runs on the large tier node with a 30 GB PVC on local-path storage. Key settings:
- Retention: 4 days or 20 GB, whichever comes first
- Scrape interval: 60s
- WAL compression: enabled
Every metric goes to the WAL first, before anything else. The WAL is an append-only log on disk that acts as a buffer between incoming data and the final TSDB blocks.
flowchart LR
S[scrape] --> W["WAL<br>(append-only)"] --> H["head block<br>(in memory)"] --> B["2h block<br>(on disk)"]
If Prometheus crashes, the WAL is what lets it recover without losing data. On startup, Prometheus replays the WAL to reconstruct the head block. Without it, any data scraped since the last 2-hour block would be gone.
The Thanos Sidecar only uploads completed 2-hour blocks. Data in the WAL and head block hasn't been shipped to MinIO yet, which means there's always a window of up to 2 hours of data that only exists locally.
Thanos: why and how
Prometheus keeps 4 days locally. For a homelab where I sometimes want to compare "this week vs the same week last month", 4 days isn't enough.
The Sidecar runs inside the Prometheus pod, watches the local TSDB directory, and uploads completed 2-hour blocks to MinIO. From there, the Store Gateway serves them back to Thanos Query, which deduplicates results from the Sidecar (recent) and Store Gateway (historical) into a single Prometheus-compatible endpoint.
flowchart LR
P[Prometheus] --> BL["Block (2h)"] --> SC[Sidecar] --> M[(MinIO)] --> SG[Storegateway]
SG -->|historical| Q[Thanos Query]
SC -->|recent| Q
Q --> G[Grafana]
The Query Frontend sits in front of Query as a caching layer. It splits large time-range queries into smaller chunks and caches the results in Memcached. Repeated queries return from cache.
The Compactor is the background worker. It merges small 2-hour blocks into larger ones (fewer blocks means less I/O on MinIO and faster queries) and downsamples old data:
| Resolution | Retention |
|---|---|
| Raw | 7 days |
| 5 minutes | 30 days |
| 1 hour | 365 days |
A query for "CPU usage last 6 months" doesn't need per-second granularity. 1-hour resolution is enough and uses far less storage.
What I don't run:
- Ruler is disabled. Alerting is Grafana-managed rules, so there's nothing for a Thanos Ruler to evaluate.
- Receive is disabled. The homelab uses sidecar mode (Prometheus ships to MinIO via the Sidecar). Receive is for push-based ingestion, which adds complexity without benefit here.
Multi-cluster federation
The homelab runs two clusters, develop and production. Each has its own kube-prometheus-stack, its own Thanos, and its own MinIO bucket.
Production's Thanos Query federates develop's Store Gateway over the LAN. Develop pins its Store Gateway to a fixed MetalLB IP on port 10901, and production references it in query.stores. The result is a single Grafana that queries both clusters, with deduplication keyed on the prometheus_replica and cluster external labels.
Each cluster owns its MinIO bucket and runs its own Compactor. Two Compactors writing to the same bucket would corrupt each other.
Grafana
Grafana runs on the medium tier node. Datasources:
- Thanos Query for everything (default)
- Prometheus for the most recent data, direct
- Loki for logs, so the same Grafana serves the log stack too
Grafana's default datasource is Thanos Query, not Prometheus directly. The same dashboards work for both recent and historical data without picking the right datasource.
Dashboards load automatically via a sidecar that watches for ConfigMaps with the grafana_dashboard: "1" label across all namespaces. Each addon can ship its own dashboard as a ConfigMap.
Admin credentials come from Doppler via the Doppler operator. Grafana has a 5 GB PVC for persistence.
Alerting
There's no Alertmanager. The chart bundles one, but it's disabled: alerting runs as Grafana-managed rules provisioned by grafana-operator, evaluated against Thanos, and delivered straight to Slack. The upstream defaultRules pack is off too, replaced by a small curated set.
See alerting to Slack for the whole design and the rules.
Node placement
| Component | Tier |
|---|---|
| Prometheus, Thanos Sidecar, Storegateway, Compactor | large |
| Grafana, Operator, Query, Query Frontend | medium |
| Node Exporter | all nodes (DaemonSet) |
Prometheus, the Compactor, and the Storegateway sit on large because they're either memory-hungry or block-heavy. Querying components stay on medium, where the load is bursty but bounded.
The 60-second scrape interval is a tradeoff. It keeps storage and CPU low, but you lose granularity for short-lived spikes. For a homelab it's fine. In production I'd use 15 or 30 seconds for critical services.
The Compactor is the component that surprised me most. It runs quietly in the background, but without it, the bucket fills up with thousands of tiny 2-hour blocks and queries get slow. Compaction and downsampling are what make long-term storage practical.