How to Set Up kube-prometheus-stack: Prometheus and Grafana (2026)

Oct 31, 2026 · 17 min · Ajay Kumar

This guide is for engineers who want the standard Prometheus and Grafana setup on Kubernetes without copying a values file from 2021. At the end you will have kube-prometheus-stack installed with persistent storage and a retention limit you chose on purpose, your own app scraped through a ServiceMonitor or PodMonitor, an alert rule routed to Slack with a dead man's switch, a dashboard provisioned from a ConfigMap, and optional remote_write to long-term storage. Everything is pinned to chart 92.16.1, released 30 October 2026, which ships Prometheus Operator v0.94.2, Prometheus v3.15.1, Alertmanager v0.34.2 and Grafana 13.3.0; I rendered every values file below with helm template against that exact chart (Helm v3.14.4). You need a cluster on Kubernetes 1.25 or newer (the chart's kubeVersion floor), a default StorageClass, kubectl, jq and a recent Helm 3 or Helm 4.

The moving parts are worth one picture before any YAML, because nearly every "my target is missing" ticket comes from misunderstanding who selects whom.

Your app Deployment + Service port name: web ServiceMonitor or PodMonitor, any namespace selects the Service by label PrometheusRule alerts and recording rules severity: warning, critical Prometheus Operator v0.94.2, watches the CRs writes scrape config Prometheus v3.15 TSDB on a 50Gi PVC 15d or 40GB, first wins Alertmanager v0.34.2 routes by severity Grafana 13.3 29 dashboards uid: prometheus remote_write to Mimir or Grafana Cloud scrapes /metrics every 30s Alertmanager sends critical and warning alerts to Slack; Watchdog always fires and pings a heartbeat URL Default selectors only see objects labelled release=kps; this guide sets the NilUsesHelmValues flags to false
How the objects in this guide connect. Versions are the images pinned by chart 92.16.1; the 29 dashboards are the ConfigMaps that `helm template` renders with default components enabled.

Step 1: Check the cluster and pin the chart version

Pin the chart. kube-prometheus-stack ships major versions often and each one can break something: the UPGRADE notes for 92.x add a default nodeSelector that restarts Prometheus, Alertmanager and the operator, and 90.x changed how the control-plane ServiceMonitors authenticate. An unpinned helm upgrade in a pipeline is how monitoring goes dark on a Friday.

kubectl version                 # server must be v1.25 or newer
kubectl get storageclass        # one should say (default); otherwise set storageClassName below

export KPS_VERSION=92.16.1
CHART=oci://ghcr.io/prometheus-community/charts/kube-prometheus-stack

# What the chart pins (appVersion is the Prometheus Operator version, which also fixes the CRDs)
helm show chart "$CHART" --version "$KPS_VERSION" | grep -E '^(version|appVersion)'

# Keep the full defaults next to your values file; you will diff against it at every upgrade
helm show values "$CHART" --version "$KPS_VERSION" > kps-defaults-$KPS_VERSION.yaml

Expected output of the grep:

appVersion: v0.94.2
version: 92.16.1

The defaults file is about 6,000 lines. You only need to override a handful of keys, which is the point of the next two steps.

Step 2: Create the namespace and the secrets

Create secrets before the install so no credential ends up in a values file or in Git. If you skip the Grafana secret, the Grafana subchart generates a random 40-character admin password and stores it in a Secret called kps-grafana; that works, but you will be fishing it out with kubectl forever.

kubectl create namespace monitoring

# Grafana admin login, referenced by grafana.admin.existingSecret
kubectl -n monitoring create secret generic grafana-admin \
  --from-literal=admin-user=admin \
  --from-literal=admin-password="$(openssl rand -base64 24)"

# Slack incoming-webhook URL plus a heartbeat URL for the Watchdog alert (Step 6).
# Alertmanager reads both from files, so they never appear in its rendered config.
kubectl -n monitoring create secret generic alertmanager-slack \
  --from-literal=webhook-url='https://hooks.slack.com/services/REPLACE/ME/PLEASE' \
  --from-literal=deadman-url='https://your-heartbeat-service.example/ping/REPLACE_ME'

Step 3: Write the values file for retention, storage and selectors

Three decisions matter on day one: which monitors Prometheus picks up, how long it keeps data, and where it keeps it.

Selectors. By default the chart renders a Prometheus object that only selects ServiceMonitors, PodMonitors and rules carrying the label release: <your release name>. That is the single most common reason a correct ServiceMonitor is silently ignored. Setting the five *SelectorNilUsesHelmValues flags to false renders empty selectors ({}), which in Prometheus Operator means "all objects, in all namespaces". On a shared cluster you may prefer an explicit selector such as prometheus: main; either way, decide on purpose.

Retention. There are three defaults in play. Prometheus itself keeps 15 days if no retention flag is set, the operator's CRD defaults to 24 hours if all retention fields are empty, and this chart sets retention: 10d. Set both a time and a size limit: per the Prometheus storage docs, whichever triggers first wins, the WAL and memory-mapped chunks count toward the size limit, and the size should be at most 80 to 85 percent of the volume to leave room for compaction.

Disk size. The same page gives the formula: retention seconds times samples per second times bytes per sample, with Prometheus averaging 1 to 2 bytes per sample. 100,000 active series scraped every 30 seconds is about 3,333 samples a second; over 15 days that is 4.3 billion samples, or 4.3 to 8.6 GB. At a million series it is 43 to 86 GB, and 50Gi is no longer enough. NFS, including EFS, is not supported, so use block storage.

# values-kps.yaml for kube-prometheus-stack 92.16.1
# (Prometheus Operator v0.94.2, Prometheus v3.15.1, Alertmanager v0.34.2, Grafana 13.3.0)
fullnameOverride: kps            # short names: kps-prometheus, kps-grafana, kps-alertmanager

prometheus:
  prometheusSpec:
    # Pick up monitors, rules, probes and ScrapeConfigs from every namespace,
    # whatever their labels. The default only selects release=<release name>.
    serviceMonitorSelectorNilUsesHelmValues: false
    podMonitorSelectorNilUsesHelmValues: false
    ruleSelectorNilUsesHelmValues: false
    probeSelectorNilUsesHelmValues: false
    scrapeConfigSelectorNilUsesHelmValues: false

    scrapeInterval: 30s          # every halving doubles samples, disk and any per-sample bill
    evaluationInterval: 30s
    externalLabels:
      cluster: dev-eu1           # stamped on every series and alert; required once you remote_write

    retention: 15d               # whichever limit is hit first wins
    retentionSize: 40GB          # about 75% of the 50Gi volume; WAL and head chunks count toward it

    resources:
      requests:
        cpu: 500m
        memory: 2Gi
      limits:
        memory: 4Gi

    storageSpec:
      volumeClaimTemplate:
        spec:
          storageClassName: standard   # gp3 on EKS, premium-rwo on GKE, managed-csi on AKS
          accessModes: ["ReadWriteOnce"]
          resources:
            requests:
              storage: 50Gi

alertmanager:
  alertmanagerSpec:
    storage:                     # silences and notification state survive restarts
      volumeClaimTemplate:
        spec:
          storageClassName: standard
          accessModes: ["ReadWriteOnce"]
          resources:
            requests:
              storage: 2Gi

grafana:
  admin:
    existingSecret: grafana-admin   # from Step 2
    userKey: admin-user
    passwordKey: admin-password
  persistence:
    enabled: true                   # keeps users, API tokens and UI-made changes across restarts
    size: 10Gi

If memory limits on Prometheus make you nervous, they should: series count, not traffic, decides how much RAM it needs. My OOMKilled guide covers how to size the limit from real usage instead of guessing.

Step 4: Install the chart and verify every component

helm upgrade --install kps "$CHART" \
  --version "$KPS_VERSION" \
  --namespace monitoring \
  -f values-kps.yaml \
  --wait --timeout 10m

kubectl -n monitoring get pods
kubectl -n monitoring get prometheus,alertmanager

You should see six kinds of pod (ReplicaSet hashes will differ). The Prometheus and Alertmanager pods carry a config-reloader sidecar; Grafana carries two sidecars that load dashboards and data sources from ConfigMaps.

NAME                                      READY   STATUS    RESTARTS   AGE
alertmanager-kps-alertmanager-0           2/2     Running   0          2m
kps-grafana-5d8c7b9f6d-qx7lm              3/3     Running   0          2m
kps-kube-state-metrics-6b9d8c7f5-h2kfp    1/1     Running   0          2m
kps-operator-7f6d5c9b8-w4znc              1/1     Running   0          2m
kps-prometheus-node-exporter-8xk2v        1/1     Running   0          2m
prometheus-kps-prometheus-0               2/2     Running   0          2m

Open the three UIs through port-forwards, then ask Prometheus which targets are healthy. The scrape pool name tells you which ServiceMonitor produced each target, which is what you need when something is red.

kubectl -n monitoring port-forward svc/kps-prometheus 9090:9090 >/dev/null &
kubectl -n monitoring port-forward svc/kps-alertmanager 9093:9093 >/dev/null &
kubectl -n monitoring port-forward svc/kps-grafana 3000:80 >/dev/null &

curl -s 'http://localhost:9090/api/v1/targets?state=active' \
  | jq -r '.data.activeTargets[] | "\(.health)\t\(.scrapePool)\t\(.lastError)"' \
  | sort | uniq -c

On a managed cluster (EKS, GKE, AKS) the controller manager, scheduler and etcd pools will be empty or down, and the bundled critical alerts KubeControllerManagerDown and KubeSchedulerDown (both absent(up{...}) with for: 15m) will fire after a quarter of an hour; the Troubleshooting section explains why and how to switch them off.

Step 5: Scrape your app with a ServiceMonitor or PodMonitor

The example app is the one the Prometheus Operator docs use: quay.io/brancz/prometheus-example-app, which counts requests to /, /err (404) and /internal-err (500) in http_requests_total.

# example-app.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: example-app
  namespace: demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: example-app
  template:
    metadata:
      labels:
        app: example-app
    spec:
      containers:
        - name: example-app
          image: quay.io/brancz/prometheus-example-app:v0.6.0
          ports:
            - name: web              # named port; monitors refer to the name
              containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: example-app
  namespace: demo
  labels:
    app: example-app                 # the ServiceMonitor selects on THESE labels
spec:
  selector:
    app: example-app
  ports:
    - name: web
      port: 8080
      targetPort: web
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: example-app
  namespace: demo                    # same namespace as the Service, so no namespaceSelector needed
spec:
  selector:
    matchLabels:
      app: example-app
  endpoints:
    - port: web                      # Service port NAME, never the number
      path: /metrics
      interval: 30s
kubectl apply -f example-app.yaml

# Generate some 200s, 404s and 500s
kubectl -n demo run load --rm -i --restart=Never --image=curlimages/curl:8.23.0 -- \
  sh -c 'for i in $(seq 1 200); do curl -s -o /dev/null example-app:8080/; \
         curl -s -o /dev/null example-app:8080/err; curl -s -o /dev/null example-app:8080/internal-err; done'

curl -s http://localhost:9090/api/v1/query \
  --data-urlencode 'query=up{job="example-app"}' | jq -r '.data.result[] | "\(.metric.pod) \(.value[1])"'

Expect one line per pod ending in 1. The job label is the Service name by default. Give the target about one scrape interval plus the operator's reconcile before you panic.

Use a PodMonitor when there is no Service in front of the pods (batch workers, DaemonSets that only expose metrics, sidecars). It selects Pod labels and container port names instead, and its default job label is <namespace>/<podmonitor-name>, so adjust any queries that filter on job. Do not apply both for the same pods, or every series is scraped twice.

apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: example-app-pods
  namespace: demo
spec:
  selector:
    matchLabels:
      app: example-app               # Pod labels
  podMetricsEndpoints:
    - port: web                      # container port NAME
      path: /metrics
      interval: 30s

Step 6: Add an alert rule and route alerts to Slack

The chart already ships 155 alerting rules and 95 recording rules for the cluster itself (counted from the rendered 92.16.1 manifests). Add rules for your own services as PrometheusRule objects that live next to the app:

# example-app-rules.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: example-app-alerts
  namespace: demo
spec:
  groups:
    - name: example-app
      rules:
        - alert: ExampleAppHighErrorRate
          expr: |
            sum by (namespace, job) (rate(http_requests_total{job="example-app", code=~"5.."}[5m]))
              /
            sum by (namespace, job) (rate(http_requests_total{job="example-app"}[5m]))
              > 0.05
          for: 10m
          labels:
            severity: warning
          annotations:
            summary: "example-app 5xx ratio above 5% for 10 minutes"
            description: "{{ $value | humanizePercentage }} of requests to {{ $labels.job }} in {{ $labels.namespace }} return 5xx."

The load from Step 5 sends a third of its requests to /internal-err, so the alert goes pending under Alerts in the Prometheus UI within a minute or two. The loop finishes long before for: 10m elapses, so for an end-to-end test of Slack, temporarily set for: 1m and rerun the load. Now replace the chart's default receiver, which sends everything to a receiver called null. Helm merges maps and replaces lists, so the chart's default inhibit_rules survive while route and receivers are yours.

# values-alerting.yaml
alertmanager:
  alertmanagerSpec:
    secrets:
      - alertmanager-slack           # mounted at /etc/alertmanager/secrets/alertmanager-slack/
  config:
    global:
      resolve_timeout: 5m
    route:
      receiver: "null"
      group_by: ["namespace", "alertname"]
      group_wait: 30s
      group_interval: 5m
      repeat_interval: 4h
      routes:
        - receiver: "null"
          matchers:
            - alertname = "InfoInhibitor"
        - receiver: deadman          # Watchdog always fires; the heartbeat service pages if it stops
          matchers:
            - alertname = "Watchdog"
          repeat_interval: 1m
        - receiver: slack-critical
          matchers:
            - severity = "critical"
        - receiver: slack-warning
          matchers:
            - severity = "warning"
    receivers:
      - name: "null"
      - name: deadman
        webhook_configs:
          - url_file: /etc/alertmanager/secrets/alertmanager-slack/deadman-url
            send_resolved: false
      - name: slack-critical
        slack_configs:
          - api_url_file: /etc/alertmanager/secrets/alertmanager-slack/webhook-url
            channel: "#alerts-critical"
            send_resolved: true      # Slack defaults to false, unlike most receivers
            title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }} ({{ .CommonLabels.namespace }})'
            text: '{{ range .Alerts }}{{ .Annotations.summary }}{{ "\n" }}{{ end }}'
      - name: slack-warning
        slack_configs:
          - api_url_file: /etc/alertmanager/secrets/alertmanager-slack/webhook-url
            channel: "#alerts"
            send_resolved: true
kubectl apply -f example-app-rules.yaml
helm upgrade kps "$CHART" --version "$KPS_VERSION" -n monitoring \
  -f values-kps.yaml -f values-alerting.yaml --wait

# Ask Alertmanager's own routing tree where a label set would go (amtool ships in the image)
kubectl -n monitoring exec alertmanager-kps-alertmanager-0 -c alertmanager -- \
  amtool config routes test --config.file=/etc/alertmanager/config_out/alertmanager.env.yaml \
  severity=critical alertname=Test

Expected output is the receiver name, slack-critical. The api_url_file, url_file and Slack's send_resolved: false default are all in the Alertmanager configuration reference. For team-owned routing, the operator's AlertmanagerConfig CRD (still v1alpha1) lets each namespace bring its own route; by default the operator adds a namespace matcher so a team cannot capture another team's alerts. Once alerts flow, the real work is deleting the noisy ones, which I covered in the Alertmanager alert fatigue guide, and replacing threshold alerts with SLO burn-rate alerts.

Step 7: Log in to Grafana and add dashboards as ConfigMaps

Browse to http://localhost:3000 and log in with the secret from Step 2. The chart provisions a Prometheus data source with uid prometheus, an Alertmanager data source, and 29 dashboards covering the API server, kubelet, nodes, namespaces, workloads and persistent volumes.

Do not build production dashboards in the UI. The chart sets allowUiUpdates: false for provisioned dashboards, and anything stored only in Grafana's database is one lost PVC away from gone. Instead, export the JSON and ship it as a ConfigMap with the label the sidecar watches (grafana_dashboard: "1"). The sidecar searches all namespaces, so app teams can keep dashboards next to their code:

# example-app-dashboard.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: example-app-dashboard
  namespace: demo
  labels:
    grafana_dashboard: "1"
data:
  example-app.json: |
    {
      "uid": "example-app",
      "title": "Example app",
      "schemaVersion": 39,
      "time": { "from": "now-1h", "to": "now" },
      "panels": [
        {
          "type": "timeseries",
          "title": "Requests per second by status code",
          "gridPos": { "x": 0, "y": 0, "w": 24, "h": 9 },
          "datasource": { "type": "prometheus", "uid": "prometheus" },
          "targets": [
            {
              "refId": "A",
              "expr": "sum by (code) (rate(http_requests_total{job=\"example-app\"}[5m]))",
              "legendFormat": "{{code}}"
            }
          ]
        }
      ]
    }
kubectl apply -f example-app-dashboard.yaml
kubectl -n monitoring logs deploy/kps-grafana -c grafana-sc-dashboard --tail=5   # shows the file being written

Step 8: Send metrics to long-term storage with remote_write

A single Prometheus is a fine short-term store and a poor archive. When you need months of data, several clusters in one view, or a managed backend, add remote_write to Mimir, Thanos Receive, VictoriaMetrics or Grafana Cloud. Two things matter: credentials from a Secret, and dropping what you will never query remotely before it leaves the cluster.

kubectl -n monitoring create secret generic remote-write-auth \
  --from-literal=username='REPLACE_ME' --from-literal=password='REPLACE_ME'
# values-remote-write.yaml
prometheus:
  prometheusSpec:
    remoteWrite:
      - name: mimir
        url: https://mimir.example.internal/api/v1/push   # Grafana Cloud uses .../api/prom/push
        basicAuth:
          username:
            name: remote-write-auth
            key: username
          password:
            name: remote-write-auth
            key: password
        writeRelabelConfigs:
          # Example: keep the API server latency histogram local only.
          # Check /api/v1/status/tsdb for your own biggest metric families first.
          - sourceLabels: [__name__]
            regex: "apiserver_request_duration_seconds_bucket|etcd_request_duration_seconds_bucket"
            action: drop
        queueConfig:
          maxSamplesPerSend: 2000
          maxShards: 50
        # messageVersion: V2.0     # Remote Write 2.0; only if the receiver supports it
helm upgrade kps "$CHART" --version "$KPS_VERSION" -n monitoring \
  -f values-kps.yaml -f values-alerting.yaml -f values-remote-write.yaml --wait

# Failures should be 0; lag (seconds behind) should stay near the scrape interval
curl -s http://localhost:9090/api/v1/query --data-urlencode \
  'query=rate(prometheus_remote_storage_samples_failed_total[5m])' | jq '.data.result'
curl -s http://localhost:9090/api/v1/query --data-urlencode \
  'query=prometheus_remote_storage_queue_highest_timestamp_seconds - prometheus_remote_storage_queue_highest_sent_timestamp_seconds' \
  | jq -r '.data.result[] | "\(.metric.remote_name) \(.value[1])s"'

Remote write is also where the bill starts. Grafana Cloud, for example, bills metrics on data points per minute, so a 30-second scrape counts double against a 60-second one. I priced that and the alternatives in the companion post, Grafana vs Datadog vs New Relic cost in 2026. If the series count is the problem, the Prometheus high-cardinality guide shows how to find and drop the offenders.

Step 9: Upgrade without breaking the CRDs

Helm installs CRDs on the first install and never upgrades them, and the chart's appVersion tells you which operator release the CRDs must match. Apply them server-side first (the Prometheus CRD alone is about 840 KiB, far past the 262,144-byte limit on the annotation client-side apply writes), then upgrade the chart:

# Set KPS_VERSION to the chart release you are moving to before running this
OP=$(helm show chart "$CHART" --version "$KPS_VERSION" | awk '/^appVersion/ {print $2}')   # v0.94.2
for crd in alertmanagerconfigs alertmanagers podmonitors probes prometheusagents \
           prometheuses prometheusrules scrapeconfigs servicemonitors thanosrulers; do
  kubectl apply --server-side --force-conflicts -f \
    "https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/${OP}/example/prometheus-operator-crd/monitoring.coreos.com_${crd}.yaml"
done
helm upgrade kps "$CHART" --version "$KPS_VERSION" -n monitoring \
  -f values-kps.yaml -f values-alerting.yaml -f values-remote-write.yaml --wait

--force-conflicts takes field ownership from Helm, which is what the chart's own upgrade job does. That job (crds.upgradeJob.enabled: true) automates this as a pre-upgrade hook, but the chart still labels it preview. In Argo CD, sync the application with ServerSideApply=true for the same size reason.

Troubleshooting

Start every "target down" investigation with the targets query from Step 4: lastError is usually the whole answer. Then match it here.

Symptom Likely cause Fix
KubeControllerManagerDown and KubeSchedulerDown firing, or etcd pool down, on EKS, GKE or AKS Managed control plane; no such pods exist and the endpoints are not reachable from pods Set kubeControllerManager.enabled, kubeScheduler.enabled and kubeEtcd.enabled to false
Same three down on kubeadm, connection refused Components bind metrics to 127.0.0.1 Set --bind-address=0.0.0.0 on controller manager and scheduler, --listen-metrics-urls=http://0.0.0.0:2381 on etcd; firewall the ports
kube-proxy down metricsBindAddress is 127.0.0.1:10249, or no kube-proxy (Cilium or another eBPF replacement) Set metricsBindAddress: 0.0.0.0:10249 in the kube-proxy ConfigMap and restart it, or set kubeProxy.enabled: false
Your ServiceMonitor never appears under Targets Default selectors only match release: kps Add the label, or set the *SelectorNilUsesHelmValues flags to false as in Step 3
Monitor exists but zero targets Selector matches Pod labels instead of Service labels, or port is a number or a misspelled name Select on Service labels; set port to the Service port name
ServiceMonitor in monitoring finds nothing in demo Monitors discover Services in their own namespace by default Create it in the app namespace, or add namespaceSelector.matchNames: [demo]
Target down with HTTP 401 or 403 Endpoint needs auth or TLS Add authorization or basicAuth and tlsConfig to the endpoint, with credentials from a Secret
prometheus-kps-prometheus-0 stuck Pending PVC unbound: no default StorageClass or wrong name kubectl -n monitoring get pvc; fix storageClassName (see the Pending pods guide)
Prometheus restarts with OOMKilled Series count grew; memory follows cardinality Check /api/v1/status/tsdb, drop or relabel the top families, then raise the limit
Grafana rejects the password after you changed the Secret Grafana sets the admin password once, on first run Run grafana cli admin reset-admin-password <new> in the Grafana container, or start with a fresh PVC

Two more checks catch the rest. The operator logs why it rejects an object (kubectl -n monitoring logs deploy/kps-operator | grep example-app). And the Prometheus image is distroless, so kubectl exec ... -- sh fails; run promtool directly instead: kubectl -n monitoring exec prometheus-kps-prometheus-0 -c prometheus -- promtool check config /etc/prometheus/config_out/prometheus.env.yaml.

FAQ

What is the default Grafana password in kube-prometheus-stack?

In chart 92.16.1 there is no fixed default: the adminPassword line is commented out and the Grafana subchart generates a random 40-character password. Read it with kubectl -n monitoring get secret kps-grafana -o jsonpath='{.data.admin-password}' | base64 -d, or set your own through grafana.admin.existingSecret as in Step 2.

Why is my ServiceMonitor not being picked up?

Usually it is the release label: by default Prometheus only selects monitors labelled with the Helm release name. The rest are a selector that matches Pod labels instead of Service labels, a port that is not the Service port's name, or a monitor in a different namespace from its Service without a namespaceSelector.

How much disk does Prometheus need on Kubernetes?

Multiply retention in seconds by samples per second by 1 to 2 bytes per sample. 100,000 series at a 30-second interval for 15 days needs roughly 4 to 9 GB; a million series needs 43 to 86 GB. Set retentionSize to about 80 percent of the volume so Prometheus deletes old blocks before the disk fills.

What is the difference between kube-prometheus-stack and the prometheus Helm chart?

kube-prometheus-stack installs the Prometheus Operator, its CRDs, Alertmanager, Grafana, node-exporter, kube-state-metrics and a curated set of rules and dashboards, and you configure scraping with ServiceMonitors and PodMonitors. The plain prometheus chart runs Prometheus without the operator and discovers targets from annotations and a static config file, which is simpler for one small cluster but gives you none of the bundled alerts.

What I check first

When I inherit a cluster running this chart, I check four things before trusting a single graph: that the chart version is pinned in Git, that Watchdog actually reaches a heartbeat service (an alerting pipeline nobody has tested is a rumour), that retentionSize is set below the volume size, and that the targets list has no permanent reds that everyone has learned to ignore. On managed clusters I disable the control-plane monitors on day one so a red target always means something. Everything else is iteration. The official references are the chart's UPGRADE notes, the Prometheus Operator getting-started guide, the Prometheus storage docs and the Alertmanager configuration reference.

Related: How to Fix Prometheus High Cardinality: Find and Drop Series (2026), How to Set Up Grafana Loki on Kubernetes with Alloy (2026) and How to Set Up the OpenTelemetry Collector on Kubernetes (2026).

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related