materialize-monitoring Helm Reference#

Unified observability stack for Materialize: Alloy-based pipeline, optional bundled backends (Loki / Thanos / Grafana / Alertmanager), and the dashboards / alerts / rules that consume the Materialize metrics surface.

Source Code#

Requirements#

Kubernetes: >=1.27.0-0

Storage Requirements#

Using Thanos or Loki (both enabled by default) requires object storage (such as an AWS S3 bucket) for long-term storage.

In development, Thanos may use a PVC for less-reliable storage. Loki however will not run without object storage. You may consider Garage or RustFS or MinIO for manually provisioned object storage.

Included Subcharts#

RepositoryNameVersion
https://grafana.github.io/helm-chartsalloy(alloy-agent)1.15.0
https://grafana.github.io/helm-chartsalloy(alloy-gateway)1.15.0
https://kubernetes-sigs.github.io/metrics-servermetrics-server3.14.0
oci://ghcr.io/grafana-community/helm-chartsgrafana14.3.0
oci://ghcr.io/grafana-community/helm-chartsloki20.2.2
oci://ghcr.io/grafana/helm-chartsgrafana-operator5.25.0
oci://ghcr.io/prometheus-community/chartsalertmanager1.43.3
oci://ghcr.io/prometheus-community/chartskube-state-metrics8.8.0
oci://ghcr.io/prometheus-community/chartsprometheus-node-exporter(node-exporter)4.60.1
oci://ghcr.io/prometheus-community/chartsprometheus-postgres-exporter(postgres-exporter)8.2.0
oci://ghcr.io/thanos-community/helm-chartsthanos0.48.0

Values#

Globals#

Values that are passed to all subcharts

KeyTypeDefaultDescription
global.imageRegistrystring""Override the registry for all images in this chart and its subcharts. Leave empty to use upstream defaults.
global.imagePullSecretslist
[]
Image pull secrets for this chart's own workloads and for the subcharts that read a global. Not all of them do. Reaches this chart's own workloads, plus Thanos, Grafana, kube-state-metrics and node-exporter. It does **not** reach every subchart on its own, because they do not agree on where to read it from:
  • Alloy reads global.image.pullSecrets and never this key. This chart honors both spellings for the workloads it renders itself, but the Alloy subchart only sees its own.
  • Loki, grafana-operator, Alertmanager and metrics-server read no global at all and need their own <chart>.imagePullSecrets.

profiles/registry/pull-secret.values.yaml sets every one of those paths from a single Secret name; prefer it over setting this key alone, which leaves Loki — the largest pod count in the release — pulling anonymously.

global.clusterDomainstring"cluster.local"The cluster's DNS domain. `cluster.local` is a **default, not a fact** — clusters get built with `--cluster-domain=cluster.internal` or a site-specific domain often enough that hardcoding it is a reliable way to ship a feature that works everywhere we test and nowhere a customer runs.

It lives under global because two subcharts already read global.clusterDomain and build real addresses from it — Loki’s memberlist join address and canary URL, and Thanos’s in-cluster endpoints. Helm propagates global into every subchart, so setting it here is what makes one value mean one thing rather than three keys that can disagree.

One subchart is not covered by that propagation. metrics-server reads its own metrics-server.tls.clusterDomain, which is set to the same default below; a render-time check warns when the two disagree, because the failure is a certificate whose SANs name a domain the cluster does not use.

Load-bearing for certificates: the SAN ladder ends in $svc.$ns.svc.$clusterDomain, so a wrong value here produces certificates that fail verification against the endpoints this chart itself dials.

Main chart configuration#

Configuration for the main chart

KeyTypeDefaultDescription
nameOverridestring""Standard Helm name override. Note that in umbrella charts, fullname may be shorted. See fullnameOverride.
fullnameOverridestring"mzmon"Standard Helm fullname override. Note that in umbrella charts, this may be shortened to avoid long prefixes. (and have more stable names in resources)
namespaceOverridestring""Namespace override for default workloads.
clusterNamestring"default"Name of this Kubernetes cluster, stamped as `cluster` on every log line, metric sample and alert.

One value, read everywhere the stack labels a signal: the Alloy agent and gateway (through pipeline.env.CLUSTER_NAME, which defaults to it), the gateway’s remote-write external_labels, and both rulers’ alerts (through the ruler-env ConfigMap). Terraform’s cluster_name sets it.

Give each cluster its own. The label is what tells clusters apart once they share a log or metric store, a notification channel or an incident tool, and the default distinguishes nothing. Letters, digits, ., _ and -, starting with a letter or digit: it is substituted unquoted into Loki’s configuration and into an Alloy template.

Subchart enablement (Helm tags)#

Group toggles for subchart enablement. Subchart enablement has two layers, in precedence order:

  1. Per-chart circuit breaker (<chart>.enabled). Each subchart dependency carries a condition: <chart>.enabled in Chart.yaml. The corresponding enabled: key is left commented-out under each subchart block in values.yaml; uncommenting it force-includes (true) or force-excludes (false) that chart regardless of any tag state. Helm evaluates condition: first and only falls through to tags when the path is absent from values, so this is a hard override.
  2. Helm tags (the tags: block). When no <chart>.enabled is set, enablement is decided by tags. Each dependency in Chart.yaml carries the master default tag, a group tag (e.g. bundled-backends), and a per-chart tag (e.g. loki); a dependency is included if any of its tags evaluates true (OR semantics). To opt one chart out of an otherwise-enabled group, set the group tag false and flip the per-chart tags individually — or use the circuit breaker.

Group ↔ chart mapping:

Group tagCharts
defaultpipeline + bundled-backends + managed-grafana
groups + kube-state-metrics (recommended stack)
pipelinealloy-agent, alloy-gateway
bundled-backendsloki, thanos, alertmanager
managed-grafanagrafana, grafana-operator
cluster-metricskube-state-metrics, node-exporter,
metrics-server
crdsprometheus-operator-crds, grafana-operator-crds
(in the sibling materialize-monitoring-crds chart)

default is the only group on by default and enables the full recommended stack. --set tags.default=false turns everything off, so you can enable a single group (bundled-backends, managed-grafana, pipeline, cluster-metrics) or individual charts on top. Profile preset values files under profiles/ flip these appropriately. (kube-state-metrics and node-exporter are in default, but metrics-server is not — most clusters already run metrics-server; enable it via tags.cluster-metrics or tags.metrics-server if yours doesn’t. A cluster that already runs its own node-exporter should turn ours off with the node-exporter.enabled: false circuit breaker — setting tags.node-exporter: false does nothing while tags.default is true, because tags are OR’d.)

postgres-exporter belongs to no group, default included. It watches databases you name with credentials you create, so it stays off until you set tags.postgres-exporter: true.

KeyTypeDefaultDescription
tags.defaultbooltrueEnable all recommended defaults. You can `--set tags.default=false` to disable all services and explicitly enable others.
tags.pipelineboolfalseEnable both Alloy releases (agent + gateway).
tags.bundled-backendsboolfalseEnable Loki, Thanos, and Alertmanager as a group. (Grafana is in the `managed-grafana` group.)
tags.cluster-metricsboolfalseEnable kube-state-metrics and metrics-server as a group.
tags.alloy-agentboolfalsePer-chart override: enable just the Alloy agent. OR'd with `tags.default` / `tags.pipeline`.
tags.alloy-gatewayboolfalsePer-chart override: enable just the Alloy gateway. OR'd with `tags.default` / `tags.pipeline`.
tags.lokiboolfalsePer-chart override: enable just Loki. OR'd with `tags.default` / `tags.bundled-backends`.
tags.thanosboolfalsePer-chart override: enable just Thanos. OR'd with `tags.default` / `tags.bundled-backends`.
tags.grafana-standaloneboolfalsePer-chart override: enable just Grafana standalone. OR'd with `tags.default` / `tags.managed-grafana`.
tags.grafana-operatorboolfalsePer-chart override: enable just Grafana operator. OR'd with `tags.default` / `tags.managed-grafana`.
tags.alertmanagerboolfalsePer-chart override: enable just Alertmanager. OR'd with `tags.default` / `tags.bundled-backends`.
tags.kube-state-metricsboolfalsePer-chart override: enable just kube-state-metrics. OR'd with `tags.default` / `tags.cluster-metrics`.
tags.node-exporterboolfalsePer-chart override: enable just node-exporter. OR'd with `tags.default` / `tags.cluster-metrics`.
tags.metrics-serverboolfalsePer-chart override: enable just metrics-server. OR'd with `tags.cluster-metrics`.
tags.postgres-exporterboolfalseEnable postgres_exporter. In no group, `default` included: it watches databases you list under `postgres-exporter.targets`.

Priority classes#

Scheduling priority shared across the stack’s subcharts. Two PriorityClasses, created here and referenced by name from the subchart blocks further down. The split is about what losing a pod costs you:

ClassUsed byLosing a pod means
monitoring-criticalalloy-agent, node-exporter, alloy-gatewaya blind spot with no replica to cover it
monitoring-scalableloki, thanos, grafana, alertmanager, KSMreduced capacity a surviving replica or a retry absorbs

The per-node collectors are singletons per node: when the agent or node-exporter is evicted from a node, nothing else reports that node, and the gap is permanent (there is no backfill). The backends are replicated, buffered, or both — Alloy retries writes, so a Loki ingester that is evicted costs latency rather than data. The gateway is graded critical despite being a Deployment because it is the single egress choke point for every signal.

Both classes set preemptionPolicy: Never. That is the load-bearing choice: priority still decides scheduling-queue order and, more importantly, which pod the kubelet evicts first under node pressure — but monitoring will never evict a Materialize pod to make room for itself. Monitoring that takes down the thing it monitors is worse than monitoring that is late.

Both sit well below system-cluster-critical (2000000000) and system-node-critical (2000001000), so cluster plumbing still outranks us.

PriorityClasses are cluster-scoped. Two releases of this chart in one cluster will fight over these objects. Set create: false on all but one, or rename them per release — and if you rename, update the priorityClassName values in the subchart blocks to match. A priorityClassName naming a class that does not exist does not degrade: the API server rejects the pod. The chart warns at render time when it can tell that has happened.

KeyTypeDefaultDescription
priorityClasses.createbooltrueCreate the PriorityClass objects. Set false when they are managed elsewhere (another release, or cluster-wide by a platform team); the `priorityClassName` references below still apply, so the classes must already exist.
priorityClasses.criticalobject
{
  "description": "Materialize monitoring components whose loss creates a blind spot with no replica to cover it. Never preempts other workloads.",
  "name": "monitoring-critical",
  "preemptionPolicy": "Never",
  "value": 1000000
}
Priority for components whose loss creates an unrecoverable blind spot — the per-node collectors and the egress gateway.
priorityClasses.scalableobject
{
  "description": "Materialize monitoring backends that are replicated or write-buffered. Evicted before the critical tier; never preempts other workloads.",
  "name": "monitoring-scalable",
  "preemptionPolicy": "Never",
  "value": 1000
}
Priority for replicated or buffered components, where losing a pod costs capacity rather than visibility.

Network policies#

NetworkPolicies for the components whose own charts ship none. Most of the stack polices itself, and each component is configured in its own block further down:

ComponentKey
Alloy agentalloy-agent.networkPolicy
Alloy gatewayalloy-gateway.networkPolicy
Lokiloki.networkPolicy
Thanosthanos.global.networkPolicies
Grafanagrafana.networkPolicy
kube-state-metricskube-state-metrics.networkPolicy
node-exporternode-exporter.networkPolicy

This block covers the three subcharts that render no networkpolicy.yaml at all — Alertmanager, grafana-operator, and metrics-server — so the stack does not leave three unpoliced pods in an otherwise policed namespace. The policies are rendered by this chart, from templates/networkpolicies.yaml.

The shape mirrors Loki’s, because Loki is the component whose policy set this repo has actually operated: allow the release to talk to itself, allow DNS, name the destinations that live outside the cluster, deny the rest. Each app gets one Ingress+Egress policy plus a separate DNS policy, for the same reason Loki splits them — a rule you have to disable should not take the rest of the policy with it.

A NetworkPolicy is a declaration, not always a control. Four limits are worth knowing before treating any of this as a boundary:

  • It does nothing unless the CNI enforces it. kindnet (the default in kind, and therefore in the E2E tiers) ignores NetworkPolicy entirely; Cilium and Calico enforce it.
  • Most CNIs do not apply pod policy to host-networked pods. Nothing in this block is host-networked today, but node-exporter.networkPolicy carries the same caveat at greater length, and it is the same caveat.
  • Rules written with podSelector alone stop at the namespace boundary. Under profiles/split-namespace.values.yaml the components no longer share a namespace, so those rules match nothing — add namespaceSelector entries through the extra hooks below, or turn the affected policy off.
  • Nothing here allows the kubelet’s liveness and readiness probes, which arrive from the node’s own IP. Cilium and Calico both exempt host-to-pod traffic so probes keep working; a CNI that does not would fail every pod in the namespace, Loki’s included, and that is the symptom to recognize.

Egress is where a wrong rule hurts: it fails silently, minutes or hours later, somewhere other than the pod you changed. So egress is narrowed only where the destination set is knowable from the chart. Where it is not — the API server’s address, kubelet ports on node IPs, an arbitrary notification provider — the rule is broad and says so, and egress.extra is where you narrow it against facts only your cluster has.

Every component below takes the same eight keys, so they are described once here rather than three times over. Each component’s own entry then says only what is particular to it — which ports, and why that egress is as wide as it is.

KeyMeaning
enabledRender this policy. Unset follows networkPolicies.enabled.
ingress.portsPorts opened to the rest of the component’s namespace. A bare number means TCP; {port, protocol} spells out anything else.
ingress.allowExternalDrop the source restriction entirely, so the ports above accept connections from any namespace and from outside the cluster.
ingress.extraRaw NetworkPolicyIngressRule entries, appended verbatim. The hook for a cross-namespace source.
egress.inNamespaceReach the other pods of this release, on any port.
egress.dnsReach cluster DNS. Rendered as a policy of its own.
egress.external.ports / .cidrsPorts and CIDRs outside the cluster. Ports with no CIDRs means those ports anywhere.
egress.extraRaw NetworkPolicyEgressRule entries, appended verbatim.
KeyTypeDefaultDescription
networkPolicies.enabledbooltrueRender the policies in this block. Off skips all three at once; the per-component `enabled` keys below override it either way.
networkPolicies.alertmanagerobject
{
  "egress": {
    "dns": true,
    "external": {
      "cidrs": [
        "0.0.0.0/0"
      ],
      "ports": [
        443,
        587,
        465
      ]
    },
    "extra": [],
    "inNamespace": true
  },
  "enabled": null,
  "ingress": {
    "allowExternal": false,
    "extra": [],
    "ports": [
      9093,
      {
        "port": 9094,
        "protocol": "TCP"
      },
      {
        "port": 9094,
        "protocol": "UDP"
      }
    ]
  }
}
Alertmanager: reachable from the release namespace, free to egress to notification providers.

Ingress covers 9093 (the API — Loki’s ruler, the Alloy gateway’s scrape, and Grafana’s alerting all arrive here) and 9094, the gossip port a multi-replica Alertmanager uses to deduplicate notifications between peers.

9094 is listed twice, TCP and UDP, and both are required. Alertmanager’s mesh is memberlist: it joins and pushes state over TCP and gossips over UDP, and the upstream chart’s Service declares clusterpeer-tcp and clusterpeer-udp on the same port for exactly that reason. A TCP-only rule produces the worst version of this failure — the peers find each other, the cluster forms, and it never converges, so every notification goes out once per replica.

Both are listed even at the default replicaCount: 1, where nothing dials either: the cost is an open port that no pod connects to, and the alternative is a cluster that silently sends every notification twice the day someone scales it up.

Egress is deliberately wide. Alertmanager’s job is to reach PagerDuty, Slack, an SMTP relay, or a webhook on someone’s internal network, and this chart cannot know which — 443 and the SMTP ports are the defaults because they are what receivers use, not because they are a boundary.

networkPolicies.grafana-operatorobject
{
  "egress": {
    "dns": true,
    "external": {
      "cidrs": [
        "0.0.0.0/0"
      ],
      "ports": [
        443,
        6443
      ]
    },
    "extra": [],
    "inNamespace": true
  },
  "enabled": null,
  "ingress": {
    "allowExternal": false,
    "extra": [],
    "ports": [
      9090
    ]
  }
}
grafana-operator: metrics scraped by the Alloy gateway, egress to the API server and to whichever Grafana it reconciles.

Ingress is only the metrics port. 8888 (pprof) is deliberately left out — it is a debugging surface, and a policy that opens it by default defeats the point of having one.

Egress carries 443/6443 to 0.0.0.0/0 because the operator is an API client first: it watches Grafana, GrafanaDashboard and GrafanaDatasource resources continuously, and a policy that cannot reach the API server leaves it running, healthy, and reconciling nothing. The API server’s address is not something a chart can derive — in-cluster it is a Service ClusterIP that most CNIs evaluate against the real endpoint behind it, which on a managed control plane is outside the cluster network entirely.

connections.grafana.mode: external puts the Grafana it writes to outside the cluster as well; 443 already covers the usual case, but a self-hosted Grafana on another port needs an egress.extra entry. The render warns when it can tell that applies.

networkPolicies.grafanaGossipobject
{
  "enabled": null,
  "port": 9094
}
Grafana's unified-alerting gossip port, which the `grafana` subchart's own policy cannot open.

A supplement, not a replacement: grafana.networkPolicy stays on and keeps doing what it does. That template emits exactly one ingress rule, on service.targetPort, with no way to add a second port through values — so a multi-replica Grafana running HA alerting has its 9094 gossip closed by the very policy that protects its UI. The failure is quiet in the worst way: the replicas start healthy, never find each other, and every Grafana-managed alert notifies once per replica.

This renders the missing rule from the umbrella chart, which can. TCP and UDP, because memberlist uses both — TCP for the join and for state pushes, UDP for the gossip itself — and a TCP-only rule produces a cluster that forms and then does not converge.

It renders only when grafana.ini.unified_alerting.ha_peers is set, which is what makes the replicas dial 9094 in the first place; profiles/grafana-postgres.values.yaml is the shipped shape that sets it. Nothing is created for a single-replica Grafana that never gossips.

networkPolicies.metrics-serverobject
{
  "egress": {
    "dns": true,
    "external": {
      "cidrs": [
        "0.0.0.0/0"
      ],
      "ports": [
        10250,
        443,
        6443
      ]
    },
    "extra": [],
    "inNamespace": true
  },
  "enabled": null,
  "ingress": {
    "allowExternal": true,
    "extra": [],
    "ports": [
      10250
    ]
  }
}
metrics-server: serves the metrics API to the API server, scrapes every kubelet.

Both directions are broad, and both for the same reason: the peers are the control plane and the nodes, neither of which has an address this chart can select. allowExternal is true by default here, unlike the other two — the aggregation layer dials 10250 from the API server, which on a managed control plane is not a pod, not in the cluster’s pod CIDR, and not reachable by any podSelector. A policy that closed that port would break every kubectl top and every HPA in the cluster, not just this release.

What the policy still buys is the rest of the surface: no other port on the pod accepts a connection, and egress is limited to the kubelet port plus the API server rather than anywhere at all.

Egress 10250 to 0.0.0.0/0 is the kubelet summary API on each node. Node IPs are not selectable either, and narrowing this to the node CIDR is a good idea wherever you know it — egress.extra with an ipBlock, and drop 10250 from external.ports.

Certificates#

cert-manager Certificate resources for in-cluster TLS. Off by default, and cert-manager is never a hard dependency. With enabled: false this section renders nothing at all, which is what keeps a hardening feature from becoming a new prerequisite for an install that does not want it.

When it is on, the chart renders one Certificate per component with the full SAN ladder for that component’s Services. It does not turn TLS on anywhere: issuing a certificate and using it are separate switches, on purpose. Each hop moves through the phases in Securing under its own flag, once that component’s renewal behaviour has been proven.

Gated on values, never on .Capabilities. The obvious implementation is to probe the API server for cert-manager.io/v1, and it is wrong: the same chart would then render differently under helm template, the tier-0 Terraform render check, and an ArgoCD server-side diff than it does under a live install — which is exactly the class of bug the render tests exist to catch. A missing CRD with this flag on is a clear apply-time failure with a name in it.

Two issuers, because they cannot be one#

InternalExternal
Names$svc, $svc.$ns, $svc.$ns.svc, $svc.$ns.svc.$clusterDomain, localhostthe public DNS name the load balancer answers on
Typical issuera self-signed root, or a private CAACME, or a private CA that signs your public names
Who verifies itthe stack’s own componentsa browser

A public ACME issuer cannot sign loki-distributor.monitoring.svc, and a self-signed root means nothing to a browser, so collapsing these into one key would make one of the two unusable. They are separate issuerRefs for that reason and no other.

The external certificate is only needed for an L4 load balancer, which passes TCP through and leaves TLS to terminate at the pod — so the material has to exist in the cluster. An L7 load balancer terminating with a cloud-managed certificate (ACM, Google Certificate Manager, Azure Key Vault) attaches it by ARN or resource ID and the key never enters the cluster; for that shape, leave external unset and pass the annotation through grafana.service.annotations.

KeyTypeDefaultDescription
certificates.enabledboolfalseRender the cert-manager resources in this section.
certificates.internalobject
{
  "issuerRef": {
    "group": "cert-manager.io",
    "kind": "ClusterIssuer",
    "name": ""
  },
  "selfSigned": {
    "caSecretNamespace": "cert-manager",
    "commonName": "mzmon-internal-ca",
    "duration": "43800h",
    "enabled": false,
    "kind": "ClusterIssuer",
    "renewBefore": "2160h",
    "secretName": "mzmon-internal-ca"
  }
}
The internal mesh: certificates the stack's components present to each other, carrying in-cluster DNS names.

Supply an issuer with issuerRef, or let the chart bootstrap a self-signed root with selfSigned.enabled. The two are mutually exclusive and the render refuses both at once.

Prefer an issuer scoped to this stack over the cluster’s general-purpose one. None of the receiving components here implement per-client authorization — Loki, Thanos receive and Alloy’s receivers can be told to require a certificate signed by a given CA, and none of them can be told that this identity may write and that one may not. The whole authorization decision is “is this signed by the CA we trust”, so the size of the trust domain is the security property. Reusing a ClusterIssuer that signs for every workload in the cluster reduces mTLS to “has any certificate”, which is a real improvement over “can reach the Service” and much narrower than it sounds.

certificates.internal.issuerRefobject
{
  "group": "cert-manager.io",
  "kind": "ClusterIssuer",
  "name": ""
}
An existing cert-manager issuer to sign internal certificates. `kind` is `Issuer` or `ClusterIssuer`. Leave `name` empty to use `selfSigned` instead.
certificates.internal.selfSignedobject
{
  "caSecretNamespace": "cert-manager",
  "commonName": "mzmon-internal-ca",
  "duration": "43800h",
  "enabled": false,
  "kind": "ClusterIssuer",
  "renewBefore": "2160h",
  "secretName": "mzmon-internal-ca"
}
Have the chart bootstrap a self-signed root and issue from it.

Renders the three resources cert-manager needs for a private CA: a selfSigned issuer, a CA Certificate signed by it, and an issuer backed by that CA which every component certificate then references.

Convenient rather than principled — a real deployment usually has a PKI already, and issuerRef above is the path for it. This exists so that trying the feature does not start with a cert-manager tutorial, and so the chart-only test tier has a root to issue from.

kind: ClusterIssuer is the default deliberately. A namespaced Issuer can only sign for Certificate resources in its own namespace, and profiles/split-namespace.values.yaml puts components in several — so a namespaced issuer silently signs some components and leaves the rest stuck Pending. Issuer is correct and cheaper for the single-namespace default; the render warns when it sees the combination that does not work.

certificates.internal.selfSigned.commonNamestring"mzmon-internal-ca"Subject common name on the generated root.
certificates.internal.selfSigned.durationstring"43800h"Lifetime of the **root**, not of the leaves. Long, because rotating a root means re-trusting it everywhere at once.
certificates.internal.selfSigned.secretNamestring"mzmon-internal-ca"Secret the generated root's key pair lands in.
certificates.internal.selfSigned.caSecretNamespacestring"cert-manager"Namespace that Secret is created in. **cert-manager's cluster resource namespace, not this release's.** A `ClusterIssuer` of kind `ca` reads its Secret from wherever cert-manager was told its cluster resources live — `cert-manager` unless the install overrode `--cluster-resource-namespace` — regardless of where the `Certificate` that produced it was created. Render the CA into the release namespace and the issuer sits `False` with `secret not found` while the Secret it wants is one namespace away.

Only read for kind: ClusterIssuer. A namespaced Issuer has the opposite rule and takes its own namespace; the chart handles both.

certificates.externalobject
{
  "dnsNames": [],
  "issuerRef": {
    "group": "cert-manager.io",
    "kind": "ClusterIssuer",
    "name": ""
  }
}
The browser-facing name, for a Grafana behind an L4 load balancer.

Only Grafana takes an external certificate today, because it is the only component in the stack meant for a human and the only one this chart helps expose. Set dnsNames to the hostname the load balancer answers on; it must match what users actually type, and what grafana.ini.server.root_url says.

Leave issuerRef.name empty and nothing external is rendered, which is the right answer for an L7 load balancer holding a cloud-managed certificate.

certificates.external.dnsNameslist
[]
Public DNS names to put on the certificate.
certificates.durationstring"2160h"Lifetime of each component certificate, and how long before expiry cert-manager renews it. Short durations are the only mitigation available for the fact that nothing here can check revocation — `otelcol.receiver.otlp` has no CRL support, and in-cluster there is no load balancer to act as a checkpoint, so revoking means re-issuing the CA. They also raise the cost of a component that does not reload its certificate cleanly, which is why no hop turns on until its rotation behaviour is proven. 90 days with 30 days of headroom is a starting point rather than a researched one.
certificates.componentsobject
{
  "alertmanager": {
    "enabled": null,
    "extraDnsNames": [],
    "extraIpAddresses": [],
    "secretName": "",
    "services": [
      "alertmanager"
    ]
  },
  "alloy-agent": {
    "enabled": null,
    "extraDnsNames": [],
    "extraIpAddresses": [],
    "secretName": "",
    "services": [
      "alloy-agent"
    ]
  },
  "alloy-gateway": {
    "enabled": null,
    "extraDnsNames": [],
    "extraIpAddresses": [],
    "secretName": "",
    "services": [
      "alloy-gateway",
      "alloy-gateway-cluster"
    ]
  },
  "grafana": {
    "enabled": null,
    "extraDnsNames": [],
    "extraIpAddresses": [],
    "secretName": "",
    "services": [
      "grafana"
    ]
  },
  "loki": {
    "enabled": null,
    "extraDnsNames": [],
    "extraIpAddresses": [],
    "secretName": "",
    "services": [
      "loki-distributor",
      "loki-gateway",
      "loki-query-frontend",
      "loki-ruler"
    ]
  },
  "thanos": {
    "enabled": null,
    "extraDnsNames": [],
    "extraIpAddresses": [],
    "secretName": "",
    "services": [
      "thanos-receive",
      "thanos-query"
    ]
  }
}
Per-component certificates. One `Certificate` per component rather than one shared across the stack: the SANs differ, the namespaces differ under `split-namespace`, and a single key shared by every workload makes any one compromise total.

services is the list the SAN ladder is built from, and it is a list rather than a single name because Loki and Thanos are several Services behind one trust boundary. The defaults name exactly the Services this chart’s own URLs dial — a render check asserts that every in-cluster destination URL matches a SAN on the corresponding certificate, so a Service added to a destination and forgotten here fails the render rather than the handshake.

Each entry takes:

KeyMeaning
enabledRender this certificate. Unset follows certificates.enabled.
servicesService names the SAN ladder is built for.
extraDnsNamesAppended verbatim — an ingress host, a mesh name.
extraIpAddressesAppended to the IP SANs, which already carry 127.0.0.1.
secretNameWhere the material lands. Defaults to <release>-<component>-tls.

Materialize Integration#

Materialize-specific configuration values.

KeyTypeDefaultDescription
materialize.namespaceslist
[]
Namespaces to scrape Materialize workloads from. Empty list means all namespaces the chart can read.
materialize.environmentd.serviceMonitorobject
{
  "enabled": true,
  "metricEndpoints": [],
  "selector": {},
  "targetLabels": []
}
ServiceMonitor/PodMonitor configuration for environmentd
materialize.environmentd.serviceMonitor.enabledbooltrueDeploy a PodMonitor to collect Materialize metrics.
materialize.environmentd.serviceMonitor.selectorobject
{}
Override for default selector
materialize.environmentd.serviceMonitor.targetLabelslist
[]
Override for default labels to copy from the pod into metrics
materialize.environmentd.serviceMonitor.metricEndpointslist
[]
Override for default metric endpoints The defaults carry `metricRelabelings` that copy label aliases into their canonical names, such as `instance_id` into `cluster_id`. A replacement restates them from `pre-rendered/scrapers/prometheus-operator/podmonitor-environmentd.yaml`, or its series lack the canonical labels.
materialize.environmentdSQL.serviceMonitorobject
{
  "enabled": true,
  "metricEndpoints": [],
  "selector": {},
  "targetLabels": []
}
ServiceMonitor/PodMonitor configuration for environmentd-sql
materialize.environmentdSQL.serviceMonitor.enabledbooltrueDeploy a PodMonitor to collect Materialize metrics.
materialize.environmentdSQL.serviceMonitor.selectorobject
{}
Override for default selector
materialize.environmentdSQL.serviceMonitor.targetLabelslist
[]
Override for default labels to copy from the pod into metrics
materialize.environmentdSQL.serviceMonitor.metricEndpointslist
[]
Override for default metric endpoints The defaults share one port, so each copies `__metrics_path__` into `metrics_path` with a `relabelings` entry. A replacement keeps that on every endpoint, or their `up` and `scrape_*` series collide. The defaults carry `metricRelabelings` that copy label aliases into their canonical names, such as `instance_id` into `cluster_id`. A replacement restates them from `pre-rendered/scrapers/prometheus-operator/podmonitor-sql.yaml`, or its series lack the canonical labels.
materialize.clusterd.serviceMonitorobject
{
  "enabled": true,
  "metricEndpoints": [],
  "selector": {},
  "targetLabels": []
}
ServiceMonitor/PodMonitor configuration for clusterd
materialize.clusterd.serviceMonitor.enabledbooltrueDeploy a PodMonitor to collect Materialize metrics.
materialize.clusterd.serviceMonitor.selectorobject
{}
Override for default selector
materialize.clusterd.serviceMonitor.targetLabelslist
[]
Override for default labels to copy from the pod into metrics The default `metricRelabelings` fill `cluster_id` and `replica_id` from the `cluster.environmentd.materialize.cloud/cluster-id` and `replica-id` labels copied here, so a replacement keeps both.
materialize.clusterd.serviceMonitor.metricEndpointslist
[]
Override for default metric endpoints The defaults carry `metricRelabelings` that copy label aliases into their canonical names, such as `instance_id` into `cluster_id`. A replacement restates them from `pre-rendered/scrapers/prometheus-operator/podmonitor-clusterd.yaml`, or its series lack the canonical labels.
materialize.balancerd.serviceMonitorobject
{
  "enabled": true,
  "metricEndpoints": [],
  "selector": {},
  "targetLabels": []
}
ServiceMonitor/PodMonitor configuration for balancerd
materialize.balancerd.serviceMonitor.enabledbooltrueDeploy a PodMonitor to collect balancerd's connection metrics (`mz_balancer_*`).
materialize.balancerd.serviceMonitor.selectorobject
{}
Override for default selector
materialize.balancerd.serviceMonitor.targetLabelslist
[]
Override for default labels to copy from the pod into metrics
materialize.balancerd.serviceMonitor.metricEndpointslist
[]
Override for default metric endpoints
materialize.console.serviceMonitorobject
{
  "enabled": true,
  "metricEndpoints": [],
  "selector": {},
  "targetLabels": []
}
ServiceMonitor/PodMonitor configuration for the console
materialize.console.serviceMonitor.enabledbooltrueDeploy a PodMonitor to collect the console's request metrics (`mz_console_*`). Console pods before Materialize v26.49.0 declare no `internal-http` port and yield no target.
materialize.console.serviceMonitor.selectorobject
{}
Override for default selector
materialize.console.serviceMonitor.targetLabelslist
[]
Override for default labels to copy from the pod into metrics
materialize.console.serviceMonitor.metricEndpointslist
[]
Override for default metric endpoints
materialize.deploymentModestring"self-managed"Where Materialize runs: `self-managed` or `cloud`. Decides the prefix the alerting rules expect on SQL-backed metrics (`mz_`, or Cloud's `v2_mz_`).
materialize-system.namespacestring"materialize-environment"The namespace materialize-system was deployed into
materialize-system.serviceMonitorobject
{
  "enabled": true
}
ServiceMonitor/PodMonitor configuration TODO: nothing uses this yet
materialize-operator.namespacestring"materialize"The namespace materialize-operator was deployed into
materialize-operator.serviceMonitorobject
{
  "enabled": true,
  "metricEndpoints": [],
  "selector": {},
  "targetLabels": []
}
ServiceMonitor/PodMonitor configuration
materialize-operator.serviceMonitor.selectorobject
{}
Override for default selector
materialize-operator.serviceMonitor.targetLabelslist
[]
Override for default labels to copy from the pod into metrics
materialize-operator.serviceMonitor.metricEndpointslist
[]
Override for default metric endpoints The defaults carry `metricRelabelings` that copy label aliases into their canonical names, such as `instance_id` into `cluster_id`. A replacement restates them from `pre-rendered/scrapers/prometheus-operator/podmonitor-materialize-operator.yaml`, or its series lack the canonical labels.

Networking collection#

Collection from the cluster’s networking layer.

The CNI is the one part of the platform this chart cannot assume, because it differs per cloud and per install: EKS defaults to the AWS VPC CNI, GKE to Dataplane V2, AKS to Azure CNI powered by Cilium.

Rather than asking which one is present, the monitors below are all deployed and each is self-disabling. Their pod selectors match nothing on a cluster running a different vendor, and their endpoints name container ports that exist only where the vendor exposes metrics at all — so a cluster collects from whichever it runs, and from nothing else. Every series they collect carries a network_component label, which is what the infra-net dashboard discovers its dataplane from.

Leaving these on costs a cluster nothing it does not use. Turn one off to stop collecting from a CNI that is present.

KeyTypeDefaultDescription
networking.cni.aws-vpc.enabledbooltrueCollect from the AWS VPC CNI (`aws-node`): IPAM, ENI and address exhaustion, plus NetworkPolicy drops from the node agent where it runs.
networking.cni.aws-vpc.namespaceslist
[]
Namespaces to look for the CNI daemon in.
networking.cni.aws-vpc.selectorobject
{}
Override for default pod selector
networking.cni.aws-vpc.metricEndpointslist
[]
Override for default metric endpoints
networking.cni.cilium.enabledbooltrueCollect from Cilium, including GKE Dataplane V2 and Azure CNI powered by Cilium, plus Hubble where its metrics are enabled. Note that GKE Dataplane V2 disables the agent's Prometheus endpoint, so this collects nothing there by design — see the dashboard's CNI tab.
networking.cni.cilium.namespaceslist
[]
Namespaces to look for the CNI daemon in.
networking.cni.cilium.selectorobject
{}
Override for default pod selector
networking.cni.cilium.metricEndpointslist
[]
Override for default metric endpoints

Pipeline configuration#

Pipeline configuration values that drive Alloy behavior and defaults.

The Alloy pipeline is the flagship deliverable of this chart. Pipeline configuration is expressed in values, rendered into ConfigMaps under templates/pipelines/, and consumed by the Alloy agent and gateway release instances.

KeyTypeDefaultDescription
pipeline.preValidateJob.enabledbooltrueRun a hook/dependency to validate rendered configs before rolling them out
pipeline.preValidateJob.annotationsobject
{
  "helm.sh/hook": "pre-install,pre-upgrade",
  "helm.sh/hook-delete-policy": "before-hook-creation"
}
Job specific annotations This should be used to control when the job is executed. The default is as a helm hook, but setting any other annotation overrides this.
pipeline.preValidateJob.backoffLimitint1Number of times to retry the job before failing the release.
pipeline.preValidateJob.podSecurityContextobject
{
  "fsGroup": 473,
  "runAsGroup": 473,
  "runAsUser": 473,
  "seccompProfile": {
    "type": "RuntimeDefault"
  }
}
Security context for the pre-validate job pod. This is the hardened recommendation with the alloy user.
pipeline.preValidateJob.containerSecurityContextobject
{
  "allowPrivilegeEscalation": false,
  "capabilities": {
    "drop": [
      "ALL"
    ]
  },
  "readOnlyRootFilesystem": true,
  "runAsGroup": 473,
  "runAsNonRoot": true,
  "runAsUser": 473
}
Security context for the pre-validate job container. This is the hardened recommendation with the alloy user.
pipeline.envobject
{}
Environment variables to set in agent and gateway pods. These support tpl rendering. Since these are injected into an envFrom, any env entries take precedence.
pipeline.env.CLUSTER_NAMEstring"{{ include \"mzmon.clusterName\" $ }}"The cluster name the agent and gateway stamp as `cluster`. Set `clusterName` instead. Defaults to `clusterName`, so logs and metrics agree with alerts. A literal here overrides it for Alloy alone, and the render warns that the two differ.
pipeline.env.GATEWAY_LOG_LEVELstring"info"Level for the gateway logs.

Log configuration#

Configuration for log behavior

KeyTypeDefaultDescription
pipeline.logging.agent.rateLimitint5000Rate limit for alloy agent incoming pod logs. This is per agent.
pipeline.logging.agent.burstint20000Burst limit for alloy agent incoming pod logs.
pipeline.logging.agent.destination.loki.urlstring"http://alloy-gateway.{{ include \"mzmon.alloyGateway.namespace\" $ }}.svc:3100/loki/api/v1/push"alloy-gateway push endpoint URL.
pipeline.logging.agent.destination.loki.retriesobject
{
  "maxBackoffPeriod": "5m",
  "maxBackoffRetries": 10,
  "minBackoffPeriod": "1s",
  "retryOnHttp429": true
}
Retry configuration.
pipeline.logging.agent.destination.loki.authTypestring"none"Type of authentication to use with the alloy-gateway endpoint. Use none if no authentication is required.
pipeline.logging.agent.destination.loki.tls.enabledboolfalseWhether to enable TLS for alloy-gateway dest.
pipeline.logging.agent.destination.loki.tls.verifybooltrueWhether to verify the TLS certificate for the alloy-gateway dest.
pipeline.logging.agent.destination.loki.tls.castring""Certificate Authority (CA) PEM contents for TLS.
pipeline.logging.agent.destination.loki.tls.certstring""Client certificate PEM contents for TLS.
pipeline.logging.agent.destination.loki.tls.keystring""Client private key PEM contents for TLS.
pipeline.logging.agent.destination.loki.tls.caFilestring""Paths to certificate material on disk, and the preferred carrier for anything cert-manager renews. The inline `ca`/`cert`/`key` above travel through **environment variables**, which are captured once at process start. cert-manager renews by rewriting the Secret in place, so an env-carried PEM works for exactly one certificate lifetime and then fails on every hop at once, months after the change that caused it and with no deploy nearby to blame.

A mounted file does not have that problem: the kubelet refreshes Secret contents atomically, and Alloy’s client paths pick up the new material on the next connection. Set these to paths under /etc/mzmon/tls, which is where the chart mounts the certificate it issues for this component.

Inline PEM stays supported as the bring-your-own-PKI escape hatch.

pipeline.logging.agent.destination.loki.tls.serverNamestring""Alternative SNI (Server Name Indication) to specify.
pipeline.logging.agent.destination.loki.tls.minVersionstring"TLS13"Minimum TLS version to allow. Use TLS12 if you need better compat. TLS11 and TLS10 are not recommended for production.
pipeline.logging.gateway.serverobject
{
  "tls": {
    "certFile": "",
    "clientAuth": "NoClientCert",
    "clientCAFile": "",
    "enabled": false,
    "keyFile": "",
    "minVersion": "TLS13",
    "reloadInterval": "1m"
  }
}
Server-side TLS on this role's listeners. The other half of the `destination.*.tls` blocks: those configure Alloy as a *client*, this configures it as a *server*. A configured client against an unconfigured server is TLS off, which is why the two have to move together.

The gateway has three listeners, not two, and they are split across the two pipeline trees the same way the listeners are:

ListenerPortConfigured by
loki.source.api3100pipeline.logging.gateway.server.tls
otelcol.receiver.otlp4317 / 4318pipeline.logging.gateway.server.tls
prometheus.receive_http9090pipeline.metrics.gateway.server.tls

receive_http is the one to check last: it lives in the metrics tree and is easy to leave behind, and forgetting it leaves the metrics write path wide open behind a logs path that looks secured. A validator refuses that combination rather than letting it ship.

clientAuth is exposed rather than implied because the middle state is what a safe rollout needs, and an operator who cannot name that state cannot perform one. Values are Go’s tls.ClientAuthType:

ValueMeaning
NoClientCertdo not ask (phase 1)
RequestClientCertask, do not verify
RequireAnyClientCertrequire, do not verify against a CA
VerifyClientCertIfGivenverify what is presented, allow none (phase 2)
RequireAndVerifyClientCertrequire and verify (phase 3)

Unlike Loki’s server, Alloy’s listeners are not probed by the kubelet on the same port — the readiness probe is on 12345 — so phase 3 is reachable here.

pipeline.logging.gateway.server.tls.enabledboolfalseServe TLS on this role's listeners.
pipeline.logging.gateway.server.tls.certFilestring""Certificate and key the listener presents. Files rather than inline PEM, because cert-manager renews by rewriting the Secret and Alloy re-reads the file on new connections.
pipeline.logging.gateway.server.tls.clientCAFilestring""CA used to verify client certificates. Required by every `clientAuth` value except `NoClientCert`.
pipeline.logging.gateway.server.tls.clientAuthstring"NoClientCert"How hard to insist on a client certificate. See the table above.
pipeline.logging.gateway.server.tls.minVersionstring"TLS13"Minimum TLS version the listener accepts.
pipeline.logging.gateway.server.tls.reloadIntervalstring"1m"How often the OTLP listeners re-read their certificate files. `otelcol.receiver.otlp` only — the `loki.source.api` listener has no equivalent and re-reads on new connections instead. This is the best renewal behaviour of anything in the stack, so it is worth setting: without it a long-lived gRPC stream can outlive the certificate that established it.
pipeline.logging.gateway.destination.loki.enabledbooltrueEnable writing to a loki destination. By default, we use the in-cluster loki
pipeline.logging.gateway.destination.loki.urlstring"http://loki-distributor.{{ include \"mzmon.loki.namespace\" $ }}.svc:3100/loki/api/v1/push"Loki push endpoint URL.
pipeline.logging.gateway.destination.loki.retriesobject
{
  "maxBackoffPeriod": "5m",
  "maxBackoffRetries": 10,
  "minBackoffPeriod": "1s",
  "retryOnHttp429": true
}
Retry configuration.
pipeline.logging.gateway.destination.loki.authTypestring"none"Type of authentication to use with the loki endpoint. Use none if no authentication is required. Use basicAuth for username/password. Use bearer for bearer token. Use oauth2 for OAuth2 client credentials.
pipeline.logging.gateway.destination.loki.basicAuthobject
{
  "password": "",
  "passwordEnv": "GATEWAY_LOKI_DEST_PASSWORD",
  "username": "",
  "usernameEnv": "GATEWAY_LOKI_DEST_USERNAME"
}
Configuration for auth when using authType=basicAuth You will need to provide alloy-gateway.alloy.agent.extraEnv TODO: add a check for this
pipeline.logging.gateway.destination.loki.bearerobject
{
  "token": "",
  "tokenEnv": "GATEWAY_LOKI_DEST_BEARER_TOKEN"
}
Configuration for bearer token when using authType=bearer This is used for bearer type tokens.
pipeline.logging.gateway.destination.loki.oauth2object
{
  "clientId": "",
  "clientIdEnv": "GATEWAY_LOKI_DEST_OAUTH2_CLIENT_ID",
  "clientSecret": "",
  "clientSecretEnv": "GATEWAY_LOKI_DEST_OAUTH2_CLIENT_SECRET",
  "scopes": [],
  "tokenUrl": "",
  "tokenUrlEnv": "GATEWAY_LOKI_DEST_OAUTH2_TOKEN_URL"
}
Configuration for OAuth2 when using authType=oauth2
pipeline.logging.gateway.destination.loki.tls.enabledboolfalseWhether to enable TLS for the loki destination.
pipeline.logging.gateway.destination.loki.tls.verifybooltrueWhether to verify the TLS certificate for the loki destination.
pipeline.logging.gateway.destination.loki.tls.castring""Certificate Authority (CA) PEM contents for TLS.
pipeline.logging.gateway.destination.loki.tls.certstring""Client certificate PEM contents for TLS.
pipeline.logging.gateway.destination.loki.tls.keystring""Client private key PEM contents for TLS.
pipeline.logging.gateway.destination.loki.tls.caFilestring""Paths to certificate material on disk, and the preferred carrier for anything cert-manager renews. The inline `ca`/`cert`/`key` above travel through **environment variables**, which are captured once at process start. cert-manager renews by rewriting the Secret in place, so an env-carried PEM works for exactly one certificate lifetime and then fails on every hop at once, months after the change that caused it and with no deploy nearby to blame.

A mounted file does not have that problem: the kubelet refreshes Secret contents atomically, and Alloy’s client paths pick up the new material on the next connection. Set these to paths under /etc/mzmon/tls, which is where the chart mounts the certificate it issues for this component.

Inline PEM stays supported as the bring-your-own-PKI escape hatch.

pipeline.logging.gateway.destination.loki.tls.serverNamestring""Alternative SNI (Server Name Indication) to specify.
pipeline.logging.gateway.destination.loki.tls.minVersionstring"TLS13"Minimum TLS version to allow. Use TLS12 if you need better compat. TLS11 and TLS10 are not recommended for production.
pipeline.logging.gateway.destination.otel.enabledboolfalseEnable writing to an OpenTelemetry/OTLP destination. By default, we do not use the OTLP destination. NOTE: This is configured in the pipeline.metrics.gateway.destination.otlp block, not the logging block.
pipeline.logging.tenancy.staticTenantstring"loki"Default tenant to write logs to. This is used when tenantMap values is set to `static`.
pipeline.logging.tenancy.tenantMapobject
{
  "audit": "static",
  "default": "static",
  "environment": "static",
  "infra": "static"
}
Type of tenancy used to write logs. Use static to write to the staticTenant value (recommended). Use byEnvironment to separate by the environment name. Use byNamespace to use the namespace of the source pod as the tenant. Use byLabel to use a label (tenantLabel) to specifically identify the tenant. Use none to disable tenancy (only use if loki does not have tenancy).

Metrics configuration#

Configuration for metrics behavior

KeyTypeDefaultDescription
pipeline.metrics.kubeleth5{"scrapeInterval":"60s", "tlsInsecureSkipVerify":false}Node-local container metrics, scraped from each node's kubelet.

The gateway scrapes /metrics/cadvisor on every kubelet rather than the agent running its own cAdvisor. The kubelet already computes these stats, so running our own meant computing them twice per node — measured at ~750Mi per agent against a 200Mi logs-only envelope. Scraping the kubelet removes the housekeeping cost entirely and puts the remaining scrape cost on the gateway, where it is shared across replicas rather than reserved on every node — the distinction that matters when bin packing.

Coverage is not the tradeoff it was assumed to be: a GKE kubelet serves 69 distinct container_* metrics against the 70 an in-process cAdvisor produced, including the descriptor / socket / thread family that otherwise needs the host PID namespace. Every container_* metric this chart’s queries reference is present.

Needs nodes and nodes/metrics RBAC, which the Alloy subchart’s default clusterRules already grant.

pipeline.metrics.kubelet.scrapeIntervalstring"60s"Scrape interval for the kubelet cAdvisor endpoint. The dominant cost lever — roughly 6.7k series per node per scrape.
pipeline.metrics.kubelet.tlsInsecureSkipVerifyboolfalseSkip verification of the kubelet's serving certificate.

Left false: on GKE the kubelet certificate verifies against the in-cluster CA, which the chart passes as ca_file. A distribution that signs kubelet certs with a CA the pod does not trust needs this true, and the failure is quiet — scrapes fail and container metrics stop rather than anything erroring at install. Check up{job="cadvisor"} when bringing up a new distribution.

pipeline.metrics.kubeProxyobject
{
  "scrapeInterval": "60s"
}
Scraping kube-proxy, the service proxy.

kube-proxy turns a Service into rules on the node, so its sync latency is the answer when connections to a Service fail while every pod behind it is healthy. The infra-net dashboard’s kube-proxy row reads it.

Discovery is a server-side pod selector, so a cluster without kube-proxy returns no targets and this costs nothing. GKE Dataplane V2 and any Cilium install in kube-proxy-replacement mode are exactly that case. No enable toggle, for the same reason kubelet above has none: the pipeline is pre-rendered, so a key here would have to be read by something to mean anything, and the scrape already disables itself where kube-proxy is absent. A knob written to the env ConfigMap and read by nothing is a bug this repo has shipped once already.

pipeline.metrics.kubeProxy.scrapeIntervalstring"60s"Scrape interval for kube-proxy. Roughly 230 series per node.
pipeline.metrics.providerh5{"azure":{"aks":{"clusters":[]}, "blob":{"storageAccounts":[]}, "cloudEnvironment":"azurecloud", "enabled":false, "loadBalancer":{"clusters":[]}, "metricImportance":"extended", "natGateway":{"names":[]}, "postgres":{"servers":[]}, "scrapeInterval":"5m", "scrapeTimeout":"2m", "subscriptionId":""}, "cloudwatch":{"eks":{"clusters":[]}, "enabled":false, "metricImportance":"extended", "natGateway":{"ids":[]}, "nlb":{"loadBalancers":[]}, "rds":{"instances":[]}, "region":"", "s3":{"buckets":[]}, "scrapeInterval":"5m", "scrapeTimeout":"2m"}, "gcp":{"cloudNat":{"gateways":[]}, "cloudSql":{"instances":[]}, "compute":{"regions":[]}, "enabled":false, "gcs":{"buckets":[]}, "loadBalancing":{"forwardingRules":[], "networks":[]}, "metricImportance":"extended", "projectId":"", "requestInterval":"10m", "scrapeInterval":"5m", "scrapeTimeout":"2m"}}Cloud provider metrics, pulled into the gateway.

The gateway can pull what a cloud provider’s monitoring API publishes about the managed database and the buckets a deployment depends on, and write it beside every other metric. The result shares retention, PromQL and alerting with the rest of the stack, so a provider series is joinable with mz_persist_* in one expression. The external-dependency design records why this is a pull rather than a Grafana datasource.

ProviderServicesAlloy component
cloudwatchRDS instances, S3 buckets, EKS nodes, Network Load Balancers, NAT gatewaysprometheus.exporter.cloudwatch
gcpCloud SQL instances, GCS buckets, Compute Engine quota, load balancer forwarding rules, Cloud NAT gatewaysprometheus.exporter.gcp
azurePostgreSQL Flexible Servers, Blob Storage accounts, AKS clusters and their load balancers, NAT gatewaysprometheus.exporter.azure

Every provider is off by default. Provider collection is an addition to what the clients already report about the same dependencies, not a replacement for it.

Resources are named, not discovered. Each service lists the instances or buckets to watch, and nothing else is pulled. Tag discovery would find every resource in the account and bill for each of them. Where a resource cannot be named in advance, a named scope stands in for it: EKS nodes are found by their cluster, AKS node VMs and load balancers by theirs, and GCP load balancers by their VPC network. A provider that is enabled with nothing listed fails at render.

Credentials never travel through values. The pull uses the gateway pod’s own cloud identity, bound through alloy-gateway.serviceAccount.annotations:

ProviderIdentityGrant
cloudwatchIRSA (eks.amazonaws.com/role-arn), EKS Pod Identity, or static keys as AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in the mzmon-alloy-gateway-env Secretcloudwatch:GetMetricStatistics; iam:ListAccountAliases to fill the account_alias label, without which every pull logs a warning
gcpWorkload Identity (iam.gke.io/gcp-service-account)roles/monitoring.viewer on the project
azureWorkload identity (azure.workload.identity/client-id, plus the azure.workload.identity/use: "true" label in alloy-gateway.controller.podLabels), or a service principal as AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET in the mzmon-alloy-gateway-env SecretMonitoring Reader on each named resource

The data is minutes old when it arrives. CloudWatch publishes RDS metrics a few minutes late and S3 storage metrics once a day. Cloud Monitoring stamps each sample with its own time: Cloud SQL samples arrive about three minutes old, and GCS storage samples over ten. Azure Monitor values are a minute or two behind, and blob capacity is refreshed daily. Query these families with last_over_time(...[15m]) or wider, and do not page on them.

Each pull is billed by the provider. The cost is a function of the interval and of how many resources and metrics are listed, not of how many dashboards are open. The interval is the main lever.

Every replica runs the exporter, and one scrapes it. The scrape is clustered, so a provider’s API is called once per interval however many gateway replicas there are.

The metric sets are fixed, and live in the pipeline, not here. Each pull is a custom component in the chart’s gateway-provider pipeline, instantiated per resource (CloudWatch) or per service (GCP and Azure) for what is listed below. Changing what is pulled is a change to that pipeline.

pipeline.metrics.provider.cloudwatch.enabledboolfalsePull CloudWatch metrics for the resources listed below.
pipeline.metrics.provider.cloudwatch.regionstring""AWS region the resources live in, and the region CloudWatch and STS are called in. Required when enabled.
pipeline.metrics.provider.cloudwatch.scrapeIntervalstring"5m"How often the gateway pulls. Every pull is one billed `GetMetricStatistics` call per metric per resource.
pipeline.metrics.provider.cloudwatch.scrapeTimeoutstring"2m"Scrape timeout. Must not exceed `scrapeInterval`.
pipeline.metrics.provider.cloudwatch.metricImportancestring"extended"Importance tier assigned to every CloudWatch family, for destinations that filter by `minMetricImportance`. One of `essential`, `recommended`, `extended`, `diagnostic`.
pipeline.metrics.provider.cloudwatch.rds.instanceslist
[]
RDS DB instance identifiers to watch, as `DBInstanceIdentifier`. Each is pulled for a fixed set: CPU, and the credit balance of a burstable `db.t*` class; freeable memory, free storage and connections; read and write latency and queue depth; the burst and EBS balances whose exhaustion slows every write while every in-database metric stays flat; and transaction-ID consumption. A metric CloudWatch does not publish for an instance is absent, never zero.
pipeline.metrics.provider.cloudwatch.s3.bucketslist
[]
S3 bucket names to watch, for their size in standard storage and their object count, which S3 publishes daily.
pipeline.metrics.provider.cloudwatch.eks.clusterslist
[]
EKS cluster names whose nodes to watch. Each cluster's nodes are found by the `aws:eks:cluster-name` tag EKS and Karpenter put on them, since the nodes themselves come and go, and pulled for AWS's status checks: the host, the instance and its attached EBS volumes. Its managed node groups are pulled for desired, in-service and pending instances against their maximum. Listing any cluster also pulls the region's On-Demand vCPU usage, account-wide, which is what the vCPU quota counts; CloudWatch does not publish the quota itself. Needs `cloudwatch:GetMetricData`, `cloudwatch:ListMetrics`, `tag:GetResources` and `autoscaling:DescribeAutoScalingGroups` beside the grant the other services need.
pipeline.metrics.provider.cloudwatch.nlb.loadBalancerslist
[]
Network Load Balancers to watch, each by ARN, with the target groups behind it. Each entry is `arn`, the load balancer's ARN, and `targetGroups`, its target groups' ARNs; either may be given as the end of the ARN (`net//`, `targetgroup//`), which is how CloudWatch names them. The load balancer is pulled for its open and new connections, the bytes it carried, and the resets sent by clients, targets and the load balancer itself. Each target group is pulled for its healthy and unhealthy target counts, which CloudWatch publishes only per target group, so a load balancer listed without them has no health on the dashboard.
loadBalancers:
  - arn: arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/mz-nlb/0123456789abcdef
    targetGroups:
      - arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/mz-pgwire/0123456789abcdef
pipeline.metrics.provider.cloudwatch.natGateway.idslist
[]
NAT gateway IDs to watch, such as `nat-0123456789abcdef0`. Each is pulled for port allocation errors and dropped packets, which is how exhaustion shows; connections closed for idling, which a long-lived source connection hits when it is quiet for 350 seconds; open connections; and connection attempts beside those established.
pipeline.metrics.provider.gcp.enabledboolfalsePull Cloud Monitoring metrics for the resources listed below.
pipeline.metrics.provider.gcp.projectIdstring""Google Cloud project that holds the resources. Required when enabled.
pipeline.metrics.provider.gcp.scrapeIntervalstring"5m"How often the gateway pulls. Every pull lists time series once per metric type.
pipeline.metrics.provider.gcp.scrapeTimeoutstring"2m"Scrape timeout. Must not exceed `scrapeInterval`.
pipeline.metrics.provider.gcp.requestIntervalstring"10m"How far back each pull reaches. Only the newest point per series is kept, so a window longer than the interval costs nothing in samples and covers a late point.
pipeline.metrics.provider.gcp.metricImportancestring"extended"Importance tier assigned to every Cloud Monitoring family, for destinations that filter by `minMetricImportance`. One of `essential`, `recommended`, `extended`, `diagnostic`.
pipeline.metrics.provider.gcp.cloudSql.instanceslist
[]
Cloud SQL instance names to watch — the instance name, not the `project:region:instance` connection name. Each is pulled for a fixed set: CPU, memory and disk utilization, backends by database, state and application, transaction-ID utilization, and whether the instance is up.
pipeline.metrics.provider.gcp.gcs.bucketslist
[]
GCS bucket names to watch. Each is pulled for its stored bytes and object count, split into live, noncurrent and soft-deleted, which is what shows reclaimable waste. GCS request counts are deliberately not pulled: they are a per-minute DELTA, and the exporter counts only the newest point of each pull, so at a five-minute interval they read about a fifth of the truth. The Loki, Thanos and persist clients report the same requests exactly.
pipeline.metrics.provider.gcp.compute.regionslist
[]
Compute Engine regions whose quota to watch, such as `us-east1` — the regions the GKE node pools run in. Each region and its zones are pulled for the CPUs and local SSD each machine family uses against its quota, and for every request a quota refused. A node pool that cannot grow for quota looks exactly like one that cannot grow for capacity until this says which. Usage and limit are published a few times a day, so each pull reaches back a day.
pipeline.metrics.provider.gcp.loadBalancing.forwardingRuleslist
[]
Forwarding rule names of the passthrough Network Load Balancers to watch, external or internal. A LoadBalancer Service on GKE is one of these, and GKE names its rule `a` followed by the Service's UID without dashes, cut to 32 characters; `gcloud compute forwarding-rules list` shows them, with the Service in each one's description. Each is pulled for the bytes and packets it carried in each direction. Cloud Monitoring publishes no health for these load balancers.
pipeline.metrics.provider.gcp.loadBalancing.networkslist
[]
VPC network names whose passthrough Network Load Balancers to watch, external or internal. Every passthrough load balancer whose backends are in a listed network is pulled, whoever made it, beside any rule in `forwardingRules`. This is the way to reach a rule that cannot be named beforehand: GKE names a rule only once it makes it, so a LoadBalancer Service the same release creates, such as Grafana's, has no name to list. On a network that holds only the cluster, as the Terraform wrappers create it, that is the cluster's load balancers and nothing else.
pipeline.metrics.provider.gcp.cloudNat.gatewayslist
[]
Cloud NAT gateway names to watch — the NAT's name, not its Cloud Router's. Each is pulled for whether a VM was refused a source port, the most connections any one VM holds to a single destination, which its share of ports limits, the ports allocated per NAT address, open connections, and packets dropped, by reason. Port exhaustion inside the cluster looks like a source that stalls and reconnects with no in-cluster cause.
pipeline.metrics.provider.azure.enabledboolfalsePull Azure Monitor metrics for the resources listed below.
pipeline.metrics.provider.azure.subscriptionIdstring""Subscription that holds the resources, as its ID. Required when enabled.
pipeline.metrics.provider.azure.cloudEnvironmentstring"azurecloud"Azure cloud the subscription lives in: `azurecloud`, `azurechinacloud` or `azureusgovernmentcloud`.
pipeline.metrics.provider.azure.scrapeIntervalstring"5m"How often the gateway pulls. Every pull is one Resource Graph query per service, then one metrics call per resource.
pipeline.metrics.provider.azure.scrapeTimeoutstring"2m"Scrape timeout. Must not exceed `scrapeInterval`.
pipeline.metrics.provider.azure.metricImportancestring"extended"Importance tier assigned to every Azure Monitor family, for destinations that filter by `minMetricImportance`. One of `essential`, `recommended`, `extended`, `diagnostic`.
pipeline.metrics.provider.azure.postgres.serverslist
[]
PostgreSQL Flexible Server names to watch — the name, not the FQDN or resource ID. Each is pulled for a fixed set: CPU, and the credits of a Burstable tier; memory, storage and connection headroom, and failed connections; disk queue depth, and how much of the disk's provisioned IOPS and throughput is in use, which at 100% throttles every write while every in-database metric stays flat; transaction-ID consumption; and whether Azure can reach the server. A metric Azure does not publish for a server is absent, never zero.
pipeline.metrics.provider.azure.blob.storageAccountslist
[]
Storage account names to watch, for their blob service. Each is pulled for its stored bytes and blob count, which Azure refreshes about once a day, and for request availability and latency, at the service and end to end. Transaction counts are deliberately not pulled: the exporter reads the newest five-minute bucket as the scrape ends it, which is a minute or so short. The Loki, Thanos and persist clients report the same requests exactly.
pipeline.metrics.provider.azure.aks.clusterslist
[]
AKS cluster names to watch — the name, not the resource ID. Each is pulled for its managed cluster autoscaler, which AKS runs in its control plane where nothing can scrape it: pods it cannot place, whether it considers the cluster safe to scale, whether scale-down is cooling down, and nodes it would remove. Its node pools' scale sets, found through the cluster's node resource group, are pulled for Azure's own view of whether each node VM is up. The identity needs Monitoring Reader on the cluster and on its node resource group.
pipeline.metrics.provider.azure.loadBalancer.clusterslist
[]
AKS cluster names whose load balancers to watch — the name, not the resource ID. AKS puts a cluster's LoadBalancer Services behind `kubernetes` and, for internal ones, `kubernetes-internal`, in its node resource group. Every cluster's are named the same, so the cluster is what names them. Each is pulled per frontend address for Azure's own probe of the data path, each backend's health probe results, and the bytes and new connections of the newest minute. The identity needs Monitoring Reader on the node resource group.
pipeline.metrics.provider.azure.natGateway.nameslist
[]
NAT gateway names to watch — the name, not the resource ID. Each is pulled for Azure's own probe of its data path, SNAT connections attempted and failed, and dropped packets. A failed SNAT connection is one the gateway could not give a source port. Names are unique only within a resource group, so two gateways of one name in the subscription are both pulled.
pipeline.metrics.gateway.denyMetricslist
[]
Denylist of metrics that are excluded from being exported These are |'d in a regex pattern
pipeline.metrics.gateway.serverobject
{
  "tls": {
    "certFile": "",
    "clientAuth": "NoClientCert",
    "clientCAFile": "",
    "enabled": false,
    "keyFile": "",
    "minVersion": "TLS13",
    "reloadInterval": "1m"
  }
}
Server-side TLS on this role's listeners. The other half of the `destination.*.tls` blocks: those configure Alloy as a *client*, this configures it as a *server*. A configured client against an unconfigured server is TLS off, which is why the two have to move together.

The gateway has three listeners, not two, and they are split across the two pipeline trees the same way the listeners are:

ListenerPortConfigured by
loki.source.api3100pipeline.logging.gateway.server.tls
otelcol.receiver.otlp4317 / 4318pipeline.logging.gateway.server.tls
prometheus.receive_http9090pipeline.metrics.gateway.server.tls

receive_http is the one to check last: it lives in the metrics tree and is easy to leave behind, and forgetting it leaves the metrics write path wide open behind a logs path that looks secured. A validator refuses that combination rather than letting it ship.

clientAuth is exposed rather than implied because the middle state is what a safe rollout needs, and an operator who cannot name that state cannot perform one. Values are Go’s tls.ClientAuthType:

ValueMeaning
NoClientCertdo not ask (phase 1)
RequestClientCertask, do not verify
RequireAnyClientCertrequire, do not verify against a CA
VerifyClientCertIfGivenverify what is presented, allow none (phase 2)
RequireAndVerifyClientCertrequire and verify (phase 3)

Unlike Loki’s server, Alloy’s listeners are not probed by the kubelet on the same port — the readiness probe is on 12345 — so phase 3 is reachable here.

pipeline.metrics.gateway.server.tls.enabledboolfalseServe TLS on this role's listeners.
pipeline.metrics.gateway.server.tls.certFilestring""Certificate and key the listener presents. Files rather than inline PEM, because cert-manager renews by rewriting the Secret and Alloy re-reads the file on new connections.
pipeline.metrics.gateway.server.tls.clientCAFilestring""CA used to verify client certificates. Required by every `clientAuth` value except `NoClientCert`.
pipeline.metrics.gateway.server.tls.clientAuthstring"NoClientCert"How hard to insist on a client certificate. See the table above.
pipeline.metrics.gateway.server.tls.minVersionstring"TLS13"Minimum TLS version the listener accepts.
pipeline.metrics.gateway.server.tls.reloadIntervalstring"1m"How often the OTLP listeners re-read their certificate files. `otelcol.receiver.otlp` only — the `loki.source.api` listener has no equivalent and re-reads on new connections instead. This is the best renewal behaviour of anything in the stack, so it is worth setting: without it a long-lived gRPC stream can outlive the certificate that established it.
Prometheus remote-write destinations.#

Prometheus remote-write destinations, as a map of name to destination.

A map, not a single destination. Each key names one destination and becomes the label on that destination’s Alloy components, so the name is visible in component_id on the gateway’s own metrics and in its UI. The default map holds one entry, thanos, pointing at the bundled in-cluster Thanos Receive.

Every destination gets its own prometheus.remote_write component rather than a second endpoint block on a shared one. That costs a WAL per destination and buys the two things a shared component cannot give:

  • Its own minMetricImportance. The tier filter is a prometheus.relabel upstream of the component, so a destination on essential writes a WAL holding only essential series. A shared component can only filter with write_relabel_config, which runs on the way out of the WAL — every destination would pay full firehose disk regardless of tier. This is the whole reason the tiers exist: Amazon Managed Prometheus bills per sample ingested and per series.
  • Its own failure domain. A destination that stops accepting writes backs up its own WAL and nothing else. On a shared component a stuck endpoint holds back WAL truncation for every other endpoint too.

external_labels is per destination for the same reason, though the default (cluster from CLUSTER_NAME) is what every destination wants.

Names are Alloy component labels, so they must match [a-zA-Z_][a-zA-Z0-9_]*, and egress is reserved — the fan-out seam already uses it. A name that breaks either rule fails at render.

Fields are optional. Anything a destination omits falls back to the per-destination defaults below, so a second destination is usually three lines. Environment variable names are derived from the destination name when not given — GATEWAY_PROM_DEST_AMP, GATEWAY_PROMETHEUS_DEST_AMP_PASSWORD, GATEWAY_UNFILTERED_PROM_METRICS_AMP — and can be set explicitly where a caller (such as the Terraform module’s mzmon-alloy-gateway-env Secret) wants to choose them.

FieldDefaultMeaning
enabledtrueWrite to this destination.
url—Remote-write endpoint. Required when enabled.
minMetricImportanceallTier floor: essential, recommended, extended, diagnostic, all.
unfilteredMetricsEnvGATEWAY_UNFILTERED_PROM_METRICS_<NAME>Env var carrying the tier allowlist regex.
urlEnvGATEWAY_PROM_DEST_<NAME>Env var carrying the endpoint URL.
externalLabels{cluster: CLUSTER_NAME}external_labels on the component. Values are Alloy expressions.
authTypenonenone, basicAuth, bearer, oauth2, or sigv4.
basicAuth / bearer / oauth2 / sigv4see belowCredentials for the chosen authType.
tlsoffClient TLS for this hop.

A worked two-destination example — full fidelity in Thanos, only the alerting metrics in AMP — is in profiles/aws-amp-fanout.values.yaml.

OpenTelemetry/OTLP destinations.#

OpenTelemetry/OTLP destination configuration for metrics (and logging).

This supports several components and allows further customization of endpoints. Multiple exporters can be enabled at once.

KeyTypeDefaultDescription
pipeline.metrics.gateway.destination.otel.enabledboolfalseEnable writing metrics to an OpenTelemetry/OTLP destination. By default, we do not use the OTel destination. WARNING: if logging otel destination is enabled, this block will still be used for configuration! (just not for metrics)
pipeline.metrics.gateway.destination.otel.googleCloudExporter.enabledboolfalseEnable writing metrics to Google Cloud Managed Service for Prometheus.

Metrics go over OTLP to Google’s Telemetry API (telemetry.googleapis.com) and land as prometheus.googleapis.com/ metric types, queryable with PromQL in Cloud Monitoring. They are billed as Prometheus samples ingested, not per byte as custom workload.googleapis.com/ metrics are.

Names. The metric type carries the point kind: prometheus.googleapis.com/<name>/gauge, /counter, /histogram, /summary, and /unknown plus /unknown:counter for an untyped series. The gateway’s scrapes honor metadata, so the kind is the one the target declares. PromQL takes the bare name, as it does in Thanos, and a histogram answers to <name>_bucket, _count and _sum. There is no prefix to set: the Telemetry API accepts only prometheus.googleapis.com or no domain at all.

Labels. Cloud Monitoring files each series under a prometheus_target resource whose labels are project_id, location, cluster, namespace, job and instance. job and instance are the scrape target’s. namespace is the series’ own, as in Thanos. cluster is CLUSTER_NAME (the cluster label every other destination carries) unless the series has one of its own. location and the project come from the GKE metadata server unless location and project below say otherwise. A series that already carries a location, project_id, job or instance label keeps it as exported_<label>. Every series also carries collected_by="materialize-monitoring", which sets it apart from other Prometheus data in the project: GKE’s managed kube-state-metrics, for one, writes the same metric types under the same job.

Auth is the gateway’s ambient Google identity (Workload Identity on GKE) through otelcol.auth.google, which needs roles/monitoring.metricWriter and the telemetry.googleapis.com API enabled on the project. That component is public preview, which the gateway’s experimental stability level already allows.

pipeline.metrics.gateway.destination.otel.googleCloudExporter.compressionstring"gzip"Compression for the OTLP request body.
pipeline.metrics.gateway.destination.otel.googleCloudExporter.minMetricImportancestring"recommended"Only export metrics with the specified importance level. Values are "essential", "recommended", "extended", "diagnostic", "all"
pipeline.metrics.gateway.destination.otel.googleCloudExporter.endpointstring"https://telemetry.googleapis.com"Telemetry API endpoint. Override for a Private Service Connect endpoint.
pipeline.metrics.gateway.destination.otel.googleCloudExporter.projectstring""Google Cloud project to write to. Empty writes to the project the gateway runs in, as GKE's metadata server reports it. Required off GKE: `otelcol.auth.google` will not start without a project, and nothing else supplies one there.
pipeline.metrics.gateway.destination.otel.googleCloudExporter.locationstring""`location` label for every series: a Google Cloud region or zone. Empty uses the GKE cluster's own. Required off GKE: the Telemetry API refuses a point without one.
pipeline.metrics.gateway.destination.otel.googleCloudExporter.handlerslist
[
  "otelcol.processor.filter.googleCloud.input"
]
Handlers to use for the Google Cloud exporter: the first component of the chain in `config`.
pipeline.metrics.gateway.destination.otel.googleCloudExporter.configstringThe chain rendered by the chart's `mzmon.alloyGateway.otelDest.googleCloud` helper.Raw configuration for the Google Cloud export chain. Override it only to change the chain itself; every component is labelled `googleCloud`, and `handlers` must name its first one.
pipeline.metrics.gateway.destination.otel.datadogExporter.enabledboolfalseEnable writing to a Datadog destination.
pipeline.metrics.gateway.destination.otel.datadogExporter.compressionstring"gzip"Compression for logs/metrics Only gzip is supported for Datadog.
pipeline.metrics.gateway.destination.otel.datadogExporter.minMetricImportancestring"recommended"Only export metrics with the specified importance level. Values are "essential", "recommended", "extended", "diagnostic", "all"
pipeline.metrics.gateway.destination.otel.datadogExporter.hostMetadata.enabledboolfalseWhether to include host metadata in the Datadog exporter. FIXME: how do we support this in an Agent->Gateway architecture?
pipeline.metrics.gateway.destination.otel.datadogExporter.handlerslist
[
  "otelcol.exporter.datadog.destination.input"
]
Handlers to use for the Datadog exporter.
pipeline.metrics.gateway.destination.otel.datadogExporter.configstringAn `otelcol.exporter.datadog.destination` definition.Raw configuration for an otelcol.exporter.datadog block.
pipeline.metrics.gateway.destination.otel.otlpExporter.enabledboolfalseEnable writing to an OpenTelemetry/OTLP destination. This is the generic fallback for other destinations. You can also use this if you need to set a custom destination block.
pipeline.metrics.gateway.destination.otel.otlpExporter.urlstring""OTLP push endpoint URL. This does not need a protocol prefix (http:// or https://)
pipeline.metrics.gateway.destination.otel.otlpExporter.protocolstring"grpc"Protocol to use for OTLP. Use grpc for gRPC protocol. Use http for HTTP protocol.
pipeline.metrics.gateway.destination.otel.otlpExporter.compressionstring"gzip"Compression to use Use gzip for better compatibility. Use snappy for better performance.
pipeline.metrics.gateway.destination.otel.otlpExporter.minMetricImportancestring"all"Only export metrics with the specified importance level. Values are "essential", "recommended", "extended", "diagnostic", "all"
pipeline.metrics.gateway.destination.otel.otlpExporter.handlerslist
[
  "otelcol.exporter.otlp[http].destination.input"
]
Handlers to use for the OTLP exporter. Update this if your config was customized.
pipeline.metrics.gateway.destination.otel.otlpExporter.configstring
{{- $exporterType := ternary "otlp" "otlphttp" ( eq .Values.pipeline.metrics.gateway.destination.otel.otlpExporter.protocol "grpc" ) }}
otelcol.exporter.{{ $exporterType }} "destination" {
    client {
        endpoint = {{ .Values.pipeline.metrics.gateway.destination.otel.otlpExporter.url | required "destination.otlp.url must be set" | quote }}
        compression = {{ .Values.pipeline.metrics.gateway.destination.otel.otlpExporter.compression | quote }}
{{- if ( include "mzmon.alloyGateway.otelDest.authEnabled" $ ) }}
        auth = {{ include "mzmon.alloyGateway.otelDest.authHandler" $ }}
{{- end }}
    }
}
Raw configuration for an otelcol.exporter.otlp block. Use this if you need to configure multiple destinations or use an alternative endpoint entirely.
OpenTelemetry Authentication Configuration#

Configuration for OpenTelemetry/OTLP destinations. This is only needed for destinations that require an auth handler (like otlpExporter).

KeyTypeDefaultDescription
pipeline.metrics.gateway.destination.otel.auth.authTypestring"none"Type of authentication to use with the OpenTelemetry destination. Valid values are: `none`, `basic`, `bearer`, `headers`, `awsSigv4`, and `custom`.
pipeline.metrics.gateway.destination.otel.auth.basic.configstringAn `otelcol.auth.basic.oteldest` definition.Raw configuration for an otelcol.auth.basic block. This uses the GATEWAY_OTEL_DEST_USERNAME/GATEWAY_OTEL_DEST_PASSWORD env vars.
pipeline.metrics.gateway.destination.otel.auth.bearer.configstringAn `otelcol.auth.bearer.oteldest` definition.Raw configuration for an otelcol.auth.bearer block. This uses the GATEWAY_OTEL_DEST_BEARER_TOKEN env var.
pipeline.metrics.gateway.destination.otel.auth.headers.headerslist
[]
Headers attached to every request to the OpenTelemetry destination.

For backends that authenticate with an API-key header rather than a bearer token — Honeycomb’s x-honeycomb-team, for instance. Before this existed the only way to send one was authType: custom and a hand-written otelcol.auth.headers block, which no validator could check.

Each entry names a header with key and sets exactly one of:

  • value — a literal. It renders into the gateway’s pipeline ConfigMap in plaintext, so use it only for non-secret routing headers such as a dataset or tenant name.
  • valueEnv — the name of an environment variable, read with sys.env() when the gateway starts. Use it for anything secret and supply the variable through alloy-gateway.alloy.envFrom (a secretRef). The name is yours to choose; nothing else in the chart depends on it.

Unlike the Loki and Prometheus destinations, there is no value/valueEnv pair per header where the value is copied into the env ConfigMap. A header is either a literal or a lookup, and routing a literal through the env ConfigMap would only move it from one plaintext ConfigMap into another.

pipeline.metrics.gateway.destination.otel.auth.headers.configstringAn `otelcol.auth.headers.oteldest` definition built from `headers`.Raw configuration for an otelcol.auth.headers block.
pipeline.metrics.gateway.destination.otel.auth.awsSigv4.regionstring""Override the region to sign requests for.
pipeline.metrics.gateway.destination.otel.auth.awsSigv4.roleArnstring""Override the role ARN to assume for signing requests.
pipeline.metrics.gateway.destination.otel.auth.awsSigv4.configstringAn `otelcol.auth.sigv4.oteldest` definition.Raw configuration for an otelcol.auth.sigv4 block.
pipeline.metrics.gateway.destination.otel.auth.custom.handlerstring"{{ fail \"Be sure to set this\" }}"Handler for a custom auth handler (Advanced escape hatch). This should point to the definition you used. Most auth types use `.handler` for their capsule export. If you need multiple handlers, you can skip this field and instead modify your otlpExporter.config to not use "mzmon.alloyGateway.otelDest.authHandler".
pipeline.metrics.gateway.destination.otel.auth.custom.configstring
// THIS IS AN EXAMPLE
otelcol.auth.basic "oteldest" {
    client_auth {
        username = sys.env("GATEWAY_OTEL_DEST_USERNAME")
        password = sys.env("GATEWAY_OTEL_DEST_PASSWORD")
    }
}
Raw configuration for a custom auth handler.

Dashboards#

Where the bundled dashboards are filed.

The dashboards themselves are not in this chart. They ship in three charts of their own, one per folder below — materialize-monitoring-dashboards, materialize-monitoring-dashboards-infra, and materialize-monitoring-dashboards-meta — each installed as a release of its own, because Helm stores a release in a Kubernetes Secret and a Secret may not exceed 1 MiB, and the rendered set outgrew that and took helm upgrade with it.

What stays here is what a dashboard is filed into: the Grafana instance, its datasources, and the folders below. The dashboards charts reference the folder UIDs this one creates, spelled out in their own values rather than discovered, since they cannot see this release.

KeyTypeDefaultDescription
dashboards.config.grafana.enabledbooltrueCreate the Grafana folders the bundled dashboards are filed into. Requires the Grafana operator.
dashboards.config.grafana.modestring"operator"Grafana deployment mode, either "standalone" (the bundled Grafana chart) or "operator" (a separate Grafana Operator instance).
dashboards.config.grafana.manifesth5{"allowCrossNamespaceImport":null, "instanceSelector":{}, "resyncPeriod":"5m"}Settings shared by the Grafana resources this chart creates. Named `manifest` from when it also covered the dashboard manifests; those moved to the `materialize-monitoring-dashboards*` charts, which carry their own copies of these under `grafana`. What is left applies to the folders.
dashboards.config.grafana.manifest.resyncPeriodstring"5m"Time to sync the folder from its resource.
dashboards.config.grafana.manifest.instanceSelectorobject
{}
Non-default label selector for a Grafana-operator Grafana instance. Defaults to the labels on the `Grafana` instance this chart creates (see `connections.grafana.labels`), so the two cannot drift. Whatever this resolves to must also be set as `grafana.instanceSelector` in each dashboards chart, none of which can read it from here.
dashboards.config.grafana.manifest.allowCrossNamespaceImportstringinferredAllow folders to match a Grafana instance outside their own namespace. Left unset, this is inferred — it turns on only when the `Grafana` resource lands in a different namespace than the folders, as it does under the `split-namespace` profile. Set it explicitly when pointing `instanceSelector` at an instance this chart does not create. Note that the CRDs forbid turning this back off in place; the resource has to be recreated.
Grafana folders.#

Grafana folders the dashboards are filed into.

A map, not a list. Each key names one folder and becomes both the GrafanaFolder resource name and, unless existingUid overrides it, the folder UID: <fullname>-<key>, which is mzmon-<key> at the chart’s default fullnameOverride.

The keys are the contract with the dashboards. A dashboard carries its placement as a grafana.app/folder annotation naming a folder — the names are Folder in packages/mzmon-lib/src/grafana/folder.rs, which spells infra, materialize and meta-o11y — and the chart rewrites that name to the UID below on the way to the operator, the same way it rewrites apiVersion. So the UID is free to move with the release or with existingUid, and the dashboards follow it without being re-rendered.

What does not move freely is a key: rename one and the dashboards that name it have their annotation dropped and land at the root instead. Adopt a folder with existingUid rather than renaming, or re-render the dashboards against the new name.

Only rendered when mode is operator; the standalone Grafana chart has no folder resource to create.

FieldDefaultMeaning
createtrueRender a GrafanaFolder for this entry. false still resolves dashboards to its UID, for a folder created out of band.
title—Display name in Grafana. Unlike the key, this is free to change.
existingUid""Adopt the folder with this UID instead of deriving one from the key.
parent.folderRef—Nest under another key in this map. Refers to that entry’s resource name, so it needs create: true.
parent.folderUID—Nest under a folder UID this chart does not manage. Takes precedence over folderRef.

Scraper configuration#

Configuration for scrapers

KeyTypeDefaultDescription
config.scrapers.enabledbooltrueInstall ServiceMonitors / Alloy scrape configs for Materialize and adjacent components.

Grafana connection configuration#

How to talk to a grafana instance

KeyTypeDefaultDescription
connections.grafana.enabledbooltrueCreate the `Grafana` resource that grafana-operator reconciles this chart's folders, datasources, and dashboards into. Turn it off when no grafana-operator acts on this release, or when `dashboards.config.grafana.manifest.instanceSelector` points them at a `Grafana` resource you manage. The chart then creates none, whatever the `mode`, and refuses to render folders or datasources that still select it.
connections.grafana.modestring"bundled"How this establishes its connection to Grafana. `bundled` (default) targets the Grafana deployed by the bundled `grafana` subchart; the URL and admin-credential Secret are derived from it. `external` targets a Grafana you already run — Grafana Cloud, a shared platform Grafana, another cluster — and requires `external.url` plus either `external.apiKey` or `external.adminUser` + `external.adminPassword`. `operator` hands the instance lifecycle to grafana-operator itself, which builds it from the operator's own defaults and is not yet production-ready.
connections.grafana.labelsobject
{}
Additional labels applied to the Grafana instance, and to the `instanceSelector` of every Grafana resource this chart targets at it. Merged over a static `monitoring.materialize.cloud/grafana-instance: mzmon` label, which is what keeps the selector non-empty — grafana-operator reads an empty `matchLabels` as *every* instance, not none. Add to this to narrow the selector further, e.g. to scope per release when two `materialize-monitoring` releases share a cluster.
connections.grafana.allowPublicAccessboolfalseAcknowledge that Grafana is deliberately reachable from outside the cluster's private network. Left false, the chart refuses to render a `LoadBalancer` (or `NodePort`) Service with no `grafana.service.loadBalancerSourceRanges`, and an Ingress with no source-range allowlist it can see. That mirrors the load-balancer convention the Terraform modules already enforce: internal by default, and public only against an explicit allowlist.

Setting this true is the escape hatch for a deployment whose allowlist lives somewhere the chart cannot read — a security group, an egress firewall, a service mesh, an authenticating proxy in front. It suppresses the error, not the exposure: the render still warns, because a Grafana on the open internet is protected by nothing but its admin password until you configure an identity provider under grafana.ini.

connections.grafana.operatorobject
{}
Spec passed through to the `Grafana` resource in `mode: operator`. Break-glass. In that mode grafana-operator owns the server lifecycle and builds it from its own stock defaults — unpinned image, no persistence, no admin secret, no resource requests — none of which this chart models. This key is the raw `GrafanaSpec` escape hatch for configuring it anyway: whatever you put here is emitted verbatim as the resource's `spec`.

Nothing in it is validated or defaulted, and the production guidance on the grafana block above does not reach it. Prefer mode: bundled, where the grafana subchart owns the Deployment and all of that applies.

connections:
  grafana:
    mode: operator
    operator:
      spec:
        version: "13.0.2"
        deployment:
          spec:
            template:
              spec:
                containers:
                  - name: grafana
                    resources:
                      requests:
                        cpu: 100m
                        memory: 256Mi
        config:
          database:
            type: postgres
            host: grafana-db.example.internal:5432

Reference: https://grafana.github.io/grafana-operator/docs/api/#grafanaspec

connections.grafana.external.urlstring""External grafana uri
connections.grafana.external.adminPasswordobject
{}
Secret for Grafana admin password
connections.grafana.external.adminUserobject
{}
Secret for Grafana admin user
connections.grafana.external.apiKeyobject
{}
Secret for Grafana API key

Datasource configuration#

Datasources provisioned into the Grafana instance

Provisioned as GrafanaDatasource resources, pushed into the same instance the dashboards target. url values are rendered with tpl, so they may reference chart helpers.

KeyTypeDefaultDescription
connections.datasources.enabledbooltrueInstall the bundled datasources. Requires the Grafana operator.
connections.datasources.resyncPeriodstring"5m"How often the operator re-pushes each datasource.
connections.datasources.editableboolfalseWhether the datasources can be edited in the Grafana UI. Edits are reverted on the next resync either way; this only hides the controls so the reversion is not a surprise.
connections.datasources.thanos.enabledstringfollows `thanos.enabledProvision the Thanos datasource. Unset follows whether the bundled Thanos is enabled. Set it explicitly to point Grafana at metrics storage this chart does not deploy.
connections.datasources.thanos.namestring"Thanos"Datasource name, as shown in Grafana.
connections.datasources.thanos.uidstring"mzmon-thanos"Stable datasource UID.
connections.datasources.thanos.urlstring"http://thanos-query.{{ include \"mzmon.thanos.namespace\" $ }}.svc:9090"Thanos Query endpoint. Rendered with `tpl`.
connections.datasources.thanos.isDefaultbooltrueMake this Grafana's default datasource. The bundled dashboards deliberately do not pin a datasource: their `${metricsDatasource}` variable resolves to whichever Prometheus-type datasource is default. With no default, every panel renders empty and Grafana reports no error. Only turn this off if something else in the instance is already the default Prometheus datasource.
connections.datasources.thanos.tlsobject
{
  "caPem": "",
  "caSecret": {
    "key": "ca.crt",
    "name": ""
  },
  "clientCert": {
    "certKey": "tls.crt",
    "keyKey": "tls.key",
    "secretName": ""
  },
  "enabled": null,
  "serverName": ""
}
TLS for Grafana's connection to this backend. Grafana does not read certificate material from files. It stores it in its own database as `secureJsonData`, provisioned through the datasource — which is why this is the one hop in the stack that **does not renew on its own**. cert-manager rewriting the Secret changes nothing until the datasource is re-provisioned; grafana-operator does that every `connections.datasources.resyncPeriod`, so the material is refreshed on that cadence rather than on the certificate's.

caSecret names a Secret in the Grafana instance’s namespace holding the roots to trust. Under split-namespace that is grafana, not the release namespace, and the certificate machinery already issues <release>-grafana-tls there — its ca.crt key is the internal CA, so the default below works as-is once certificates.enabled is on.

Prefer this over jsonData.tlsSkipVerify. Skipping verification leaves the connection encrypted and unauthenticated, which on the read path means Grafana will happily talk to anything that answers on that address — and the failure mode of getting it wrong is a dashboard that renders, from the wrong source.

connections.datasources.thanos.tls.enabledstringfollows the URL schemeVerify the backend's certificate against `caSecret`. Unset follows whether the datasource URL is `https://`, so moving the URL is enough and this does not become a second switch to forget.
connections.datasources.thanos.tls.caPemstring""The CA to trust, inline as PEM. A CA certificate is public material — it is the thing you hand out — so putting it in values is not the leak that an inline key would be.

Takes precedence over caSecret, and needs no operator involvement at all, which makes it the right choice when the CA is already in hand at render time. It does not track a rotation: the PEM here is a copy, and re-issuing the CA means re-rendering. Prefer caSecret when the material lives in the cluster.

connections.datasources.thanos.tls.caSecretobject
{
  "key": "ca.crt",
  "name": ""
}
Secret holding the CA bundle, referenced rather than inlined. Rendered as a `valuesFrom` entry on the `GrafanaDatasource` plus a placeholder for the operator to substitute into. Survives a CA rotation, which `caPem` does not, so this is the better default whenever the CA already lives in the cluster — a cert-manager issuer's own Secret, say.

The Secret must be in the Grafana instance’s namespace, which is not necessarily this release’s under split-namespace.

key is load-bearing beyond naming the field: grafana-operator substitutes ${<key>} — the secretKeyRef key verbatim, dots and all — so the chart derives the placeholder from it. A mismatch there fails in the least visible way available: the CR applies, the operator logs nothing, secureJsonFields reports tlsCACert: true, and only at query time does Grafana say failed to parse TLS CA PEM certificate, because what it stored is the literal placeholder text.

connections.datasources.thanos.tls.clientCertobject
{
  "certKey": "tls.crt",
  "keyKey": "tls.key",
  "secretName": ""
}
Present a client certificate as well. Only useful against a backend that requires one; Loki cannot (its port is probed by the kubelet), so this is here for an external backend that does.
connections.datasources.thanos.tls.serverNamestring""SNI to send, when it differs from the URL host.
connections.datasources.thanos.jsonDataobject
{}
Extra `jsonData`, merged over the chart's defaults (`prometheusType: Thanos`, `httpMethod: POST`, and `timeInterval`).

timeInterval is the scrape interval Grafana assumes when it sizes $__rate_interval. The chart sets it to the slower of pipeline.metrics.kubelet.scrapeInterval and pipeline.metrics.kubeProxy.scrapeInterval (60s by default). rate() needs two samples in its window, and with Grafana’s own 15s assumption the window is one minute, which holds one sample of a 60s scrape. Set it here only when this datasource reads metrics scraped more slowly than that; the render warns about a shorter one.

connections.datasources.thanos.secureJsonDataobject
{}
Inline `secureJsonData`. Prefer `valuesFrom` for real secrets — this renders into the release manifest.
connections.datasources.thanos.valuesFromlist
[]
Secret- or ConfigMap-sourced field injection, passed through to the `GrafanaDatasource`. This is the supported way to supply credentials.
valuesFrom:
  - targetPath: secureJsonData.basicAuthPassword
    valueFrom:
      secretKeyRef:
        name: thanos-basic-auth
        key: password
connections.datasources.loki.enabledstringfollows `loki.enabledProvision the Loki datasource. Unset follows whether the bundled Loki is enabled.
connections.datasources.loki.namestring"Loki"Datasource name, as shown in Grafana.
connections.datasources.loki.uidstring"mzmon-loki"Stable datasource UID.
connections.datasources.loki.urlstring"http://loki-gateway.{{ include \"mzmon.loki.namespace\" $ }}.svc:8080"Loki read endpoint. Rendered with `tpl`. The Loki gateway, which routes queries to the query frontend and rule state to the ruler. Grafana's alerting UI lists the Loki rules from this same URL, so a datasource pointed at the query frontend shows none. With `loki.gateway.enabled` off, point this at `http://loki-query-frontend..svc:3100`.
connections.datasources.loki.tenantstringfollows `pipeline.logging.tenancy.staticTenantTenant to read as, sent in the `X-Scope-OrgID` header. The bundled Loki runs `auth_enabled: true`, so reads without this header fail with `no org id`. Unset follows the tenant the pipeline writes to. Set to `""` to send no header, which is only correct against a Loki with `auth_enabled: false`.
connections.datasources.loki.tlsobject
{
  "caPem": "",
  "caSecret": {
    "key": "ca.crt",
    "name": ""
  },
  "clientCert": {
    "certKey": "tls.crt",
    "keyKey": "tls.key",
    "secretName": ""
  },
  "enabled": null,
  "serverName": ""
}
TLS for Grafana's connection to this backend. Grafana does not read certificate material from files. It stores it in its own database as `secureJsonData`, provisioned through the datasource — which is why this is the one hop in the stack that **does not renew on its own**. cert-manager rewriting the Secret changes nothing until the datasource is re-provisioned; grafana-operator does that every `connections.datasources.resyncPeriod`, so the material is refreshed on that cadence rather than on the certificate's.

caSecret names a Secret in the Grafana instance’s namespace holding the roots to trust. Under split-namespace that is grafana, not the release namespace, and the certificate machinery already issues <release>-grafana-tls there — its ca.crt key is the internal CA, so the default below works as-is once certificates.enabled is on.

Prefer this over jsonData.tlsSkipVerify. Skipping verification leaves the connection encrypted and unauthenticated, which on the read path means Grafana will happily talk to anything that answers on that address — and the failure mode of getting it wrong is a dashboard that renders, from the wrong source.

connections.datasources.loki.tls.enabledstringfollows the URL schemeVerify the backend's certificate against `caSecret`. Unset follows whether the datasource URL is `https://`, so moving the URL is enough and this does not become a second switch to forget.
connections.datasources.loki.tls.caPemstring""The CA to trust, inline as PEM. A CA certificate is public material — it is the thing you hand out — so putting it in values is not the leak that an inline key would be.

Takes precedence over caSecret, and needs no operator involvement at all, which makes it the right choice when the CA is already in hand at render time. It does not track a rotation: the PEM here is a copy, and re-issuing the CA means re-rendering. Prefer caSecret when the material lives in the cluster.

connections.datasources.loki.tls.caSecretobject
{
  "key": "ca.crt",
  "name": ""
}
Secret holding the CA bundle, referenced rather than inlined. Rendered as a `valuesFrom` entry on the `GrafanaDatasource` plus a placeholder for the operator to substitute into. Survives a CA rotation, which `caPem` does not, so this is the better default whenever the CA already lives in the cluster — a cert-manager issuer's own Secret, say.

The Secret must be in the Grafana instance’s namespace, which is not necessarily this release’s under split-namespace.

key is load-bearing beyond naming the field: grafana-operator substitutes ${<key>} — the secretKeyRef key verbatim, dots and all — so the chart derives the placeholder from it. A mismatch there fails in the least visible way available: the CR applies, the operator logs nothing, secureJsonFields reports tlsCACert: true, and only at query time does Grafana say failed to parse TLS CA PEM certificate, because what it stored is the literal placeholder text.

connections.datasources.loki.tls.clientCertobject
{
  "certKey": "tls.crt",
  "keyKey": "tls.key",
  "secretName": ""
}
Present a client certificate as well. Only useful against a backend that requires one; Loki cannot (its port is probed by the kubelet), so this is here for an external backend that does.
connections.datasources.loki.tls.serverNamestring""SNI to send, when it differs from the URL host.
connections.datasources.loki.jsonDataobject
{}
Extra `jsonData`, merged over the chart's defaults (the tenant header name, and `timeout`).
connections.datasources.loki.secureJsonDataobject
{}
Inline `secureJsonData`. Prefer `valuesFrom` for real secrets — this renders into the release manifest.
connections.datasources.loki.valuesFromlist
[]
Secret- or ConfigMap-sourced field injection, passed through to the `GrafanaDatasource`. This is the supported way to supply credentials.
connections.datasources.alertmanager.enabledstringfollows `alertmanager.enabledProvision an Alertmanager datasource, which gives Grafana the alert list and the silence editor for the bundled Alertmanager. Unset follows whether the bundled Alertmanager is enabled. Grafana reads and writes Alertmanager's state here and keeps none of its own, so this is not Grafana-managed alerting. Silences created through it gossip to every replica like any other.
connections.datasources.alertmanager.namestring"Alertmanager"Datasource name, as shown in Grafana.
connections.datasources.alertmanager.uidstring"mzmon-alertmanager"Stable datasource UID.
connections.datasources.alertmanager.urlstring"{{ include \"mzmon.alertmanager.url\" $ }}"Alertmanager endpoint. Rendered with `tpl`. The load-balanced Service is right for a reader: silences and the notification log are gossiped, and both rulers send every alert to every replica, so any replica answers with the same state. The helper's scheme follows `alerting.server.tls.enabled`.
connections.datasources.alertmanager.tlsobject
{
  "caPem": "",
  "caSecret": {
    "key": "ca.crt",
    "name": ""
  },
  "clientCert": {
    "certKey": "tls.crt",
    "keyKey": "tls.key",
    "secretName": ""
  },
  "enabled": null,
  "serverName": ""
}
TLS for Grafana's connection to Alertmanager. Same shape and caveats as `connections.datasources.thanos.tls`, including that Grafana stores the material rather than reading a file, so it refreshes on `resyncPeriod` rather than on renewal. `profiles/mtls.values.yaml` sets `caSecret` to Grafana's own certificate Secret, and `mtls-phase2` presents it as the client certificate.
connections.datasources.alertmanager.tls.enabledstringfollows the URL schemeVerify Alertmanager's certificate. Unset follows the URL scheme.
connections.datasources.alertmanager.tls.caPemstring""The CA to trust, inline as PEM.
connections.datasources.alertmanager.tls.caSecretobject
{
  "key": "ca.crt",
  "name": ""
}
Secret holding the CA bundle, in the Grafana instance's namespace.
connections.datasources.alertmanager.tls.clientCertobject
{
  "certKey": "tls.crt",
  "keyKey": "tls.key",
  "secretName": ""
}
Present a client certificate.
connections.datasources.alertmanager.tls.serverNamestring""SNI to send, when it differs from the URL host.
connections.datasources.alertmanager.jsonDataobject
{}
Extra `jsonData`, merged over the chart's defaults (`implementation: prometheus`, `handleGrafanaManagedAlerts: false`).
connections.datasources.alertmanager.valuesFromlist
[]
Secret- or ConfigMap-sourced field injection, passed through to the `GrafanaDatasource`.

Alerting rules#

Which alerting and recording rules install: the bundled rule set, gated by what this deployment contains.

The bundled rules are rendered at build time from the query registry (packages/queries/) and installed as PrometheusRule resources, each labelled with its query language as mzmon.materialize.cloud/flavor. The Thanos ruler imports the PromQL ones and evaluates them. The alloy-gateway’s loki.rules.kubernetes writes the LogQL ones into the Loki ruler, so they install only where this release runs both. Where they go once they fire is alerting, below.

An alert installs when every capability it requires is present, and it is either in the default set or named in selected, and it is not named in disabled. Capabilities name what a deployment contains, never who operates it: a CockroachDB rule is for a deployment running CockroachDB. Most are derived from what this chart deploys (materialize, kube-state-metrics, loki, a provider pull, …); the rest are listed in capabilities. The generated pre-rendered/rules/_index.yaml lists every rule with its engine and the capabilities it requires.

A recording rule installs wherever its capabilities are present, with no selection: the normalized ext:* series are what other rules and panels read, and recording one costs a few series. selected, disabled and overrides name alerts only.

KeyTypeDefaultDescription
rules.enabledbooltrueInstall the bundled alerting and recording rules.
rules.capabilitieslist
[]
Capabilities this deployment has beyond those the chart derives.
CapabilityMeans
synthetic-uptimeA per-environment probe exporter writes connection and SELECT 1 results
external-uptimeAn external uptime checker probes environments from outside the network
feature-flagsA feature-flag service synchronizes system parameters into environmentd
frontegg-authenvironmentd authenticates through Frontegg
memory-limiterclusterd’s memory limiter is active
crdb-dedicatedThe metadata database is a dedicated CockroachDB cluster exporting crdb_dedicated_*
ciliumThe CNI is Cilium and its metrics are scraped
corednsCoreDNS is scraped
cert-managercert-manager is scraped
kubelet-metricsThe kubelet’s own /metrics is scraped (this chart scrapes only /metrics/cadvisor)
swap-nodesMaterialize runs on swap-enabled nodes whose materialize.cloud/swap label reaches cAdvisor series
egress-gatewayA dedicated egress-gateway node pool, identified by a workload label

The derived ones are materialize, materialize-sql, materialize-operator, kube-state-metrics, cadvisor, node-exporter, loki and alloy, each present when this chart runs the component and collects its metrics, and cloudwatch, cloud-monitoring and azure-monitor, present when the gateway runs that provider pull (pipeline.metrics.provider.*). List one here as well when something outside the chart provides it — your own kube-state-metrics, say. An unknown name fails the render.

rules.selectedlist
[]
Bundled rules to install beyond the default set: alert names, rule-group names, or `*` for every rule that applies.

Selecting a rule does not bypass its capabilities: a selected rule whose capabilities are missing is still not installed, and the render warns.

rules.disabledlist
[]
Alert names never to install, even when they apply and are in the default set.
rules.overridesobject
{}
Per-alert changes to a bundled rule: how long its condition must hold (`for`), and labels to add or replace.

An override never changes a rule’s expression. The usual reason for one is a workload whose normal behaviour a default does not fit: a large cluster can take hours to hydrate when nothing is wrong.

rules:
  overrides:
    cluster-hydration-stuck:
      for: 6h
    cluster-cpu-high:
      labels:
        severity: notice
        team: analytics

severity must stay one of critical, warning and notice, and audience one of platform, workload and development, so the routes still match. An unknown alert name fails the render, and an override for a rule that is not installed renders with a warning.

rules.logTenantslist
[]
Loki tenants the log-derived rules are written into. Empty means `pipeline.logging.tenancy.staticTenant`.

A Loki rule reads one tenant’s logs, and the ruler does not evaluate across tenants, so the gateway writes every LogQL PrometheusRule into each tenant listed here. Under the default static tenancy every log line is in staticTenant, and the default is right.

Under byEnvironment each Materialize environment’s logs are in a tenant named for its environment id, which the chart cannot know at render time. List those tenants here, and restate the list when an environment is added. Under byNamespace the tenant set grows with every namespace, and no list stays complete.

rules.namespaces.environmentlist
[]
Namespaces holding Materialize environments.

Rules about an environment’s pods (pending, restarting, OOMKilled) scope to these. Empty uses materialize.namespaces, and if that is empty too, materialize-system.namespace.

rules.namespaces.operatorlist
[]
Namespaces holding the Materialize operator. Empty uses `materialize-operator.namespace`.
rules.namespaces.excludelist
[]
Namespaces excluded from alerting, such as a scratch environment nobody should be paged for.
rules.infraWorkloadsobjectcommon EKS and GKE add-ons and this chart's collectors, per tier belowWhich infrastructure workloads the rules treat as core, important, non-essential, or on every node.

The infrastructure rules (memory, OOMKills, restarts, file descriptors, Deployment availability, daemonset budgets) grade a workload by the tier it is in. Each entry is an RE2 fragment matched against the whole name: container names, and for the k8s-deployment-unavailable-* rules, Deployment names. That is why a tier lists both where they differ, such as ebs-plugin and ebs-csi-controller. It may not contain a quote, a backslash or whitespace.

The defaults are the Kubernetes add-ons common on EKS and GKE, plus this chart’s own collectors. Replace a tier to describe your cluster; an empty tier matches nothing. Container names shared across unrelated workloads (Karpenter’s is controller) are left out, since the match is on the name alone.

rules.infraWorkloads.corelist
[
  "aws-load-balancer-controller",
  "aws-node",
  "cilium-agent",
  "cilium-operator",
  "coredns",
  "csi-attacher",
  "csi-node-driver-registrar",
  "csi-provisioner",
  "csi-resizer",
  "csi-snapshotter",
  "external-dns",
  "karpenter",
  "kube-dns",
  "kube-proxy",
  "kubedns",
  "node-cache",
  "node-driver-registrar",
  "openebs-lvm.*",
  "snapshot-controller"
]
Workloads the cluster cannot run without: DNS, networking, storage provisioning, autoscaling.
rules.infraWorkloads.importantlist
[
  ".*kube-state-metrics",
  ".*metrics-server.*",
  "alloy.*",
  "cert-manager.*",
  "ebs-csi-controller",
  "ebs-plugin",
  "gce-pd-driver",
  "hubble-relay",
  "liveness-probe",
  "node-exporter",
  "prometheus-adapter"
]
Workloads whose loss degrades the cluster without stopping it.
rules.infraWorkloads.nonessentiallist
[
  "event-exporter",
  "grafana.*",
  "hubble-ui",
  "loki.*",
  "lvm-exporter",
  "memcached"
]
Workloads worth a notice rather than a page. `pod-restart-rate-high` covers every container not listed here.
rules.infraWorkloads.daemonsetlist
[
  "alloy",
  "aws-eks-nodeagent",
  "aws-node",
  "cilium-agent",
  "csi-driver-registrar",
  "csi-node-driver-registrar",
  "ebs-plugin",
  "gce-pd-driver",
  "kube-proxy",
  "liveness-probe",
  "lvm-exporter",
  "node-cache",
  "node-driver-registrar",
  "node-exporter",
  "openebs-lvm-plugin"
]
Containers every node runs, whose requests the `k8s-daemonset-*` rules total against a per-node budget.

External dependencies#

The services Materialize depends on and does not run: which of the resources the stack watches are which.

A provider pull (pipeline.metrics.provider.*) or the postgres-exporter watches every database it is told to, and neither can say which of them is Materialize’s metadata database: the Terraform modules add Grafana’s database to the same pull. This block says which is which. The normalized ext:consensus_* recording rules read only the databases named here, so an undeclared database, Grafana’s among them, is never recorded as a metadata database.

Nothing here is needed for Materialize’s own view of its metadata database, which the persist adapter records wherever Materialize is.

KeyTypeDefaultDescription
externalDependencies.consensuslist
[]
Metadata (consensus) databases, by flavor and provider resource name.
flavorresourceIdWatched by
rdsThe DB instance identifierpipeline.metrics.provider.cloudwatch.rds.instances
cloudsqlThe instance name, without the projectpipeline.metrics.provider.gcp.cloudSql.instances
azure-postgresThe flexible server namepipeline.metrics.provider.azure.postgres.servers
postgresThe target’s namepostgres-exporter.targets
externalDependencies:
  consensus:
    - flavor: rds
      resourceId: mzmon-prod-db

The resource has to be watched by its provider pull or the exporter as well, or there is nothing to record, and the render warns. Each recorded series carries the resource as its resource label. Matching ignores case.

Alert routing#

Where alerts go: receivers, severity presets, and extra routes.

Rendered into the bundled Alertmanager’s configuration by templates/alertmanager-config.yaml. A rule says how bad a condition is with its severity label; this block says what each severity means for this deployment and who hears about it. Alert Channels walks through it.

alerting:
  preset: important
  receivers:
    oncall:
      class: page
      config:
        pagerduty_configs:
          - routing_key_file: /etc/alertmanager/secrets/alertmanager-receivers/pagerduty-key
    platform:
      class: [high, normal, low]
      config:
        slack_configs:
          - channel: "#platform-alerts"
            api_url_file: /etc/alertmanager/secrets/alertmanager-receivers/slack-url
            send_resolved: true

Nothing here applies when the bundled Alertmanager is disabled. A deployment notifying its own Alertmanager configures that one’s routing itself.

KeyTypeDefaultDescription
alerting.presetstring"important"Which entry of `presets` routes alerts: `critical-infrastructure`, `important`, `evaluation`, or one of your own.

The three shipped presets say how much this deployment depends on Materialize. important is the default because it is the assumption that is wrong in the least damaging direction: a critical-infrastructure deployment left on it gets a notification where it wanted a page, while an evaluation deployment that pages on critical gets an operator who turns alerting off.

alerting.presetsobject
{
  "critical-infrastructure": {
    "critical": "page",
    "notice": "normal",
    "warning": "high"
  },
  "evaluation": {
    "critical": "normal",
    "notice": "suppressed",
    "warning": "normal"
  },
  "important": {
    "critical": "high",
    "notice": "low",
    "warning": "normal"
  }
}
Severity-to-class mappings, one per preset.

A class names a kind of delivery — page, high, normal, low — rather than a receiver. Each receiver declares the classes it serves, so one preset works for a deployment with a pager and one with a single chat channel. suppressed is reserved: the alert still fires and still shows in Alertmanager and Grafana, and no notification is sent.

severitycritical-infrastructureimportantevaluation
criticalpagehighnormal
warninghighnormalnormal
noticenormallowsuppressed

This is a map, so one cell can be changed without restating the rest, a preset can carry a severity of its own, and a preset of your own is one more key. Class names are free-form. The render fails when the selected preset names a class no receiver serves, since an unroutable severity is otherwise discovered during the incident it should have reported.

alerting.unknownSeveritystring"warning"Severity an alert is routed as when its `severity` label is missing or is not in the preset. An alert that matches no severity route still reaches somebody, rather than the null receiver.
alerting.receiversobject
{}
Notification receivers, keyed by name. Empty means no alert reaches anybody.
KeyMeaning
classThe class, or list of classes, this receiver serves. Optional: a receiver with none is reachable only from routes.extra.
configAn Alertmanager receiver, verbatim, without name: slack_configs, pagerduty_configs, webhook_configs, email_configs, msteamsv2_configs, and every other integration Alertmanager supports.
routeRoute options — group_wait, group_interval, repeat_interval, group_by, mute_time_intervals, active_time_intervals — applied wherever the preset routes to this receiver.

The chart does not model receiver types, so every integration Alertmanager documents works as written in its configuration reference. Credentials are referenced through a *_file field against a mounted Secret; see alertmanager.extraSecretMounts.

Several receivers can serve one class, and every one of them is notified.

alerting.routes.rootobject
{
  "group_by": [
    "alertname",
    "cluster",
    "namespace"
  ],
  "group_interval": "5m",
  "group_wait": "30s",
  "repeat_interval": "4h"
}
The top-level route's grouping and timing. Every route below inherits them.

group_by: [alertname, cluster, namespace] sends one notification per condition per Materialize environment. cluster is constant within one Alertmanager, and is there for the incident tools downstream: PagerDuty’s dedup_key and Opsgenie’s alias are hashes of the group key, so without it two clusters sending the same condition open one incident, and either cluster’s resolution closes it. receiver, routes and matchers are the chart’s and are rejected here.

alerting.routes.extralist
[]
Routes placed ahead of the preset's severity routes, in Alertmanager's route format.

An alert is tested against these first, so a specific match wins and the preset remains the fallback. A route that should also reach the preset sets continue: true. Each receiver has to name one under receivers.

routes:
  extra:
    - matchers: ['component="storage"', 'severity="critical"']
      receiver: data-team
alerting.inhibitRuleslist
[]
Alertmanager `inhibit_rules`, verbatim.
alerting.timeIntervalslist
[]
Alertmanager `time_intervals`, verbatim. Routes reference them by name from `mute_time_intervals` and `active_time_intervals`.
alerting.defaultTemplatesbooltrueMake the chart's notification templates every receiver's default.

The chart ships its templates as mzmon.gotmpl whatever this says, as mzmon.* definitions a receiver can reference. On, they also replace Alertmanager’s built-in defaults, so a receiver that sets no title, text or link of its own gets them:

Built-inBecomes
__subject, the title or subject of nearly every integration[FIRING:2] env-uptime-sla — prod/materialize (critical): alert, cluster/namespace, severity
__alertmanagerURL, every integration’s linkThe alert group in Grafana, or nothing when Grafana’s URL is unknown; Alertmanager’s own UI when alertmanager.baseURL is set instead
slack.default.text, empty upstreamThe summary, the description, one line per alert, and links to the runbook, Grafana and a pre-filled silence
slack.default.colorBy severity rather than red for everything

A template under templates that defines one of these names again still wins, because the chart’s file is loaded first. Off restores Alertmanager’s own defaults, whose links name the pod’s address.

alerting.grafanaURLstring""Grafana's browser-facing URL, for the links in notifications. Empty derives it.

Notifications link to the alert group and to a pre-filled silence in Grafana’s alerting pages, which read the bundled Alertmanager through the connections.datasources.alertmanager datasource. Empty takes the URL Grafana builds its own links from:

connections.grafana.modeDerived from
bundledgrafana.grafana.ini.server.root_url
operatorconnections.grafana.operator.spec.config.server.root_url
externalconnections.grafana.external.url

Set this when that address is not the one people reach Grafana at: an external Grafana addressed in-cluster, or a root_url written with Grafana’s %(domain)s placeholders, which the chart cannot resolve. With no URL, notifications carry no Grafana links, and the render warns once a receiver exists.

alerting.templatesobject
{}
Notification templates, keyed by file name, loaded by every receiver. Names end in `.tmpl`; anything else is not loaded and fails the render. They load after the chart's `mzmon.gotmpl`, so a name defined in both takes the definition here. See `defaultTemplates`.
alerting.globalobject
{}
Alertmanager's `global` block, verbatim: `resolve_timeout`, SMTP defaults, a shared `http_config`.
alerting.server.tlsobject
{
  "certFile": "/etc/mzmon/tls/tls.crt",
  "clientAuth": "NoClientCert",
  "clientCAFile": "",
  "enabled": false,
  "keyFile": "/etc/mzmon/tls/tls.key",
  "minVersion": "TLS13"
}
TLS on Alertmanager's API port (9093), rendered as its `--web.config.file`.

Off by default. profiles/mtls.values.yaml turns it on and assembles the settings that have to move with it, and mtls-phase2 verifies client certificates. Set these directly only alongside the same changes; the render refuses each half-applied combination.

KeyMeaning
enabledServe TLS. Takes effect when alertmanager.extraArgs.web.config.file is also set, which is what restarts Alertmanager onto it: TLS on or off is decided at startup.
certFile / keyFileThe serving certificate. The defaults are the chart’s mzmon-alertmanager-tls Secret, which alertmanager.extraSecretMounts mounts at /etc/mzmon/tls whether or not it exists yet. amtool in the pod trusts the ca.crt beside certFile, which a cert-manager Secret carries.
clientCAFileRoots client certificates are verified against. Ignored with NoClientCert, which Alertmanager refuses to combine with a client CA.
clientAuthNoClientCert, or VerifyClientCertIfGiven. See below for RequireAndVerifyClientCert.
minVersionLowest TLS version accepted, in Alertmanager’s spelling (TLS12, TLS13).

The certificate, the client-auth policy and the CA are re-read on every connection, so a renewal or a move between NoClientCert and VerifyClientCertIfGiven applies without a restart.

RequireAndVerifyClientCert is refused while Alertmanager’s own probes or config reloader would be locked out. Both reach port 9093 from inside the pod and neither can present a client certificate: a Kubernetes httpGet probe has no field for one, and the config reloader’s client has no option for one. So VerifyClientCertIfGiven is the terminal state here, as it is for Loki’s HTTP port: certificates that are presented are verified, and a client that presents none is still served.

alerting.assertNoInlineCredentialsbooltrueFail the render when a receiver or `global` carries an inline credential.

Leave this on. Values files are committed, diffed, pasted into support threads and rendered into Terraform plans, so a token written into one is a token published. Nearly every Alertmanager credential field has a _file variant — api_url_file, routing_key_file, credentials_file, password_file — that reads it from a mounted Secret instead.

Uninstall cleanup#

Deleting resources this chart cannot delete on its own.

grafana-operator puts operator.grafana.com/finalizer on the custom resources it reconciles, and clears it only after removing the corresponding object from the Grafana instance. Helm’s uninstall does not order that against the operator’s own removal, so the ordinary teardown races: the operator Deployment goes away with everything else, nobody is left to process the finalizers, and the GrafanaManifest / GrafanaDatasource objects sit in Terminating forever. The namespace then will not delete either, and the next install adopts the leftovers.

A pre-delete hook is the fix because of when it runs: before Helm removes anything, so the operator is still up and still watching. kubectl delete blocks until the objects are actually gone, which is the point — it returns only once the finalizers have been processed, and Helm proceeds from there.

KeyTypeDefaultDescription
cleanup.grafanaOperator.enabledbooltrueRun the pre-delete cleanup hook. Turning this off restores the hang described above; the manual recovery is to delete the resources yourself before `helm uninstall`, or to clear the finalizers by hand afterwards.
cleanup.grafanaOperator.scopestring"instance"Which resources the hook deletes: `instance` or `release`.

instance (the default) selects everything pointed at the Grafana this release created, by the labels in connections.grafana.labels — the same ones the resources carry and their instanceSelector matches.

That deliberately reaches beyond this release. The dashboards ship in the materialize-monitoring-dashboards* charts, releases of their own whose GrafanaManifests carry grafana-operator’s finalizer, and nothing in those releases’ teardown runs when this one is being removed. Take the operator away without clearing them and they wedge in Terminating with no remover. It is still bounded: it cannot reach resources aimed at a Grafana this release did not create.

release is the narrow form — only what this release created. Correct when grafana-operator is not ours to remove, since it survives the uninstall and clears the rest itself, and the escape hatch if the broader sweep ever reaches something it should not.

Only instance consults mzmon.grafanaOperator.enabled; with the operator unmanaged here, both scopes fall back to the release.

cleanup.grafanaOperator.kindslist
[
  "grafanamanifests.grafana.integreatly.org",
  "grafanadatasources.grafana.integreatly.org",
  "grafanafolders.grafana.integreatly.org"
]
Resource types to delete, as `.`. Fully qualified on purpose: a bare `grafanamanifests` resolves through discovery and can collide with another CRD of the same short name. Only kinds that actually carry the operator's finalizer belong here — the `Grafana` instance CR does not, so Helm removes it unaided. Extend this if you add your own operator resources (`GrafanaAlertRuleGroup`, `GrafanaContactPoint`, and so on) with the chart's instance label.
cleanup.grafanaOperator.timeoutstring"2m"How long `kubectl delete` waits for the finalizers to clear. This bounds the hook rather than the uninstall: on expiry the objects are already marked for deletion, the hook fails, and Helm stops before removing the operator — which leaves the cluster recoverable instead of wedged.
cleanup.grafanaOperator.backoffLimitint1Number of times to retry the job before failing the uninstall.
cleanup.grafanaOperator.activeDeadlineSecondsint420Hard ceiling on the Job, including retries. Sized above `timeout` × (`backoffLimit` + 1) so the kubectl timeout is what normally fires; this only catches a pod that never gets that far.
cleanup.grafanaOperator.annotationsobject
{
  "helm.sh/hook": "pre-delete",
  "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded",
  "helm.sh/hook-weight": "0"
}
Job specific annotations. The default makes this a pre-delete hook; setting any annotation replaces that, which is how you would move it to `post-delete` or set an argocd sync wave.
cleanup.grafanaOperator.imageobject
{
  "pullPolicy": "IfNotPresent",
  "pullSecrets": [],
  "registry": "registry.k8s.io",
  "repository": "kubectl",
  "tag": "v1.36.5"
}
Image for the cleanup job. Upstream's own kubectl build: distroless, multi-arch, and published beside Kubernetes itself, so it tracks patch releases without a third-party rebuild. Its entrypoint is `/bin/kubectl` and there is no shell in the image — the hook runs one argv and needs nothing else. Keep `tag` within one minor of your API server, per Kubernetes' version-skew policy.
cleanup.grafanaOperator.resourcesobject
{
  "limits": {
    "cpu": "100m",
    "memory": "64Mi"
  },
  "requests": {
    "cpu": "50m",
    "memory": "32Mi"
  }
}
Resources for the cleanup job. It issues one API call and waits, so this is deliberately small.
cleanup.grafanaOperator.podSecurityContextobject
{
  "runAsGroup": 65532,
  "runAsNonRoot": true,
  "runAsUser": 65532,
  "seccompProfile": {
    "type": "RuntimeDefault"
  }
}
Security context for the cleanup job pod.
cleanup.grafanaOperator.containerSecurityContextobject
{
  "allowPrivilegeEscalation": false,
  "capabilities": {
    "drop": [
      "ALL"
    ]
  },
  "readOnlyRootFilesystem": true,
  "runAsGroup": 65532,
  "runAsNonRoot": true,
  "runAsUser": 65532
}
Security context for the cleanup job container. The image ships `USER 0`; kubectl needs no privileges to call an API server, so it is dropped to nonroot here rather than trusted to the image.
cleanup.grafanaOperator.nodeSelectorobject
{}
Node selector for the cleanup job.
cleanup.grafanaOperator.tolerationslist
[]
Tolerations for the cleanup job. An uninstall has to work even when the ordinary pools are tainted or full, so this is worth widening if scheduling it is ever the thing that fails.

Bundled subchart configurations#

Configuration for bundled subcharts

Alloy Agent#

Alloy collector instance running close to scrape targets. Pre-egress shaping happens here.

Upstream reference:

KeyTypeDefaultDescription
alloy-agent.fullnameOverridestring"alloy-agent"Standard Helm full-name override. We use a static name for deterministic relations.
alloy-agent.namespaceOverridestringnilNamespace override.
alloy-agent.crdsobject
{
  "create": false
}
Control for the PodLogs crd.
alloy-agent.global.podSecurityContextobject
{
  "fsGroup": 473,
  "runAsGroup": 473,
  "runAsUser": 473
}
Security context for the alloy agent pods.
alloy-agent.alloy.stabilityLevelstring"generally-available"Stability level of alloy components.
alloy-agent.alloy.extraEnvlist
[
  {
    "name": "GOMEMLIMIT",
    "value": "240MiB"
  }
]
Extra environment variables to pass to the alloy agent pod.

GOMEMLIMIT at ~80% of the memory limit is the part worth keeping regardless of the number. Go’s GC has no knowledge of a cgroup limit, so it grows the heap toward a ceiling the kernel enforces by killing the process. Telling the runtime about the ceiling turns an OOM-kill into GC pressure — the failure mode becomes “slower”, not “dead”. Keep it in step with the memory limit above.

alloy-agent.alloy.envFromlist
[
  {
    "configMapRef": {
      "name": "mzmon-alloy-agent-env"
    }
  },
  {
    "secretRef": {
      "name": "mzmon-alloy-agent-env",
      "optional": true
    }
  }
]
Environment variable configmaps/secrets to pass to the alloy agent pod.
alloy-agent.alloy.mountsobject
{
  "extra": [
    {
      "mountPath": "/tmp",
      "name": "tmp"
    },
    {
      "mountPath": "/run/log/journal",
      "name": "runlogjournal",
      "readOnly": true
    },
    {
      "mountPath": "/etc/machine-id",
      "name": "machineid",
      "readOnly": true
    },
    {
      "mountPath": "/etc/mzmon/tls",
      "name": "mzmon-tls",
      "readOnly": true
    }
  ],
  "varlog": true
}
Volume mounts to expose to alloy agent. Three mounts feed `loki.source.journal`, and journal collection needs all three. Every way of getting it wrong fails the same silent way: the component starts, reports healthy, and reads nothing.

Where the journal lives is host-specific, so both roots have to be covered. Observed:

HostJournalCovered by
Bottlerocket (EKS)/var/log/journal/<machine-id>/, persistentvarlog
kindest/node/run/log/journal/<machine-id>/, volatilerunlogjournal

Bottlerocket also has an empty /run/log/journal, and kind has no /var/log/journal at all — neither is a problem, but neither alone is enough either.

Deliberately not mounting /var/log/journal directly: journald’s default Storage=auto switches to persistent storage as soon as that directory exists, so a hostPath mount that creates it would change how the host journals. varlog already exposes it wherever it legitimately exists.

/etc/machine-id is required, and its absence is silent. systemd stores the journal under <path>/<machine-id>/, and the container has no machine-id of its own — the Alloy image ships none. Without the host’s, loki.source.journal starts, reports healthy, and reads zero lines.

Measured on a Bottlerocket node, controlling for the restart that a mount change forces: with the mount, loki_source_journal_target_lines_total went 0 -> 1244; removed again, back to 0 on a freshly started pod.

A kind node happens to work without it, which makes this a bad thing to test on kind alone — the two differ in which directory holds the journal (/run/log/journal there, /var/log/journal on Bottlerocket), and only the latter needs the ID to resolve. Do not remove this because kind stays green.

alloy-agent.alloy.mounts.extra[3]object
{
  "mountPath": "/etc/mzmon/tls",
  "name": "mzmon-tls",
  "readOnly": true
}
Certificate material from `certificates`, when it is enabled. `optional: true` is what lets this be unconditional. The Secret does not exist until `certificates.enabled` is on and cert-manager has signed, and an optional secret volume that is missing mounts empty rather than blocking the pod — so the same values work before, during and after issuance.

That holds only while nothing reads the material, which is why the mTLS profiles flip it to optional: false. Once a config names a path inside this mount, an empty mount is not a pod serving plaintext: Alloy fails its initial load and enters CrashLoopBackOff, backing off to five minutes, on an error naming a file rather than the Secret behind it. Required, the same race is a container the kubelet holds in ContainerCreating with a FailedMount event, started on the next volume re-sync. Do not set this to false here — with certificates off there is no Secret to wait for and the pod would never start.

The kubelet refreshes the contents in place on renewal, which is why the tls.*File carriers are preferred over the inline PEMs.

alloy-agent.alloy.securityContextobject
{
  "allowPrivilegeEscalation": false,
  "capabilities": {
    "drop": [
      "ALL"
    ]
  },
  "readOnlyRootFilesystem": true,
  "runAsGroup": 473,
  "runAsNonRoot": false,
  "runAsUser": 0,
  "seccompProfile": {
    "type": "RuntimeDefault"
  }
}
Security context for the alloy agent containers. The agent MUST run as root in order to be able to read container logs. No capabilities are added and none are needed: everything it reads is reachable by uid 0 under ordinary DAC.
alloy-agent.alloy.resourcesobject
{
  "limits": {
    "cpu": "250m",
    "memory": "300Mi"
  },
  "requests": {
    "cpu": "100m",
    "memory": "300Mi"
  }
}
Resources for the alloy agent containers.

Sized for the logs-only agent. This needed ~750Mi while an in-process cAdvisor lived here; that moved to the gateway, so the envelope is back to what the log path actually costs, with headroom over the ~200Mi it ran at before.

The CPU limit stays above the request: the agent is bursty, and a limit equal to the request turns that into CFS throttling on the busiest nodes.

alloy-agent.controller.extraAnnotationsobject
{}
Extra annotations to apply to the alloy agent pod. If you are using pulumi, be sure to add `config.kubernetes.io/depends-on: job/mzmon-validate-agent`
alloy-agent.controller.tolerationslist
[
  {
    "effect": "NoSchedule",
    "operator": "Exists"
  }
]
Taints the agent tolerates out of the box: every `NoSchedule` one. Coverage is correctness for this workload, not a placement preference. A node the DaemonSet never lands on produces no logs and no error, and nothing shows a hole where a node should be — so the default is the same blanket rule `node-exporter` already ships, for the same reason. Enumerating keys instead would make coverage depend on this chart knowing your taints, which it does not: a node pool tainted with anything the list does not name goes silently uncollected. That is the failure this replaced. It is not always silent, either. A bootstrap gate — a `NoSchedule` taint applied to every node at boot and lifted once the DaemonSets are running, which is what `node.materialize.com/daemonsets-not-scheduled` is — deadlocks outright against an agent that does not tolerate it: the taint waits on a pod that is waiting on the taint, and the node never admits any workload. `NoExecute` is deliberately not tolerated here — a node being drained for a problem should shed the agent too. The two exceptions are not the chart's to make: the DaemonSet controller adds `node.kubernetes.io/not-ready` and `node.kubernetes.io/unreachable` (both `NoExecute`) to every DaemonSet pod unconditionally, so the agent keeps running on a node that goes unreachable whatever is written here. Add other `NoExecute` taints through the Terraform module's `tolerations`, which appends to this list. To narrow this — a node pool whose per-node budget genuinely cannot absorb the agent — replace the list rather than adding to it, and record which pools you gave up. Helm overwrites lists, so setting this key at all replaces the whole default.
alloy-agent.controller.priorityClassNamestring"monitoring-critical"Scheduling priority. See the Priority classes section. Critical: this is a per-node singleton, so an eviction is a log gap on that node with no replica to cover it.
alloy-agent.networkPolicyobject
{
  "egress": null,
  "enabled": true,
  "flavor": "kubernetes",
  "ingress": [
    {
      "from": [
        {
          "podSelector": {
            "matchLabels": {
              "app.kubernetes.io/name": "alloy-gateway"
            }
          }
        }
      ],
      "ports": [
        {
          "port": 12345,
          "protocol": "TCP"
        }
      ]
    }
  ],
  "policyTypes": [
    "Ingress"
  ]
}
NetworkPolicy for the agent.

Ingress is one port from one peer. 12345 is Alloy’s HTTP server — it serves /metrics and the live debugging UI, which shows the running pipeline and samples of the data moving through it — and the only pod that needs to reach it is the gateway performing the serviceMonitor scrape.

Egress is not restricted, and the policyTypes list says so rather than pairing an Egress type with an allow-everything rule. Two of the agent’s three destinations are unaddressable from here: the API server backs discovery.kubernetes, and on a managed control plane it is not in the pod network at all. The third, the gateway, is in-namespace and would be easy — but a policy that allows only the parts we happen to know is a policy that breaks the first time someone adds a scrape to the agent pipeline, and does it silently. Narrow it deliberately, with egress and policyTypes, once you know what your agent actually collects.

The default from selects by pod label within the release namespace. Running the gateway in a different namespace (see profiles/split-namespace.values.yaml) requires replacing this list with one that adds a namespaceSelector.

alloy-agent.networkPolicy.egressstringnilNull rather than `[]`: an empty list still merges as "no rules", but null is what the subchart documents for "do not render this direction", and it keeps `egress` out of a spec whose `policyTypes` omits `Egress`.

Alloy Gateway#

Alloy gateway instance. Cardinality reduction and backend-specific egress happen here.

Upstream reference:

KeyTypeDefaultDescription
alloy-gateway.fullnameOverridestring"alloy-gateway"Standard Helm full-name override. We use a static name for deterministic relations.
alloy-gateway.namespaceOverridestringnilNamespace override.
alloy-gateway.crdsobject
{
  "create": false
}
Control for the PodLogs crd.
alloy-gateway.global.podSecurityContextobject
{
  "fsGroup": 473,
  "runAsGroup": 473,
  "runAsUser": 473
}
Security context for the alloy gateway pods.
alloy-gateway.alloy.stabilityLevelstring"experimental"Stability level of alloy components.

experimental, because every gateway scrape sets honor_metadata, which Alloy refuses to build below that level. It is what types the metrics the OpenTelemetry destinations receive. The Google Cloud exporter’s otelcol.auth.google is public preview and needs no more than this.

A lower level renders an error rather than a gateway that crashloops: alloy validate, and so the pre-validate job, does not check a component’s arguments against it.

alloy-gateway.alloy.extraEnvlist
[
  {
    "name": "AUTOMEMLIMIT",
    "value": "0.8"
  }
]
Extra environment variables to pass to the alloy gateway pod. `AUTOMEMLIMIT` sets `GOMEMLIMIT` to 80% of the container's memory limit when Alloy starts, for the same reason as the agent's `GOMEMLIMIT`. Deriving it means raising `resources` is the whole change: the GC ceiling follows, and so do the memory limiter's thresholds, which are percentages of the same limit. The ratio has to stay below the limiter's 85%. The GC should be holding the heap down well before the limiter starts refusing scrapes, and a refused scrape is lost. An explicit `GOMEMLIMIT` here takes precedence and is not kept in step with `resources`. Replacing this list without either leaves Alloy's own default of 90%, which sits above the limiter.
alloy-gateway.alloy.envFromlist
[
  {
    "configMapRef": {
      "name": "mzmon-alloy-gateway-env"
    }
  },
  {
    "secretRef": {
      "name": "mzmon-alloy-gateway-env",
      "optional": true
    }
  }
]
Environment variable configmaps/secrets to pass to the alloy gateway pod.
alloy-gateway.alloy.extraPortslist
[
  {
    "name": "loki",
    "port": 3100,
    "protocol": "TCP",
    "targetPort": 3100
  },
  {
    "name": "otlp-grpc",
    "port": 4317,
    "protocol": "TCP",
    "targetPort": 4317
  },
  {
    "name": "otlp-http",
    "port": 4318,
    "protocol": "TCP",
    "targetPort": 4318
  },
  {
    "name": "prom",
    "port": 9090,
    "protocol": "TCP",
    "targetPort": 9090
  }
]
Ports to expose from alloy-gateway.
alloy-gateway.alloy.mountsobject
{
  "extra": [
    {
      "mountPath": "/tmp",
      "name": "tmp"
    },
    {
      "mountPath": "/etc/mzmon/tls",
      "name": "mzmon-tls",
      "readOnly": true
    }
  ],
  "varlog": false
}
Volume mounts to expose to alloy gateway.
alloy-gateway.alloy.mounts.extra[1]object
{
  "mountPath": "/etc/mzmon/tls",
  "name": "mzmon-tls",
  "readOnly": true
}
Certificate material from `certificates`, when it is enabled. `optional: true` is what lets this be unconditional. The Secret does not exist until `certificates.enabled` is on and cert-manager has signed, and an optional secret volume that is missing mounts empty rather than blocking the pod — so the same values work before, during and after issuance.

That holds only while nothing reads the material, which is why the mTLS profiles flip it to optional: false. Once a config names a path inside this mount, an empty mount is not a pod serving plaintext: Alloy fails its initial load and enters CrashLoopBackOff, backing off to five minutes, on an error naming a file rather than the Secret behind it. Required, the same race is a container the kubelet holds in ContainerCreating with a FailedMount event, started on the next volume re-sync. Do not set this to false here — with certificates off there is no Secret to wait for and the pod would never start.

The kubelet refreshes the contents in place on renewal, which is why the tls.*File carriers are preferred over the inline PEMs.

alloy-gateway.alloy.securityContextobject
{
  "allowPrivilegeEscalation": false,
  "capabilities": {
    "drop": [
      "ALL"
    ]
  },
  "readOnlyRootFilesystem": true,
  "runAsGroup": 473,
  "runAsNonRoot": true,
  "runAsUser": 473,
  "seccompProfile": {
    "type": "RuntimeDefault"
  }
}
Security context for the alloy gateway containers. The locked-down context the `mzmon-alloy` image documents, `RuntimeDefault` seccomp included. No pipeline component here needs a syscall that profile blocks; the eBPF-based ones, such as `beyla` and `pyroscope.ebpf`, would.
alloy-gateway.alloy.resourcesobject
{
  "limits": {
    "cpu": "500m",
    "memory": "2Gi"
  },
  "requests": {
    "cpu": "500m",
    "memory": "2Gi"
  }
}
Resources for the alloy gateway containers.

Memory is the gateway’s binding constraint. Its live heap is about 200MiB of fixed cost plus about 2.6KiB for every series the pod holds. Each series is held three times: by the scrape cache, the OTLP-to-Prometheus bridge and the remote-write queue. The gateways split the scrape targets, so at minReplicas each pod holds its share of every series in the cluster. It also holds, until its write-ahead log next checkpoints, the series of targets a recent resharding moved away from it.

Keep that live heap under about half of GOMEMLIMIT. Past that the GC runs constantly: measured with a 600MiB GOMEMLIMIT, a pod with 300MiB live collected about 3 times a minute on 0.07 cores, and one with 450MiB live collected 30 or more times a minute on 0.3 cores. That CPU is what the autoscaler scales on, so a gateway short of memory also scales out and back for no other reason.

The default fits a medium install. It was measured on 9 nodes and ten Materialize replicas, a quarter of a million series in all. At 2 replicas a pod there holds 120k to 160k series, about 500 to 600MiB live, against a 1.6GiB GOMEMLIMIT. Raise this as series grow. GOMEMLIMIT and the memory limiter’s thresholds follow it (see extraEnv).

alloy-gateway.controller.extraAnnotationsobject
{}
Extra annotations to apply to the alloy gateway pod. If you are using pulumi, be sure to add `config.kubernetes.io/depends-on: job/mzmon-validate-gateway`
alloy-gateway.controller.priorityClassNamestring"monitoring-critical"Scheduling priority. See the Priority classes section. Critical despite being a Deployment: every signal in the stack leaves through here, so losing it stops logs and metrics at once.
alloy-gateway.controller.autoscaling.horizontal.targetMemoryUtilizationPercentageint0Memory scaling is deliberately OFF (`0` is the subchart's disable value; it renders the metric away rather than setting it to zero). Not a tuning choice — memory utilization does not say whether a gateway is short of memory. The Go runtime lets the heap grow toward `GOMEMLIMIT` before it collects hard, so a healthy pod's working set reads high too, and on a small cluster most of it is fixed per-process cost that a new replica only duplicates. On a 7-node cluster, going from 3 replicas to 6 moved per-pod memory from 370Mi to 341Mi while total consumption went from 1.1Gi to 2.0Gi. It also cannot stabilize. Idle sat at ~62% of the request, so a 60% target was below the floor: the HPA scaled up, the metric did not move, and it flapped against maxReplicas indefinitely. No target value fixes that, because the control loop is open — the action does not change the measurement. Size gateway memory vertically instead (`resources`, above). A pod that is short of it shows on CPU first, because collecting more often is what it spends the CPU on.
alloy-gateway.controller.autoscaling.horizontal.scaleDownobject
{
  "policies": [
    {
      "periodSeconds": 600,
      "type": "Pods",
      "value": 1
    }
  ],
  "selectPolicy": "Max",
  "stabilizationWindowSeconds": 3600
}
Scale down slowly: only after an hour of low CPU, and one pod at a time.

Every scale event reshards the scrape targets. A scale-down hands each remaining pod a larger share, and every pod keeps the series of the targets it gave up until its write-ahead log next checkpoints, hours later. A gateway that scales down and back up within the hour pays for both shares at once. The scale-up path stays fast, because it relieves load rather than concentrating it.

alloy-gateway.serviceAccount.createbooltrueCreate a service account for alloy-gateway.
alloy-gateway.serviceAccount.annotationsobject
{}
Extra annotations to set on the alloy-gateway service account. Use `eks.amazonaws.com/role-arn` to set up IRSA. Use `iam.gke.io/gcp-service-account` to set up Workload Identity Federation.
alloy-gateway.networkPolicyobject
{
  "egress": null,
  "enabled": true,
  "flavor": "kubernetes",
  "ingress": [
    {
      "from": [
        {
          "namespaceSelector": {}
        }
      ],
      "ports": [
        {
          "port": 3100,
          "protocol": "TCP"
        },
        {
          "port": 4317,
          "protocol": "TCP"
        },
        {
          "port": 4318,
          "protocol": "TCP"
        },
        {
          "port": 9090,
          "protocol": "TCP"
        }
      ]
    },
    {
      "from": [
        {
          "podSelector": {
            "matchLabels": {
              "app.kubernetes.io/name": "alloy-gateway"
            }
          }
        }
      ],
      "ports": [
        {
          "port": 12345,
          "protocol": "TCP"
        }
      ]
    }
  ],
  "policyTypes": [
    "Ingress"
  ]
}
NetworkPolicy for the gateway.

Two ingress rules, because the gateway’s ports fall into two groups with very different audiences.

The ingest ports — 3100 (Loki push), 4317/4318 (OTLP), 9090 (Prometheus remote write) — are open to every pod in the cluster. That is deliberate and it is not laziness: these are the endpoints the stack exists to offer. The agent pushes logs to 3100, and anything in the cluster with telemetry to send — a Materialize workload emitting OTLP, an application remote-writing metrics — is a legitimate client this chart cannot enumerate. namespaceSelector: {} still means in-cluster: a sender outside the pod network is denied, and the gateway is not meant to be internet-facing.

12345 is not an ingest port and is closed to everything but the gateway itself. It carries the clustering gossip between gateway replicas (alloy.clustering.enabled) and serves /metrics and the debugging UI, which exposes the pipeline and samples of the telemetry passing through it. The gateway scrapes its own serviceMonitor, so its own pods are the only peer.

Egress is not restricted, and cannot usefully be. The gateway scrapes the kubelet cAdvisor endpoint on every node — port 10250 on a node IP, which no podSelector matches — and it discovers ServiceMonitor and PodMonitor targets cluster-wide, so its destination set is every pod and every port that anyone in the cluster has ever pointed a monitor at. On top of that it is the stack’s egress point for external backends: Amazon Managed Prometheus, Honeycomb, a Datadog or OTLP destination under connections. Enumerating that is not a policy, it is a copy of the cluster’s inventory that goes stale.

alloy-gateway.networkPolicy.egressstringnilSee the note above: unrestricted, deliberately.

Loki#

Bundled Loki backend for logs.

Upstream reference:

KeyTypeDefaultDescription
loki.fullnameOverridestring"loki"Standard Helm full-name override. We use a static name for deterministic relations.
loki.namespaceOverridestringnilNamespace override.
loki.global.priorityClassNamestring"monitoring-scalable"Scheduling priority for Loki. See the Priority classes section. `global` covers every component that renders through the chart's `_pod.tpl` — but *not* the two memcached StatefulSets, which read only their own component key. They are set explicitly further down. This is the same asymmetry `terraform/modules/materialize-monitoring/scheduling.tf` calls out for nodeSelector and tolerations; rendering is what catches it.
loki.deploymentModestring"Distributed"How loki is deployed. We prefer to run in Distributed/Microservices mode.
loki.networkPolicy.enabledbooltrueWhether to enable a network policy for loki In production, this is recommended to be enabled.
loki.networkPolicy.metricsobject
{
  "namespaceSelector": {}
}
Selector for incoming traffic to metric endpoints. This must be configured manually (usually set to `kubernetes.io/metadata.name: monitoring`).
loki.networkPolicy.ingressobject
{
  "namespaceSelector": {}
}
Selector for incoming traffic to the read/write endpoints. This must be configured manually (usually set to `kubernetes.io/metadata.name: monitoring`).
loki.networkPolicy.externalStorageobject
{
  "cidrs": [
    "0.0.0.0/0"
  ],
  "ports": [
    443,
    80
  ]
}
Outgoing traffic from loki to the object store. This is usually unrestricted, even in many production settings. Adjust if you have a VPCEndpoint in front of S3/STS or are using a non-standard port.
loki.loki.imageobject
{
  "registry": "docker.io",
  "repository": "grafana/loki",
  "tag": "3.7.9"
}
Loki image, pinned here rather than inherited from the subchart's `appVersion`.

So Renovate bumps Loki on its own cadence, with Loki’s own release notes, instead of only when a chart release happens to carry a new appVersion. Every Loki component runs this image unless its own image overrides it. The canary is pinned separately in lokiCanary.image, and Renovate groups the two so that they move together.

In the Loki chart, global.imageRegistry outranks the registry of every image, this one included. The profiles under profiles/registry/ repoint registry and repository and keep this tag.

loki.loki.storage.bucketNamesobject
{
  "chunks": "\u003cREPLACE-ME\u003e",
  "ruler": "\u003cREPLACE-ME\u003e"
}
Bucket names for object storage. These are required to be populated.
loki.loki.storage.use_thanos_objstorebooltrueUse the thanos object store client
loki.loki.storage.object_storeobject
{
  "s3": {
    "endpoint": "s3.amazonaws.com"
  },
  "type": "s3"
}
Object storage configuration. Modify as needed. Only the block named by `type` is read; it is handed to Loki verbatim, with `bucket_name` filled in from `bucketNames` above.
loki.loki.schemaConfigobject
{
  "configs": [
    {
      "from": "2024-01-01",
      "index": {
        "period": "24h",
        "prefix": "loki_index_"
      },
      "object_store": "s3",
      "schema": "v13",
      "store": "tsdb"
    }
  ]
}
Schema configuration for the loki TSDB. This is append-only and MUST be copied into projects and mutated on upgrades (if v13 stops being in use).
loki.loki.commonConfigobject
{
  "replication_factor": 3
}
Cluster-wide replication factor. Three is our HA floor and the reason at least three ingesters are required. (This matches the upstream default; surfaced here because it is load-bearing.)
loki.loki.limits_configobject
{
  "ingestion_burst_size_mb": 20,
  "ingestion_rate_mb": 10,
  "max_global_streams_per_user": 10000,
  "reject_old_samples": true,
  "reject_old_samples_max_age": "168h",
  "retention_period": "30d",
  "volume_enabled": true
}
Per-tenant limits, sized for a medium install. These are protective caps (per environment), not the expected volume — see Operating > Production Best Practices for the throughput envelope.
loki.loki.limits_config.retention_periodstring"30d"Default retention before the compactor deletes logs. Upstream defaults to infinite retention; we set a real bound.
loki.loki.limits_config.ingestion_rate_mbint10Per-tenant ingestion rate / burst, in MB. Per environment, not the fleet aggregate.
loki.loki.limits_config.max_global_streams_per_userint10000Active-stream ceiling per tenant; a cardinality guard.
loki.loki.limits_config.reject_old_samplesbooltrueReject writes too far in the past.
loki.loki.limits_config.volume_enabledbooltrueEnable the log-volume endpoints Grafana's logs drilldown relies on.
loki.loki.compactorobject
{
  "delete_request_store": "s3",
  "retention_delete_delay": "8h",
  "retention_enabled": true
}
Compactor *configuration* (distinct from the compactor deployment below). Retention is enforced by the compactor and is OFF in upstream defaults.
loki.loki.compactor.delete_request_storestring"s3"Where delete requests are stored; matches the object-store backend.
loki.loki.compactor.retention_delete_delaystring"8h"Grace period before retention/deletes actually remove data.
loki.loki.rulerConfigobject
{
  "alert_relabel_configs": [
    {
      "action": "replace",
      "regex": "",
      "replacement": "${CLUSTER_NAME}",
      "source_labels": [
        "cluster"
      ],
      "target_label": "cluster"
    }
  ],
  "alertmanager_refresh_interval": "30s",
  "alertmanager_url": "http://_http._tcp.alertmanager-headless.{{ .Release.Namespace }}.svc.{{ include \"mzmon.clusterDomain\" . }}",
  "enable_alertmanager_discovery": true,
  "enable_alertmanager_v2": true,
  "enable_sharding": true,
  "evaluation_interval": "1m",
  "poll_interval": "1m",
  "remote_write": {
    "clients": {
      "gateway": {
        "queue_config": {
          "batch_send_deadline": "5s",
          "capacity": 2500,
          "max_samples_per_send": 500,
          "max_shards": 10,
          "min_shards": 1
        },
        "remote_timeout": "30s",
        "url": "http://alloy-gateway.{{ .Release.Namespace }}.svc.{{ include \"mzmon.clusterDomain\" . }}:9090/api/v1/metrics/write",
        "write_relabel_configs": [
          {
            "regex": "",
            "replacement": "loki-ruler",
            "source_labels": [
              "job"
            ],
            "target_label": "job"
          },
          {
            "regex": "",
            "replacement": "loki-ruler",
            "source_labels": [
              "instance"
            ],
            "target_label": "instance"
          }
        ]
      }
    },
    "enabled": true
  },
  "rule_path": "/var/loki/ruler-rules"
}
Ruler *configuration* (distinct from the ruler deployment below).

The ruler deployment has been on since this chart had one. What it has never had is somewhere to send an alert: without alertmanager_url it evaluates its rules correctly and drops every alert on the floor, which is indistinguishable from nothing being wrong. That is what this block fixes.

Rule storage is configured and is not here. With loki.storage.use_thanos_objstore on, the subchart renders a top-level ruler_storage block pointing at loki.storage.bucketNames.ruler. The chart’s log-derived rules are written into it through the ruler API, by the alloy-gateway’s loki.rules.kubernetes, from PrometheusRule resources labelled mzmon.materialize.cloud/flavor: logql. A rule store that refuses writes, such as local, leaves them out.

Everything here is tpl-evaluated by the subchart, so .Release.* resolves. It does not see the umbrella’s values, which is why these addresses are spelled out rather than built from the helpers the Thanos side uses — and why split-namespace overrides both of them.

loki.loki.rulerConfig.enable_shardingbooltrueDivide the rule groups between the ruler replicas, so each group is evaluated by one of them.

Without it every replica loads every group: each alert is sent once per replica, and each recording-rule sample is written once per replica.

The replicas divide the groups through a hash ring. Loki puts every ring on memberlist when memberlist.join_members is set, which the subchart does, so the ruler’s ring rides the same gossip cluster as the rest of Loki and needs no configuration of its own.

A replicaIts groups move to the others
Stops cleanly (rollout, eviction, drain)Within one evaluation, because it leaves the ring on the way out
Is lost without stopping (node failure)After two heartbeat_timeouts (two minutes by default), when the others forget it. The groups are not evaluated until then
loki.loki.rulerConfig.remote_writeobject
{
  "clients": {
    "gateway": {
      "queue_config": {
        "batch_send_deadline": "5s",
        "capacity": 2500,
        "max_samples_per_send": 500,
        "max_shards": 10,
        "min_shards": 1
      },
      "remote_timeout": "30s",
      "url": "http://alloy-gateway.{{ .Release.Namespace }}.svc.{{ include \"mzmon.clusterDomain\" . }}:9090/api/v1/metrics/write",
      "write_relabel_configs": [
        {
          "regex": "",
          "replacement": "loki-ruler",
          "source_labels": [
            "job"
          ],
          "target_label": "job"
        },
        {
          "regex": "",
          "replacement": "loki-ruler",
          "source_labels": [
            "instance"
          ],
          "target_label": "instance"
        }
      ]
    }
  },
  "enabled": true
}
Remote-write for recording-rule samples.

This is what the ruler’s PVC exists for: the WAL buffers derived samples when the metric store is unreachable, which is exactly the run-up to an incident. Same destination as the Thanos ruler — the alloy-gateway’s prometheus.receive_http listener — so both rulers’ results reach the destination fan-out rather than only the bundled metric store.

loki.gateway.enabledbooltrueRun the Loki gateway, the nginx reverse proxy Grafana reads Loki through.

Grafana’s alerting UI lists a datasource’s rules from the datasource’s own URL. In distributed mode the query frontend answers queries and the ruler answers for rules, so a datasource pointed at either one sees half of Loki. The gateway routes by path and gives Grafana one address for both.

The routing is this chart’s own (nginxConfig.file), and it is narrower than the subchart’s:

RequestSubchart’s gatewayThis chart’s gateway
Queries, labels, tailQuery frontendQuery frontend
Rule and alert stateRulerRuler, GET only
Rule definitionsRuler, read and writeRefused; loki.rules.kubernetes on the alloy-gateway owns them
PushesDistributorRefused; the alloy-gateway is the write path
Rings, flush, config, deletesEach componentRefused

Every request resolves its backend’s name through resolver, so a Service recreated under the same name is followed without a restart. Turning the gateway off needs connections.datasources.loki.url pointed back at the query frontend, and Grafana then shows no Loki rules.

loki.gateway.replicasint2Replicas. Two, for availability. The subchart's required per-node anti-affinity is kept, so each replica needs a node of its own.
loki.gateway.podDisruptionBudget.minAvailableint1Keep one gateway through voluntary disruption.
loki.gateway.imageobject
{
  "pullPolicy": "IfNotPresent",
  "registry": "docker.io",
  "repository": "nginxinc/nginx-unprivileged",
  "tag": "1.31.7-alpine-slim"
}
The nginx image. The `-alpine-slim` variant carries the SSL and stub-status modules this config uses, at a quarter of the full image's size.
loki.gateway.resourcesobject
{
  "requests": {
    "cpu": "50m",
    "memory": "64Mi"
  }
}
Resource requests.
loki.gateway.verboseLoggingboolfalseLog only failed requests (4xx and 5xx). Grafana reads through the gateway constantly, and every successful request's line would be ingested back into the Loki it is proxying.
loki.gateway.service.portint8080The Service port, equal to the container port. A TLS profile then changes only the scheme of the URL that dials it.
loki.gateway.nginxConfig.filestring"{{ include \"mzmon.loki.gateway.nginxConf\" . }}"The nginx.conf, rendered by `mzmon.loki.gateway.nginxConf`. Its header comment lists what it routes and what it refuses.
loki.gateway.nginxConfig.tlsdescevery path emptyTLS for the gateway's listener and for its connections to Loki.

Read by this chart’s nginx.conf; the subchart does not know the key. profiles/mtls.values.yaml sets these, alongside ssl and schema.

Keynginx directiveUsed when
certFile, keyFilessl_certificate, ssl_certificate_keyssl is true
clientCaFilessl_client_certificate, with ssl_verify_client optionalset, with ssl
upstreamCaFileproxy_ssl_trusted_certificate, with proxy_ssl_verify onschema is https
upstreamCertFile, upstreamKeyFileproxy_ssl_certificate, proxy_ssl_certificate_keyset, with schema: https

Requiring a client certificate is not offered. The kubelet’s readiness probe dials the same listener and cannot present one, the same limit that holds Loki’s own HTTP port at phase 2.

loki.gateway.metrics.imageobject
{
  "pullPolicy": "IfNotPresent",
  "registry": "ghcr.io",
  "repository": "jkroepke/access-log-exporter",
  "tag": "1.0.1"
}
The access-log exporter sidecar, which turns the gateway's request log into Prometheus metrics.
loki.gateway.metrics.extraArgslist
[
  "--nginx.scrape-url=http://127.0.0.1:8081/stub_status"
]
Scrape `stub_status` from the loopback-only listener the nginx.conf serves on 8081, rather than from the client port. The flag is repeated after the subchart's own and the later one wins, so turning on TLS for clients does not break the scrape.
loki.gateway.metrics.service.labelsobject
{
  "monitoring.materialize.cloud/scrape-scheme": "plaintext",
  "prometheus.io/service-monitor": "false"
}
Keep the exporter out of the subchart's ServiceMonitor and into this chart's plaintext one. It serves `/metrics` in the clear whatever the gateway's listener does. See `monitoring.serviceMonitor` below.
Loki Microservice Configuration#

Configuration for each loki microservice.

https://grafana.com/docs/loki/latest/get-started/components/

KeyTypeDefaultDescription
loki.distributor.enabledbooltrueEnable the distributor microservice. Distributor is required. The Distributor is the stateless front door for writes. It validates incoming streams against per-tenant limits, enforces rate limits, and normalizes labels, then splits the batch into individual streams and forwards each to the owning ingesters.
loki.distributor.replicasstringnilNumber of replicas for the distributor microservice. If autoscaling is enabled, this should be set to null.
loki.distributor.kindstring"Deployment"Type of workload for the distributor.
loki.distributor.autoscalingobject
{
  "enabled": true,
  "maxReplicas": 4,
  "minReplicas": 2,
  "targetCPUUtilizationPercentage": 60,
  "targetMemoryUtilizationPercentage": 75
}
Configuration for autoscaling of distributor For production, this is recommended to be enabled. We provide opinionated defaults.
loki.distributor.service.typestring"ClusterIP"Service type for the distributor microservice. Without a gateway, this is the exposed write component.
loki.distributor.podDisruptionBudgetobject
{
  "enabled": true,
  "minAvailable": 1
}
Configuration for pod disruption budget for the distributor microservice.
loki.distributor.resourcesobject
{
  "requests": {
    "cpu": "150m",
    "memory": "256Mi"
  }
}
Resource requests (medium install). Tune per profile.
loki.ingester.enabledbooltrueEnable the ingester microservice. Ingester is required. The Ingester is the stateful heart of the write path, and it also serves the most recent reads. It buffers incoming entries into per-stream in-memory chunks, compresses them, and periodically flushes those chunks and their index to object storage.
loki.ingester.replicasint3Number of ingester replicas. Three is the floor for replication_factor 3. Scale UP on memory / stream-cardinality (so streams shard across the ring), not on bytes — with replicas == replication_factor every ingester holds every stream.
loki.ingester.kindstring"StatefulSet"Ingesters are stateful.
loki.ingester.persistence.enabledboolfalseRun ingesters ephemerally: no PVC, node-local emptyDir for the WAL and not-yet-flushed chunks. Durability comes from replication_factor 3, not from disk — a rescheduled ingester starts fresh and the ring backfills from its peers. This avoids EBS zonal pinning and the slow volume detach/attach that PVCs incur during node replacement.
loki.ingester.terminationGracePeriodSecondsint60Termination grace period for ingesters. flush-on-shutdown is best-effort; if the node force-kills sooner, the other replicas still flush their copies, so do not depend on a long grace period for durability. Kept modest to survive enterprise force-kill windows.
loki.ingester.affinityobject
{
  "podAntiAffinity": {
    "requiredDuringSchedulingIgnoredDuringExecution": null
  }
}
Pod affinity for ingesters. Drop the chart's default *hard* per-host anti-affinity so host spread can be soft (see topologySpreadConstraints); zone spread stays hard. We null the nested list rather than the whole `affinity` map: `affinity: {}` is a no-op against the subchart default, and `affinity: null` clears it but makes helm-unittest log a noisy "cannot overwrite table" warning — nulling the list avoids both.
loki.ingester.topologySpreadConstraintslist
[
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/component": "ingester",
        "app.kubernetes.io/instance": "{{ .Release.Name }}",
        "app.kubernetes.io/name": "{{ include \"loki.name\" . }}"
      }
    },
    "matchLabelKeys": [
      "controller-revision-hash"
    ],
    "maxSkew": 1,
    "minDomains": 2,
    "nodeTaintsPolicy": "Honor",
    "topologyKey": "topology.kubernetes.io/zone",
    "whenUnsatisfiable": "DoNotSchedule"
  },
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/component": "ingester",
        "app.kubernetes.io/instance": "{{ .Release.Name }}",
        "app.kubernetes.io/name": "{{ include \"loki.name\" . }}"
      }
    },
    "matchLabelKeys": [
      "controller-revision-hash"
    ],
    "maxSkew": 1,
    "topologyKey": "kubernetes.io/hostname",
    "whenUnsatisfiable": "ScheduleAnyway"
  }
]
Topology spread for ingesters: hard across zones, soft across hosts. A pod that cannot satisfy the hard zone rule goes Pending, which is the signal Karpenter (or the cluster-autoscaler) uses to add a node in the deficient zone — soft rules cannot summon capacity that way. Host spread is soft so pods still schedule when nodes are momentarily scarce.
loki.ingester.zoneAwareReplication.enabledboolfalseEnable zone-aware replication for the ingester microservice. Not needed at our size: topologySpreadConstraints give the AZ spread without the complexity.
loki.ingester.podDisruptionBudgetobject
{
  "enabled": true,
  "maxUnavailable": 1
}
PDB for the ingester. Protect ingest quorum across rollouts and node drains. Do not set maxUnavailable >= 2 to avoid potential quorum loss.
loki.ingester.resourcesobject
{
  "requests": {
    "cpu": "500m",
    "memory": "1Gi"
  }
}
Resource requests (medium install). The memory *limit* is intentionally left unset: an OOM-kill drops in-memory / WAL-buffered logs, so we alert on usage rather than cap hard.
loki.querier.enabledbooltrueEnable the querier microservice. Querier is required. It executes LogQL, reading recent data from ingesters and historical data from object storage.
loki.querier.replicasstringnilReplicas are managed by autoscaling; leave null.
loki.querier.autoscalingobject
{
  "enabled": true,
  "maxReplicas": 4,
  "minReplicas": 2,
  "targetCPUUtilizationPercentage": 60,
  "targetMemoryUtilizationPercentage": 75
}
Autoscale the stateless read workers with query load.
loki.querier.podDisruptionBudgetobject
{
  "enabled": true,
  "minAvailable": 1
}
PDB for querier.
loki.querier.resourcesobject
{
  "requests": {
    "cpu": "250m",
    "memory": "512Mi"
  }
}
Resource requests. This assumes a medium install by default.
loki.queryFrontend.enabledbooltrueEnable the query-frontend microservice. Query-frontend is required. It queues and splits queries and caches results.
loki.queryFrontend.replicasstringnilStatic replicas for query-frontend when autoscaling is not enabled.
loki.queryFrontend.kindstring"Deployment"Kind of workload for query-frontend. Deployments are recommended for stateless components.
loki.queryFrontend.autoscalingobject
{
  "enabled": true,
  "maxReplicas": 4,
  "minReplicas": 2,
  "targetCPUUtilizationPercentage": 60,
  "targetMemoryUtilizationPercentage": 75
}
Configure autoscaling for query-frontend.
loki.queryFrontend.podDisruptionBudgetobject
{
  "enabled": true,
  "minAvailable": 1
}
PDB for query-frontend.
loki.queryFrontend.resourcesobject
{
  "requests": {
    "cpu": "100m",
    "memory": "256Mi"
  }
}
Resource requests for query-frontend.
loki.queryScheduler.enabledbooltrueEnable the query-scheduler microservice. Recommended for scaled deployments: it decouples the read queue from the query-frontend so the two scale independently. Two replicas for availability.
loki.queryScheduler.podDisruptionBudgetobject
{
  "enabled": true,
  "minAvailable": 1
}
PDB for query-scheduler.
loki.queryScheduler.resourcesobject
{
  "requests": {
    "cpu": "100m",
    "memory": "256Mi"
  }
}
Resources for the query-scheduler microservice.
loki.compactor.enabledbooltrueEnable the compactor. Compactor is required and MUST be a singleton: it compacts the index and enforces retention against shared object storage.
loki.compactor.replicasint1Compactor replicas. This MUST be one, since it runs as a singleton.
loki.compactor.kindstring"StatefulSet"Kind of workload for the compactor. This must be a statefulset.
loki.compactor.persistence.enabledboolfalseRun the compactor ephemerally (no PVC, node-local emptyDir). Its local dir is just a working copy of the object-store index, and compaction is idempotent and off the critical path — losing it only restarts the current cycle. Ephemeral also lets the singleton float freely between zones instead of being pinned by a PVC. Our production index is ~30MB, so re-downloading each cycle is negligible.
loki.compactor.resourcesobject
{
  "requests": {
    "cpu": "250m",
    "memory": "512Mi"
  }
}
Resources for the compactor.
loki.indexGateway.enabledbooltrueEnable the index-gateway. Index-gateway is required: it serves index lookups so queriers do not each download the whole index from object storage.
loki.indexGateway.persistenceobject
{
  "enabled": false
}
Run the index-gateway ephemerally (no PVC, node-local emptyDir). The local index is a read-through cache of object storage, not authoritative — a fresh pod just re-downloads what it queries. This keeps the read path off EBS zonal pinning; the only cost is a little cold-start warmup and a burst of index reads after a reschedule.
loki.indexGateway.podDisruptionBudgetobject
{
  "enabled": true,
  "minAvailable": 1
}
PDB for index-gateway.
loki.indexGateway.resourcesobject
{
  "requests": {
    "cpu": "200m",
    "memory": "512Mi"
  }
}
Resources for the index-gateway.
loki.ruler.enabledbooltrueEnable the ruler. The ruler evaluates LogQL alerting and recording rules. Recording-rule samples are remote-written back through alloy-gateway to the metric store. The evaluation and notification wiring lives in `loki.loki.rulerConfig`; this switch only decides whether the component is deployed.
loki.ruler.replicasint2Replicas. Two, for availability. They divide the rule groups between them rather than each evaluating all of them; see `loki.loki.rulerConfig.enable_sharding`.
loki.ruler.persistence.enabledbooltrueKeep a PVC for the ruler (unlike the other components, which are ephemeral). Rule definitions come from object storage, but the ruler's remote-write WAL buffers recording-rule samples when the metric store is unreachable — genuinely useful durability in the run-up to an incident, exactly when you don't want to drop derived signals.
loki.ruler.podDisruptionBudgetobject
{
  "enabled": true,
  "minAvailable": 1
}
PDB for the ruler.
loki.ruler.resourcesobject
{
  "requests": {
    "cpu": "100m",
    "memory": "256Mi"
  }
}
Resources for the ruler.
loki.ruler.extraEnvlist
[
  {
    "name": "CLUSTER_NAME",
    "valueFrom": {
      "configMapKeyRef": {
        "key": "CLUSTER_NAME",
        "name": "ruler-env"
      }
    }
  }
]
The cluster name, which Loki's `-config.expand-env` substitutes into `rulerConfig`. `CLUSTER_NAME` is stamped on alerts by `rulerConfig.alert_relabel_configs`, read from the `ruler-env` ConfigMap the chart renders in the Loki namespace from `clusterName`. A list: restate it when adding another.
loki.chunksCacheh5{"allocatedMemory":2048, "priorityClassName":"monitoring-scalable", "service":{"labels":{"monitoring.materialize.cloud/scrape-scheme":"plaintext", "prometheus.io/service-monitor":"false"}}}Chunk cache (memcached). Default allocation is sized for very large installs; we shrink it to match our volumes. The results cache keeps its upstream default. `priorityClassName` is repeated on both caches because the memcached StatefulSet template reads its component key only — `loki.global` does not reach it.
loki.chunksCache.service.labelsobject
{
  "monitoring.materialize.cloud/scrape-scheme": "plaintext",
  "prometheus.io/service-monitor": "false"
}
Keep the cache out of the subchart's ServiceMonitor and into this chart's plaintext one. See the `plaintext exporters` note under `monitoring.serviceMonitor` below.
loki.resultsCacheh5{"priorityClassName":"monitoring-scalable", "service":{"labels":{"monitoring.materialize.cloud/scrape-scheme":"plaintext", "prometheus.io/service-monitor":"false"}}}Query results cache (memcached).
loki.resultsCache.service.labelsobject
{
  "monitoring.materialize.cloud/scrape-scheme": "plaintext",
  "prometheus.io/service-monitor": "false"
}
Keep the cache out of the subchart's ServiceMonitor, as above.
loki.memcached.imageobject
{
  "registry": "docker.io",
  "repository": "library/memcached",
  "tag": "1.6.45-alpine"
}
Memcached image for both caches, pinned here rather than left at the subchart's default.
loki.memcachedExporter.imageobject
{
  "registry": "docker.io",
  "repository": "prom/memcached-exporter",
  "tag": "v0.17.0"
}
Memcached exporter image for both caches, pinned here rather than left at the subchart's default.
loki.sidecar.imageobject
{
  "registry": "docker.io",
  "repository": "kiwigrid/k8s-sidecar",
  "tag": "2.14.1"
}
Rules sidecar image, pinned here rather than left at the subchart's default. It runs only in `SingleBinary` mode (`profiles/loki-test`), where it loads rule ConfigMaps into the single binary's ruler.
loki.monitoring.serviceMonitor.enabledbooltrueEnable a ServiceMonitor for the loki microservices.

Plaintext exporters are excluded from it. The subchart renders a single ServiceMonitor covering everything it labels, with one scheme shared by every target. Four of those targets never speak TLS whatever Loki is configured to do — the canary’s own /metrics server, the two memcached exporters, and the gateway’s access-log exporter — so under profiles/mtls, which sets scheme: https here, all four fail the scrape and their series vanish. For the canary that means the end-to-end write→read check goes quiet rather than red, which is the worst way for a canary to fail.

Each of the four therefore carries prometheus.io/service-monitor: "false", which the subchart’s selector excludes, plus a monitoring.materialize.cloud/scrape-scheme: plaintext opt-in that this chart’s own monitor selects on (templates/scrapers/monitor-loki-plaintext.yaml). The split is unconditional so the two modes share one code path.

loki.lokiCanaryh5{"enabled":true, "image":{"registry":"docker.io", "repository":"grafana/loki-canary", "tag":"3.7.9"}, "kind":"Deployment", "lokiurl":"loki-query-frontend:3100", "priorityClassName":"monitoring-scalable", "push":false, "service":{"labels":{"monitoring.materialize.cloud/scrape-scheme":"plaintext", "prometheus.io/service-monitor":"false"}}}End-to-end write→read canary for meta-monitoring. On by default upstream; surfaced here because self-monitoring the log store is a first-class requirement for us.
loki.lokiCanary.imageobject
{
  "registry": "docker.io",
  "repository": "grafana/loki-canary",
  "tag": "3.7.9"
}
Canary image, pinned beside `loki.loki.image` and grouped with it in Renovate. Loki publishes the two from one release, so the tags match.
loki.lokiCanary.service.labelsobject
{
  "monitoring.materialize.cloud/scrape-scheme": "plaintext",
  "prometheus.io/service-monitor": "false"
}
Keep the canary out of the subchart's ServiceMonitor and into this chart's plaintext one. Its `/metrics` server is plaintext even when `-tls` is set, since that flag configures the client it uses to reach Loki. See `monitoring.serviceMonitor` above.
loki.lokiCanary.priorityClassNamestring"monitoring-scalable"Scheduling priority. See the Priority classes section. Set explicitly because the canary reaches neither of the two keys that cover the rest of Loki: it renders from its own template rather than `_pod.tpl`, so `loki.global.priorityClassName` does not reach it, and `loki.defaults` does not either. Left unset it runs at priority 0 — *below* ordinary workloads — so the first node under pressure evicts the end-to-end write→read check, which is exactly the signal you want during the incident that caused the pressure.

Thanos#

Bundled Thanos backend for long-term metrics.

Upstream reference:

KeyTypeDefaultDescription
thanos.fullnameOverridestring"thanos"Standard Helm full-name override. We use a static name for deterministic relations.
thanos.namespaceOverridestringnilNamespace override.
thanos.global.priorityClassNamestring"monitoring-scalable"Scheduling priority for every Thanos pod. See the Priority classes section.
thanos.global.imageobject
{
  "registry": "quay.io",
  "repository": "thanos/thanos",
  "tag": "v0.42.5"
}
Thanos image for every component, pinned here rather than inherited from the subchart's `appVersion`.

So Renovate bumps Thanos on its own cadence, with Thanos’s own release notes, instead of only when a chart release happens to carry a new appVersion. The subchart has no per-component image, so this one tag moves Query, Store Gateway, Compactor, Query Frontend and Ruler together. The profiles under profiles/registry/ repoint registry and repository and keep this tag.

thanos.global.pdbobject
{
  "enabled": true,
  "maxUnavailable": 1
}
PodDisruptionBudgets for every Thanos component.

Upstream ships these off. One switch turns them on for all components, and the per-component pdb.enabled cannot opt back out — the subchart templates test or <component>.pdb.enabled global.pdb.enabled.

maxUnavailable rather than minAvailable, deliberately:

  • It scales with replica count instead of pinning an absolute floor.
  • On the single-replica Compactor, minAvailable: 1 would block every voluntary eviction and hang node drains indefinitely. maxUnavailable: 1 permits the eviction, so the singleton is a harmless no-op rather than a drain deadlock.
  • On Receive it must stay within what write quorum tolerates (replicationFactor - ((replicationFactor / 2) + 1)), which is 1 at the replication factor of 3 set below. A validator enforces this.

This matches the Loki convention (ingester maxUnavailable: 1).

thanos.global.networkPoliciesbooltrueNetworkPolicies for every enabled Thanos component.

One switch, no per-component knobs and no selectors — the subchart renders a fixed policy per component and exposes nothing to tune. What each one says is: ingress on that component’s own service ports, from anywhere; egress unrestricted.

So this is the weakest policy in the stack, and worth being clear about what it does and does not buy. It does not restrict who may reach Query, Receive or Store Gateway — any pod in the cluster still can. It does close every port that is not a declared service port, which is the surface an unrelated workload would otherwise find on these pods.

It is on because it costs nothing to be right about. The rules cannot break a working install: Receive’s grpc, http and remote-write ports are all in its own ingress list, so hashring replication between Receive pods and the gateway’s writes both keep working, and unrestricted egress leaves the object store reachable. Narrowing the ingress sources needs an upstream change, or your own policy layered on top.

thanos.global.podSecurityContextobject
{
  "runAsNonRoot": true,
  "seccompProfile": {
    "type": "RuntimeDefault"
  }
}
Pod security context for Query, Query Frontend and Bucketweb.

The subchart ships this empty. Its container context is already read-only, non-root and drops every capability; this adds RuntimeDefault seccomp.

It reaches only the components whose own podSecurityContext is empty. The subchart picks the first non-empty of component, parent and global without merging them, and Compactor, Store Gateway, Receive and Ruler each ship an fsGroup of their own. Those four carry the same two keys in their own blocks below, which merge over the subchart’s fsGroup.

thanos.queryobject
{
  "autoscaling": {
    "enabled": true,
    "maxReplicas": 5,
    "minReplicas": 2,
    "targetCPUUtilizationPercentage": 80
  },
  "enabled": true,
  "resources": {
    "requests": {
      "cpu": "500m",
      "memory": "1Gi"
    }
  },
  "topologySpreadConstraints": [
    {
      "labelSelector": {
        "matchLabels": {
          "app.kubernetes.io/component": "query",
          "app.kubernetes.io/name": "thanos"
        }
      },
      "matchLabelKeys": [
        "pod-template-hash"
      ],
      "maxSkew": 1,
      "topologyKey": "topology.kubernetes.io/zone",
      "whenUnsatisfiable": "ScheduleAnyway"
    },
    {
      "labelSelector": {
        "matchLabels": {
          "app.kubernetes.io/component": "query",
          "app.kubernetes.io/name": "thanos"
        }
      },
      "matchLabelKeys": [
        "pod-template-hash"
      ],
      "maxSkew": 1,
      "topologyKey": "kubernetes.io/hostname",
      "whenUnsatisfiable": "ScheduleAnyway"
    }
  ]
}
Thanos Query configuration. Query provides a PromQL query endpoint.
thanos.query.autoscalingobject
{
  "enabled": true,
  "maxReplicas": 5,
  "minReplicas": 2,
  "targetCPUUtilizationPercentage": 80
}
Horizontal autoscaling for Query. Query is a stateless PromQL fan-out, so it is the natural place to autoscale: no local state, no ring membership, no PVC.
thanos.query.topologySpreadConstraintslist
[
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/component": "query",
        "app.kubernetes.io/name": "thanos"
      }
    },
    "matchLabelKeys": [
      "pod-template-hash"
    ],
    "maxSkew": 1,
    "topologyKey": "topology.kubernetes.io/zone",
    "whenUnsatisfiable": "ScheduleAnyway"
  },
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/component": "query",
        "app.kubernetes.io/name": "thanos"
      }
    },
    "matchLabelKeys": [
      "pod-template-hash"
    ],
    "maxSkew": 1,
    "topologyKey": "kubernetes.io/hostname",
    "whenUnsatisfiable": "ScheduleAnyway"
  }
]
Topology spread for Query: soft on both axes. Stateless and autoscaled, so an unbalanced placement costs query capacity rather than correctness — soft is sufficient, and it keeps an HPA scale-up from stalling on a hard rule when the newest zone has no room yet.

pod-template-hash, not controller-revision-hash: Query is a Deployment, and that is the label its ReplicaSets carry. Using the StatefulSet label here would match nothing and quietly disable the same-revision guard.

thanos.query.resourcesobject
{
  "requests": {
    "cpu": "500m",
    "memory": "1Gi"
  }
}
Resource requests for Thanos Query. Medium sizing; see the `thanos-small` / `thanos-large` profiles for the other two columns. A CPU request is load-bearing here beyond scheduling: `targetCPUUtilization` is a percentage *of the request*, so without one the HPA above has no denominator and never scales. No memory limit — a fan-out query's peak scales with the series it touches, and an OOM-kill mid-query is a worse failure than a slow one.
thanos.receive.enabledbooltrueEnable Thanos receiver. Receive provides a Prometheus remote_write-compatible endpoint.
thanos.receive.modestring"standalone"Whether to split receive distributors from ingesters. mode=split is not very stable in the helm chart
thanos.receive.replicaCountint3Number of receive replicas.
thanos.receive.tsdbobject
{
  "retention": "6h"
}
How long Receive keeps blocks on local disk before they age out. **6h, overriding the subchart's 24h**, because local retention is what sets Receive's disk requirement — and on an `emptyDir` that disk is the node's, a far scarcer and much smaller pool than a provisioned volume was.

This is a recent-query cache, not a durability window: blocks ship to object storage every 2h and the Store Gateway serves everything older, so all that shortens here is how far back a query is answered from local TSDB rather than from the bucket. 6h covers the common dashboard ranges and leaves 4h of margin after a block closes, uploads, and the Store Gateway syncs it.

Raising it back is a real cost rather than just a number. At the medium envelope (1.5M series) 24h is ~7.3Gi per pod, and a GKE node with a 47Gi boot disk offers only ~18.8Gi of allocatable ephemeral storage — one pod would claim 40% of the node’s budget, and Loki’s ingesters draw on the same pool.

thanos.receive.extraArgslist
[
  "--receive.replication-factor=3"
]
Extra CLI arguments for Receive. **This is a list, and Helm overwrites lists rather than merging them.** Anything that sets `receive.extraArgs` replaces this entry wholesale, so the replication factor must be restated or it silently falls back to Thanos's default of 1 — write quorum 1, no losses tolerated. The shipped profiles restate it; so must yours. See Production Best Practices > Metrics (Thanos) for the quorum table.
thanos.receive.vpaobject
{
  "enabled": false
}
VerticalPodAutoscaler for Receive. Off, overriding the subchart's `true`. Three reasons, in order:
  1. It rewrites the requests below, so a sizing profile would not survive contact with it.
  2. updateMode: Auto evicts pods to apply new requests, and Receive holds up to tsdb.retention (6h) of blocks on an emptyDir whose only redundancy is the replication factor — an eviction discards that pod’s copy of the window.
  3. The subchart’s VPA template is CRD-gated, so leaving it on makes the stack behave differently on clusters with the VPA CRD installed than on those without — silently, in both directions.

If you want its recommendations without its actions, set updateMode: "Off" rather than re-enabling Auto.

thanos.receive.resourcesobject
{
  "limits": {
    "ephemeral-storage": "6Gi"
  },
  "requests": {
    "cpu": "500m",
    "ephemeral-storage": "4Gi",
    "memory": "4Gi"
  }
}
Resource requests for Receive. Medium sizing. Memory is the cell to get right: budget ~3 KB per active series held, and note that at `replicaCount == replicationFactor` **every pod holds every series** — sharding begins only above RF.

No memory limit, deliberately, and for a sharper reason than elsewhere in this chart: an OOM-kill drops the un-uploaded block window, and unlike Loki’s ingesters that window has nothing protecting it but the replication factor. Alert on usage instead.

ephemeral-storage is declared because the TSDB lives on an emptyDir (see persistence). The request is what the scheduler places against, so a node without room refuses the pod instead of filling up silently. The limit carries deliberate headroom over the request: the kubelet’s response to exceeding it is eviction, and evicting Receive destroys exactly the un-uploaded window this sizing exists to protect.

Size these against a node’s allocatable ephemeral storage, not its disk size. The two are nowhere near each other: GKE reserves most of the boot disk for the image filesystem, so a 47Gi disk offers roughly 18.8Gi allocatable. A request above that cannot schedule anywhere, and the cluster-autoscaler will not rescue it — no node of that shape would fit either, so the pod sits Pending indefinitely on Insufficient ephemeral-storage. Check before raising these:

kubectl get nodes -o custom-columns=‘NODE:.metadata.name,EPH:.status.allocatable.ephemeral-storage’

4Gi holds 6h of the medium envelope (~1.8Gi) with room for the WAL and compaction churn, and leaves the rest of the node for everything sharing that pool — including Loki’s ingesters, which are also emptyDir-backed.

thanos.receive.topologySpreadConstraintslist
[
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/component": "receive",
        "app.kubernetes.io/name": "thanos"
      }
    },
    "matchLabelKeys": [
      "controller-revision-hash"
    ],
    "maxSkew": 1,
    "minDomains": 2,
    "nodeTaintsPolicy": "Honor",
    "topologyKey": "topology.kubernetes.io/zone",
    "whenUnsatisfiable": "DoNotSchedule"
  },
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/component": "receive",
        "app.kubernetes.io/name": "thanos"
      }
    },
    "matchLabelKeys": [
      "controller-revision-hash"
    ],
    "maxSkew": 1,
    "topologyKey": "kubernetes.io/hostname",
    "whenUnsatisfiable": "ScheduleAnyway"
  }
]
Topology spread for Receive: hard across zones, soft across hosts. This is the constraint that makes `--receive.replication-factor=3` mean what the quorum table says. Without it the scheduler is free to place all three replicas in one zone, and RF 3 buys nothing against the failure it exists for — losing a zone takes every copy and, since write quorum is 2, takes writes with it.

Hard (DoNotSchedule) on zones, deliberately. A pod that cannot satisfy it goes Pending, which is the signal Karpenter or the cluster-autoscaler uses to add a node in the deficient zone — a soft rule cannot summon capacity that way, it just quietly places the pod wrongly. Host spread stays soft so pods still schedule when nodes are momentarily scarce; two pods sharing a node is a smaller problem than a pod not running.

This became viable when Receive stopped using a PVC (see persistence below). A zonal volume cannot be attached from another zone, so a hard zone rule and an AZ-pinned pod pull in opposite directions: the rule says “go to the empty zone”, the volume says “you may only run in zone A”, and the pod goes Pending forever rather than for as long as it takes to add a node. On emptyDir there is nothing holding it back.

The selector names both app.kubernetes.io/name and app.kubernetes.io/component, and the first of those is load-bearing: component names are not unique across backends. Loki also ships a compactor, a query-frontend and a ruler, so a component-only selector would make Thanos count Loki’s pods when computing its own skew — silently, and only for the components whose names happen to collide.

The release name is deliberately absent: the subchart renders this through toYaml rather than tpl, so a {{ include ... }} would land in the manifest literally. Spread is namespace-scoped and this chart assumes one instance of each backend per namespace, so name plus component is sufficient. See the Namespace layout section.

Note this cannot move to thanos.global.topologySpreadConstraints: each constraint carries its own labelSelector, and a global one would make every Thanos component count Receive’s pods when computing its own skew.

thanos.receive.persistenceobject
{
  "enabled": false
}
Local TSDB storage for Receive: **ephemeral, not a PVC.** This is the same call the chart makes for Loki's ingesters, for the same reason, and it is worth spelling out because Receive looks stateful.

Durability comes from --receive.replication-factor=3, not from disk. Blocks ship to object storage every 2h, so the window that exists only locally is at most 2h — and every replica uploads its own copy under a distinct receive_replica external label, which the Compactor deduplicates (see compactor.extraArgs). A pod that comes back with an empty volume has lost its copy of that window, and the query path still answers from the surviving replicas.

A PVC makes an AZ failure worse, not merely less flexible: an EBS volume cannot be attached from another zone, so a pod whose zone is gone stays Pending until the zone returns rather than rescheduling into a healthy one. On the write path that turns a recoverable event into an outage that waits on the cloud provider — with RF 3 write quorum is 2, so two pods stuck Pending block writes outright.

The trade accepted in exchange: Thanos Receive has no peer hand-off, so unlike Loki it will not backfill an emptied pod from its neighbours. That window stays at two copies instead of three until the next block ships.

thanos.storegatewayobject
{
  "enabled": true,
  "extraArgs": [
    "--store.limits.request-series=5000000",
    "--store.limits.request-samples=200000000"
  ],
  "persistence": {
    "enabled": true,
    "size": "10Gi"
  },
  "podSecurityContext": {
    "runAsNonRoot": true,
    "seccompProfile": {
      "type": "RuntimeDefault"
    }
  },
  "resources": {
    "requests": {
      "cpu": "500m",
      "memory": "3Gi"
    }
  },
  "topologySpreadConstraints": [
    {
      "labelSelector": {
        "matchLabels": {
          "app.kubernetes.io/component": "storegateway",
          "app.kubernetes.io/name": "thanos"
        }
      },
      "matchLabelKeys": [
        "controller-revision-hash"
      ],
      "maxSkew": 1,
      "topologyKey": "topology.kubernetes.io/zone",
      "whenUnsatisfiable": "ScheduleAnyway"
    },
    {
      "labelSelector": {
        "matchLabels": {
          "app.kubernetes.io/component": "storegateway",
          "app.kubernetes.io/name": "thanos"
        }
      },
      "matchLabelKeys": [
        "controller-revision-hash"
      ],
      "maxSkew": 1,
      "topologyKey": "kubernetes.io/hostname",
      "whenUnsatisfiable": "ScheduleAnyway"
    }
  ]
}
Thanos Store Gateway configuration. Store Gateway provides historical block querying.

Autoscaling is available upstream but left off deliberately. Store Gateway is a PVC-backed StatefulSet that syncs the bucket’s block index on startup, so scale-up is slow to become useful (it serves nothing until the index is warm) and CPU-triggered scaling reacts long after the load that triggered it. Scale-down also leaves orphaned PVCs behind, since StatefulSet volumes are not reclaimed. Size it deliberately instead.

thanos.storegateway.persistenceobject
{
  "enabled": true,
  "size": "10Gi"
}
Index-header cache for Store Gateway. One of two Thanos components that keep a PVC — the Compactor is the other, and Receive alone is `emptyDir`-backed. The reason here is startup cost rather than durability or size: the on-disk binary index-headers are a read-through cache of the bucket and lose nothing when discarded, but rebuilding them means re-downloading from object storage, and the component serves no historical query until that finishes. On a large bucket that is minutes of degraded long-range dashboards on every restart.

Kept on at every size deliberately, rather than flipping to ephemeral for small installs. A volume that appears and disappears with the profile is a surprise during an incident, and the AZ-pinning argument that drives Receive to emptyDir is much weaker here: Store Gateway is a read-only replica set with no quorum, so a pod stuck Pending in a dead zone costs read capacity rather than blocking writes.

The kind profile is the one exception, where CI would rather not wait on volume provisioning for a cache.

thanos.storegateway.resourcesobject
{
  "requests": {
    "cpu": "500m",
    "memory": "3Gi"
  }
}
Resource requests for Store Gateway. Medium sizing. There is a floor here that is easy to trip over. Thanos defaults `--chunk-pool-size=2GB` and `--index-cache-size=250MB`, and the subchart passes neither, so a stock Store Gateway wants ~2.5Gi before it serves a single query. A request below that is an OOM, not a small install — the `thanos-small` profile shrinks the pools through `extraArgs` instead.
thanos.storegateway.topologySpreadConstraintslist
[
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/component": "storegateway",
        "app.kubernetes.io/name": "thanos"
      }
    },
    "matchLabelKeys": [
      "controller-revision-hash"
    ],
    "maxSkew": 1,
    "topologyKey": "topology.kubernetes.io/zone",
    "whenUnsatisfiable": "ScheduleAnyway"
  },
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/component": "storegateway",
        "app.kubernetes.io/name": "thanos"
      }
    },
    "matchLabelKeys": [
      "controller-revision-hash"
    ],
    "maxSkew": 1,
    "topologyKey": "kubernetes.io/hostname",
    "whenUnsatisfiable": "ScheduleAnyway"
  }
]
Topology spread for Store Gateway: soft on both axes. **Soft** (`ScheduleAnyway`) where Receive is hard, and the difference is the point rather than an inconsistency. Two things make it the right trade here:
  1. Nothing depends on quorum. Store Gateway is a read-only replica set over object storage, so an unbalanced placement costs read capacity during a zone loss rather than correctness. Receive’s hard rule protects write quorum; there is no equivalent to protect here.
  2. It is the one Thanos component that still keeps a PVC, and a hard zone rule against a zonal volume can deadlock rather than merely wait. With volumeBindingMode: WaitForFirstConsumer — the default for the zonal CSI classes and what you want — the volume follows the pod and the two agree. With Immediate, the PVC’s zone is chosen before scheduling and a hard rule pointing elsewhere leaves the pod Pending forever. Soft degrades instead of deadlocking on a StorageClass we do not control.
thanos.storegateway.extraArgslist
[
  "--store.limits.request-series=5000000",
  "--store.limits.request-samples=200000000"
]
Extra CLI arguments for Store Gateway. Read-path protection. Thanos has no per-tenant write limit to set; the realistic failure is one query fanning out across the bucket and taking this component down, which takes historical reads with it. These two caps make that query fail instead.

The subchart ships this empty, so unlike receive.extraArgs there is nothing to lose by overriding it — but it is still a list, so restate both entries when adding a third.

thanos.compactorobjectenabled with default retention policiesThanos Compactor configuration. Compactor provides block compaction and downsampling.
thanos.compactor.retentionobject
{
  "resolution1h": "365d",
  "resolution5m": "90d",
  "resolutionRaw": "30d"
}
Retention policies for Thanos Compactor downsampled data The medium row of the retention table in Production Best Practices.

Raw retention has a floor set by the downsampling thresholds: Thanos produces 5m downsamples only from blocks spanning 40h or more, and 1h downsamples only from blocks spanning 10d or more. Below those the tier is never created at all and long-range queries silently fall back to reading raw blocks — slower and more expensive, which is the opposite of the intent. 30d clears both comfortably.

thanos.compactor.extraArgslist
[
  "--log.level=info",
  "--log.format=logfmt",
  "--consistency-delay=30m",
  "--compact.enable-vertical-compaction",
  "--deduplication.replica-label=receive_replica"
]
Extra CLI arguments for the Compactor. **This is a list, and Helm overwrites lists rather than merging them.** The first three entries restate the subchart's defaults, which are also Thanos's own, so an override that drops them changes nothing. An override that drops either flag below brings back the failure it prevents.

--compact.enable-vertical-compaction lets the Compactor merge overlapping blocks. Without it, any overlap halts compaction, retention and downsampling until someone repairs the bucket by hand. Receive produces overlaps on every restart: the block it flushes on shutdown ends at its last sample, and after restart it accepts replicated and retried samples a few seconds older than that. Overlaps of 5–40s were observed after ordinary rollouts.

--deduplication.replica-label=receive_replica drops the per-pod label from block grouping, so the replication factor’s copies compact into one block. The bucket then holds one copy of each series rather than RF. The default merge function deduplicates only identical samples, which is what Receive replication writes. Thanos Query still deduplicates the recent window on receive_replica, so reads are unchanged. A deduplicated group holds every series, so above replicaCount == replicationFactor a compaction group is larger than one replica’s was.

Both flags are irreversible for the blocks they touch. The replica label alone also turns on vertical compaction (Thanos cmd/thanos/compact.go); the explicit flag keeps overlaps from halting compaction if an override drops the replica label.

thanos.compactor.vpaobject
{
  "enabled": false
}
VerticalPodAutoscaler for the Compactor. Off for the same reasons as `receive.vpa`, minus the block-loss argument — the Compactor's local volume is an idempotent working copy, so an eviction costs a restarted compaction rather than data. It still overwrites the requests below, and still behaves differently depending on whether the VPA CRD happens to be installed.
thanos.compactor.resourcesobject
{
  "requests": {
    "cpu": 1,
    "memory": "2Gi"
  }
}
Resource requests for the Compactor. Medium sizing. The Compactor is a **singleton by necessity** — concurrent compactors against one block set corrupt data — so it is the one component here with no horizontal escape hatch. Vertical is the only lever, which makes compaction falling behind something to alert on rather than discover.

Downsampling is the memory-heavy phase, and --compact.concurrency multiplies it; leave that at 1 unless compaction is provably behind.

No ephemeral-storage here: the Compactor keeps a PersistentVolume, so its scratch does not draw on the node’s budget. See persistence for why.

thanos.compactor.persistenceobject
{
  "enabled": true,
  "size": "50Gi"
}
Working directory for the Compactor: **a PersistentVolume**, unlike Receive. Scratch space for the block group under compaction. Nothing here is authoritative — the bucket is — and a Compactor killed mid-compaction simply redoes the work, so there is no *durability* argument for a volume. The argument is size.

Compaction works on whole block groups, not on the recent window, so the requirement scales with days of data rather than hours. At the medium envelope a 2d group is roughly six 8h blocks at ~2.4Gi each, and the Compactor needs the sources and the output at once — on the order of 30Gi. A GKE node with a 47Gi boot disk offers about 18.8Gi of allocatable ephemeral storage, so that never fits: the pod stays Pending on Insufficient ephemeral-storage, and the cluster-autoscaler cannot help because no node of that shape would fit either.

This is the reverse of the call made for Receive, and deliberately so. Receive holds hours of small, replicated data and is the better trade on emptyDir; the Compactor holds days of it and is not. The AZ-pinning cost is real — a zone outage stops compaction, and while compaction is stopped retention is not enforced and the bucket grows — but “compaction pauses during a zone outage” is a far better failure than “compaction never runs because the pod cannot be scheduled”.

thanos.queryFrontendobject
{
  "autoscaling": {
    "enabled": true,
    "maxReplicas": 5,
    "minReplicas": 2,
    "targetCPUUtilizationPercentage": 80
  },
  "enabled": false,
  "resources": {
    "requests": {
      "cpu": "250m",
      "memory": "512Mi"
    }
  },
  "topologySpreadConstraints": [
    {
      "labelSelector": {
        "matchLabels": {
          "app.kubernetes.io/component": "query-frontend",
          "app.kubernetes.io/name": "thanos"
        }
      },
      "matchLabelKeys": [
        "pod-template-hash"
      ],
      "maxSkew": 1,
      "topologyKey": "topology.kubernetes.io/zone",
      "whenUnsatisfiable": "ScheduleAnyway"
    },
    {
      "labelSelector": {
        "matchLabels": {
          "app.kubernetes.io/component": "query-frontend",
          "app.kubernetes.io/name": "thanos"
        }
      },
      "matchLabelKeys": [
        "pod-template-hash"
      ],
      "maxSkew": 1,
      "topologyKey": "kubernetes.io/hostname",
      "whenUnsatisfiable": "ScheduleAnyway"
    }
  ]
}
Thanos Query Frontend configuration. Query Frontend provides query parallelization and result caching. Only required for production.

Note that enabling this is not sufficient on its own: point connections.datasources.thanos.url at the query-frontend Service too, or reads keep going straight to Query and the cache is never consulted. A validator warns when the two disagree.

thanos.queryFrontend.autoscalingobject
{
  "enabled": true,
  "maxReplicas": 5,
  "minReplicas": 2,
  "targetCPUUtilizationPercentage": 80
}
Horizontal autoscaling for Query Frontend. Stateless like Query, so the same reasoning applies. Inert until `queryFrontend.enabled` is true.
thanos.queryFrontend.topologySpreadConstraintslist
[
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/component": "query-frontend",
        "app.kubernetes.io/name": "thanos"
      }
    },
    "matchLabelKeys": [
      "pod-template-hash"
    ],
    "maxSkew": 1,
    "topologyKey": "topology.kubernetes.io/zone",
    "whenUnsatisfiable": "ScheduleAnyway"
  },
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/component": "query-frontend",
        "app.kubernetes.io/name": "thanos"
      }
    },
    "matchLabelKeys": [
      "pod-template-hash"
    ],
    "maxSkew": 1,
    "topologyKey": "kubernetes.io/hostname",
    "whenUnsatisfiable": "ScheduleAnyway"
  }
]
Topology spread for Query Frontend: soft on both axes, same reasoning as Query. Inert until `enabled` is true.
thanos.queryFrontend.resourcesobject
{
  "requests": {
    "cpu": "250m",
    "memory": "512Mi"
  }
}
Resource requests for Query Frontend. Inert until it is enabled, which `thanos-large` does. Sized for splitting and result caching rather than query execution — the work happens in Query behind it.
thanos.rulerobject
{
  "alertmanagers": {
    "config": "alertmanagers:\n  - static_configs:\n      - dns+alertmanager-headless.{{ .Release.Namespace }}.svc.{{ include \"mzmon.clusterDomain\" . }}:9093\n    scheme: http\n    api_version: v2\n    timeout: 10s\n"
  },
  "autoImportPrometheusRules": {
    "enabled": true,
    "labelSelector": {
      "mzmon.materialize.cloud/flavor!": "logql"
    },
    "sidecar": {
      "image": {
        "registry": "docker.io",
        "repository": "alpine/kubectl",
        "tag": "1.36.4"
      },
      "resources": {
        "requests": {
          "cpu": "25m",
          "memory": "64Mi"
        }
      }
    }
  },
  "enabled": true,
  "extraArgs": [
    "--label=cluster=\"$(CLUSTER_NAME)\""
  ],
  "extraEnv": [
    {
      "name": "CLUSTER_NAME",
      "valueFrom": {
        "configMapKeyRef": {
          "key": "CLUSTER_NAME",
          "name": "ruler-env"
        }
      }
    }
  ],
  "extraVolumeMounts": [
    {
      "mountPath": "/etc/mzmon/tls",
      "name": "mzmon-tls",
      "readOnly": true
    }
  ],
  "extraVolumes": [
    {
      "name": "mzmon-tls",
      "secret": {
        "optional": true,
        "secretName": "mzmon-thanos-tls"
      }
    }
  ],
  "persistence": {
    "enabled": false
  },
  "podSecurityContext": {
    "runAsNonRoot": true,
    "seccompProfile": {
      "type": "RuntimeDefault"
    }
  },
  "query": {
    "urls": [
      "http://thanos-query:9090"
    ]
  },
  "remoteWrite": {
    "enabled": true,
    "secretName": "thanos-ruler-remote-write-v2"
  },
  "replicaCount": 2,
  "resources": {
    "requests": {
      "cpu": "100m",
      "memory": "256Mi"
    }
  },
  "rules": {
    "example-alerts.yaml": "groups: []\n"
  },
  "topologySpreadConstraints": [
    {
      "labelSelector": {
        "matchLabels": {
          "app.kubernetes.io/component": "ruler",
          "app.kubernetes.io/name": "thanos"
        }
      },
      "matchLabelKeys": [
        "controller-revision-hash"
      ],
      "maxSkew": 1,
      "topologyKey": "topology.kubernetes.io/zone",
      "whenUnsatisfiable": "ScheduleAnyway"
    },
    {
      "labelSelector": {
        "matchLabels": {
          "app.kubernetes.io/component": "ruler",
          "app.kubernetes.io/name": "thanos"
        }
      },
      "matchLabelKeys": [
        "controller-revision-hash"
      ],
      "maxSkew": 1,
      "topologyKey": "kubernetes.io/hostname",
      "whenUnsatisfiable": "ScheduleAnyway"
    }
  ]
}
Thanos Ruler configuration. Ruler provides alerting and recording rules evaluation.

This is the switch that makes PromQL alerting exist. The chart installs the Prometheus Operator CRDs but runs no Prometheus and no operator, and Alloy — the one component that consumes ServiceMonitor and PodMonitor — is a collector with no rule evaluator. So the single consumer of a PrometheusRule in this stack is the Ruler’s import sidecar, and until the Ruler is on, a PrometheusRule renders, applies, passes every check, and does nothing.

Evaluation is a PromQL query over the network to Thanos Query. There is no local TSDB to fall back on, which means an outage in the query path stops alert evaluation, and stopped evaluation looks exactly like nothing being wrong. Size Query with that in mind.

thanos.ruler.replicaCountint2Replica count. Both replicas evaluate every rule; Alertmanager deduplicates the resulting notifications, and `--alert.label-drop` strips the `ruler_replica` label that would otherwise make them distinct alerts.
thanos.ruler.queryobject
{
  "urls": [
    "http://thanos-query:9090"
  ]
}
Query endpoints the Ruler evaluates against.

Query, not Query Frontend, deliberately. The frontend splits and caches, which is right for a dashboard and wrong for an evaluator: a cached range served to a rule is an alert firing — or failing to fire — on data up to a cache TTL stale, with nothing in either component saying so. Grafana’s datasource follows the frontend when it is enabled; this does not.

The name is bare rather than fully qualified because Ruler and Query are components of one subchart and always land in one namespace, so it stays correct under split-namespace.

thanos.ruler.alertmanagersobject
{
  "config": "alertmanagers:\n  - static_configs:\n      - dns+alertmanager-headless.{{ .Release.Namespace }}.svc.{{ include \"mzmon.clusterDomain\" . }}:9093\n    scheme: http\n    api_version: v2\n    timeout: 10s\n"
}
Alertmanager routing, in Thanos's own format.

tpl-evaluated by the subchart, so .Release.* resolves. alertmanager-headless follows alertmanager.fullnameOverride, and the render warns when the two disagree. split-namespace overrides this because it moves Alertmanager out of the release namespace.

dns+ against the headless Service, so the Ruler resolves one target per ready replica and sends every alert to each of them. Alertmanager gossips silences and the notification log, not alerts, and a replica that never received an alert cannot notify for it when its peer is down. The Ruler re-resolves every --alertmanagers.sd-dns-interval (30s).

thanos.ruler.rulesobject
{
  "example-alerts.yaml": "groups: []\n"
}
Inline rule files, keyed by filename.

This empties the subchart’s example-alerts.yaml, which ships an ExampleAlwaysFiring rule built on vector(1). Left in place it notifies on every evaluation, forever, through whatever receiver an operator has configured.

It is emptied rather than removed because the subchart’s JSON Schema lists example-alerts.yaml as a required property, so neither rules: {} (which would not clear a subchart default anyway) nor example-alerts.yaml: null renders — the second fails schema validation before any template runs. A rule file declaring no groups is the only spelling that both satisfies the schema and evaluates nothing, until the schema is fixed upstream.

The render fails if the example rule comes back.

The chart’s own rules do not arrive here. They arrive as PrometheusRule resources, through the import sidecar below.

thanos.ruler.autoImportPrometheusRulesobject
{
  "enabled": true,
  "labelSelector": {
    "mzmon.materialize.cloud/flavor!": "logql"
  },
  "sidecar": {
    "image": {
      "registry": "docker.io",
      "repository": "alpine/kubectl",
      "tag": "1.36.4"
    },
    "resources": {
      "requests": {
        "cpu": "25m",
        "memory": "64Mi"
      }
    }
  }
}
Import `PrometheusRule` resources from the cluster into the Ruler.

A kubectl sidecar lists PrometheusRule resources every 60s, writes each one’s .spec into the Ruler’s rule directory, and POSTs /-/reload when the set changes. No Prometheus Operator controller is involved.

labelSelector imports every PrometheusRule in the cluster except the LogQL ones, including any belonging to a co-resident kube-prometheus-stack. That is kept deliberately: it is what makes a customer’s own PrometheusRule work with no chart configuration. Set a narrower selector here if this cluster runs another rule owner whose alerts should not reach this Alertmanager.

The exclusion is load-bearing. The chart’s log-derived rules are PrometheusRule resources too, labelled mzmon.materialize.cloud/flavor: logql, and the Thanos ruler reads every file it imports as PromQL; one LogQL file fails the reload, and the ruler keeps evaluating whatever it loaded before. The subchart joins this map into key=value pairs, so a key ending in ! renders as key!=value, which also keeps a PrometheusRule carrying no flavor label. A selector replacing this one keeps the exclusion, or selects mzmon.materialize.cloud/flavor: promql; the render fails otherwise.

The image is pinned rather than left on the upstream latest, so a default install does not track a floating tag. It is the only Docker Hub image the Thanos subchart pulls, which is why the registry profiles each carry a line for it.

It cannot be the distroless registry.k8s.io/kubectl this chart uses for the cleanup hook, tempting as sharing one image is. The sidecar’s entrypoint is /bin/sh /scripts/import.sh, and the script shells out to curl to POST /-/reload. alpine/kubectl is Alpine plus curl plus kubectl; the distroless build has neither a shell nor curl, so it crash-loops immediately. The same applies to any hardened kubectl image — see the note in profiles/registry/.

The minor tracks the cleanup hook’s kubectl, and the same version-skew advice applies: keep it within one minor of your API server.

thanos.ruler.remoteWriteobject
{
  "enabled": true,
  "secretName": "thanos-ruler-remote-write-v2"
}
Run stateless: remote-write rule results, keep no TSDB.

Three reasons stateless is the right mode here, in ascending order of how much they matter. It removes a stateful workload. It puts rule results on the same path as every other series in this stack, rather than making the Ruler a second writer into object storage with its own compaction interaction. And it is what makes alert state forwardable: a Ruler shipping blocks puts ALERTS in Thanos and out of reach of every gateway destination except Thanos.

With enabled, the subchart passes --remote-write.config-file and drops --objstore.config-file, so the Ruler runs no block shipper. The configuration targets the alloy-gateway’s metrics listener, and the chart renders it as a Secret (templates/thanos-ruler-remote-write.yaml) rather than through config, because the gateway’s address follows umbrella values that the subchart’s tpl cannot read.

secretName is one of two Secrets the chart renders, -tls for when pipeline.metrics.gateway.server.tls is on, which profiles/mtls.values.yaml selects and the render enforces. The Ruler reads the file once at startup, so the names carry the mode and a revision: switching Secrets changes the pod template, which is what rolls it.

Writing elsewhere means naming another Secret here, or setting secretName: "" with createSecret: true and the configuration in config. The render’s gateway checks then no longer apply.

thanos.ruler.extraArgslist
[
  "--label=cluster=\"$(CLUSTER_NAME)\""
]
Extra Ruler flags.

--label=cluster is load-bearing. It is an external label, which the Ruler adds to every alert it sends that does not already carry cluster, and to every sample it writes. $(CLUSTER_NAME) is expanded by Kubernetes from extraEnv below, which reads the ruler-env ConfigMap the chart renders from clusterName. It has to survive an override of this list; the render warns when it goes missing.

thanos.ruler.extraEnvlist
[
  {
    "name": "CLUSTER_NAME",
    "valueFrom": {
      "configMapKeyRef": {
        "key": "CLUSTER_NAME",
        "name": "ruler-env"
      }
    }
  }
]
The cluster name, for `--label=cluster` in `extraArgs`.
thanos.ruler.extraVolumeslist
[
  {
    "name": "mzmon-tls",
    "secret": {
      "optional": true,
      "secretName": "mzmon-thanos-tls"
    }
  }
]
The Thanos certificate, for the Ruler's TLS clients.

mzmon-thanos-tls is the certificate the chart issues for Thanos when certificates is on. The Ruler presents it to Alertmanager when alerting.server.tls is on and to the gateway when its metrics listener serves TLS, and trusts its ca.crt. Mounted unconditionally and optionally, so the same values work before issuance, during it and after. This is a list: restate the entry when adding one.

thanos.ruler.persistenceobject
{
  "enabled": false
}
No PVC. The data directory holds the remote-write WAL and nothing else.

The Loki ruler keeps a volume for the same WAL, on the argument that buffering derived samples through a metric-store outage is worth a disk. That argument applies here too and is deliberately not taken: the recording rules this Ruler evaluates are the ext:* layer, a few low-cardinality series, and the WAL holds them through a store outage on the pod’s own volume. What a PVC would add is keeping them across a pod rescheduled during that outage, which loses minutes of history rather than any alert, and a 10Gi PVC per replica is not a default worth shipping for that. Revisit if a recording rule’s history becomes something an alert or a report depends on.

thanos.ruler.resourcesobject
{
  "requests": {
    "cpu": "100m",
    "memory": "256Mi"
  }
}
Resource requests for the Ruler. Evaluation is a fan-out of PromQL to Query, so the work happens there; the Ruler holds rule state and a WAL.
thanos.ruler.topologySpreadConstraintslist
[
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/component": "ruler",
        "app.kubernetes.io/name": "thanos"
      }
    },
    "matchLabelKeys": [
      "controller-revision-hash"
    ],
    "maxSkew": 1,
    "topologyKey": "topology.kubernetes.io/zone",
    "whenUnsatisfiable": "ScheduleAnyway"
  },
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/component": "ruler",
        "app.kubernetes.io/name": "thanos"
      }
    },
    "matchLabelKeys": [
      "controller-revision-hash"
    ],
    "maxSkew": 1,
    "topologyKey": "kubernetes.io/hostname",
    "whenUnsatisfiable": "ScheduleAnyway"
  }
]
Topology spread for the Ruler: soft on both axes.

Soft rather than DoNotSchedule, unlike Receive: both replicas evaluate every rule and Alertmanager deduplicates, so co-locating them costs a simultaneous loss rather than a quorum. A Ruler that cannot schedule is a Ruler that is not evaluating, which is the worse outcome.

controller-revision-hash because this is a StatefulSet; Deployments in this file use pod-template-hash.

Grafana Operator#

Bundled Grafana-operator for managing Grafana instances.

Upstream references:

KeyTypeDefaultDescription
grafana-operator.priorityClassNamestring"monitoring-scalable"Scheduling priority. See the Priority classes section. Scalable tier: while the operator is down, dashboard and datasource reconciliation stops, but Grafana keeps serving what it already has.
grafana-operator.fullnameOverridestring"grafana-operator"Standard Helm full-name override. We use a static name for deterministic relations.
grafana-operator.namespaceOverridestringnilNamespace override.
grafana-operator.imageobject
{
  "registry": "ghcr.io",
  "repository": "grafana/grafana-operator",
  "tag": "v5.25.0"
}
Operator image, pinned here rather than inherited from the subchart's `appVersion`.

Unlike the other pins, this one is not meant to move on its own. The operator’s CRDs are vendored from the subchart (make grafana-operator-crds), and upstream releases the chart and the image under one version. Renovate groups this tag with the subchart in Chart.yaml so the two land in one PR. The pin is what lets Renovate see the image, and lets the registry profiles repoint registry and repository while keeping the tag.

grafana-operator.crdsobject
{
  "immutable": true
}
CRD behavior. The Grafana Operator CRDs are owned by the `materialize-monitoring-crds` chart, which vendors a deflated copy of them. The operator chart offers no way to skip its own CRDs outright — `immutable` only chooses where they come from — so keep this `true`: that keeps them out of this chart's release manifest and leaves them install-only, which `helm install --skip-crds` drops entirely. Setting it `false` makes this chart template and upgrade the CRDs itself, fighting the CRDs chart for ownership.
grafana-operator.podSecurityContextobject
{
  "runAsNonRoot": true,
  "seccompProfile": {
    "type": "RuntimeDefault"
  }
}
Pod security context. The subchart ships an empty one. Its container context is already read-only, non-root and drops every capability; this adds `RuntimeDefault` seccomp, and keeps the whole pod non-root. No UID is set, so the image's own non-root user stands, which is what a registry profile swap needs.
grafana-operator.resourcesobject
{
  "limits": {
    "memory": "512Mi"
  },
  "requests": {
    "cpu": "10m",
    "memory": "64Mi"
  }
}
Resource requests and limits for the operator. The subchart sets none, which makes the operator BestEffort. Under node pressure the kubelet evicts it ahead of every pod still within its requests, whatever `priorityClassName` says. The operator idles at a few millicores and a few tens of MiB. The memory limit leaves room for many more dashboards than this chart ships; raise it if a cluster declares far more of its own.

Grafana Instance#

Bundled Grafana for dashboard rendering.

Upstream reference:

The defaults here are the safe shape, not the production shape, and the two differ in three places you have to close yourself:

  1. State. Grafana stores users, service accounts and tokens, annotations, dashboard versions and permissions, preferences, and alert-rule state in a database of its own. The default is SQLite on an emptyDir, so all of it is lost on every restart, upgrade, and reschedule. The dashboards this chart installs come back on their own (grafana-operator re-pushes them every resyncPeriod); anything a human created does not. Apply the grafana-postgres profile, or grafana-pvc if you have no database.
  2. Reachability. The Service is ClusterIP, so the only access path is kubectl port-forward. Set ingress or service.type — and read connections.grafana.allowPublicAccess before you do.
  3. Authentication. The only account is the generated admin. Configure an identity provider under grafana.ini before exposing it to anyone but yourself.

Everything under grafana.ini is passed through to Grafana’s own config file verbatim, so any section Grafana understands can be set here — see the [auth.generic_oauth] example on that key.

See Production Best Practices > Grafana for the full checklist.

KeyTypeDefaultDescription
grafana.fullnameOverridestring"grafana"Standard Helm full-name override. We use a static name for deterministic relations. `connections.grafana.mode: bundled` derives the URL it hands grafana-operator from this, so a release-name-derived name would leave the operator dialing a host that does not resolve.
grafana.namespaceOverridestringnilNamespace override.
grafana.priorityClassNamestring"monitoring-scalable"Scheduling priority. See the Priority classes section.
grafana.imageobject
{
  "pullPolicy": "IfNotPresent",
  "registry": "docker.io",
  "repository": "grafana/grafana",
  "tag": "13.3.0"
}
Grafana server image. Pinned here rather than left to track the subchart's `appVersion`, so Renovate can bump the server on its own cadence instead of only when a new chart release happens to carry one.

A hardened rebuild is a drop-in swap: point registry/repository at it and keep the tag. Two published options track upstream Grafana versions and keep its entrypoint and layout, so a values change is the whole change:

profiles/registry/ does this for the whole stack rather than for Grafana alone, and carries the pull-secret wiring that goes with it.

Both ship no shell and no package manager, which is the point — and which also means plugins cannot work, since that installs at container start. Bake plugins into the image instead. See the note on plugins below.

Bitnami also publishes a hardened Grafana, but its images are built for Bitnami’s own charts — Bitnami entrypoints, /opt/bitnami paths, UID 1001 — against an upstream subchart here, so it is a port rather than a swap. No profile ships for it.

grafana.replicasint1Number of Grafana replicas. More than one requires a shared database (`grafana.ini.database`) — each replica otherwise carries its own divergent SQLite file, which is a correctness bug rather than availability. A render-time check enforces this.
grafana.autoscalingobject
{
  "enabled": false,
  "maxReplicas": 5,
  "minReplicas": 1,
  "targetCPU": "60",
  "targetMemory": ""
}
Horizontal autoscaling for the Grafana Deployment. Off by default because it is meaningless on the default SQLite backend: every replica would carry its own database. With `grafana.ini.database` pointed at PostgreSQL, Grafana is stateless and scales horizontally, so enable it then — `minReplicas` becomes the effective replica count and `replicas` stops being rendered at all.

targetCPU is a percentage of the CPU request, so resources.requests.cpu has to be set or the HPA has no denominator and never scales. A render-time check warns when it is missing.

grafana.podDisruptionBudgetobject
{
  "maxUnavailable": 1
}
PodDisruptionBudget for Grafana. `maxUnavailable` rather than `minAvailable`, matching the convention the Loki and Thanos blocks use: it scales with the replica count, and on a single replica `minAvailable: 1` would permit no voluntary eviction at all and hang node drains.
grafana.resourcesobject
{
  "limits": {
    "memory": "1Gi"
  },
  "requests": {
    "cpu": "100m",
    "memory": "256Mi"
  }
}
Resource requests and limits for the Grafana container. Requests are what the scheduler and the HPA both read — without a CPU request, `autoscaling.targetCPU` has no denominator and the HPA never scales. No CPU limit: query rendering is bursty and throttling it makes the UI feel broken. Raise the memory limit if you run many large dashboards or heavy plugins.
grafana.containerSecurityContextobject
{
  "readOnlyRootFilesystem": true
}
Container security context for Grafana. The subchart's own already drops every capability, forbids privilege escalation, sets `RuntimeDefault` seccomp and makes the root filesystem read-only. The chart restates the read-only root because the validators and the production checklist depend on it.

A read-only root filesystem needs two things, and Grafana breaks quietly without either:

NeedsSupplied byWithout it
A writable /tmpThe subchart, which mounts an emptyDir named tmp thereEvery backend plugin fails to start, Prometheus and Loki included. Each one listens on a Unix socket under /tmp. The pod stays Ready and every panel fails with Unable to find datasource plugin.
grafana.ini.plugins.preinstall_auto_update: falsegrafana.iniGrafana unloads a bundled datasource plugin to update it, fails to delete the old copy from the image, and leaves the datasource unloaded until the next restart.

Everything else Grafana writes lands on the storage and search volumes the subchart mounts at /var/lib/grafana and /var/lib/grafana-search. The one exception is GF_AWS_PROFILES: the image’s entrypoint writes those profiles to /usr/share/grafana/.aws, which then needs a mount of its own through grafana.extraEmptyDirMounts.

A second mount at /tmp, or a second volume named tmp, makes the API server reject the Deployment. Neither grafana.extraEmptyDirMounts nor grafana.extraVolumeMounts may add one.

Validators fail the render on the plugin setting, a missing .aws mount and a duplicate /tmp, and warn when this is turned off.

grafana.persistenceobject
{
  "accessModes": [
    "ReadWriteOnce"
  ],
  "enabled": false,
  "size": "10Gi"
}
Persistent volume for Grafana's SQLite database and plugin directory. Off by default. Turning it on survives restarts but caps you at one replica (SQLite tolerates one writer) and, on a `ReadWriteOnce` volume, requires `deploymentStrategy.type: Recreate` — a rolling update otherwise deadlocks with the new pod waiting for a volume the old pod has not released. Both are enforced at render time. The `grafana-pvc` profile is the assembled version.

Prefer PostgreSQL (grafana-postgres) wherever a database is available: it is the only option that lifts both constraints.

grafana.initChownData.imageobject
{
  "registry": "docker.io",
  "repository": "library/busybox",
  "tag": "1.38.0"
}
Image for the chown init container, pinned here rather than left at the subchart's default. It renders only alongside `persistence.enabled` (`profiles/grafana-pvc`). Pinned so Renovate sees it: Renovate reads this file and never the vendored subchart's `values.yaml`.
grafana.deploymentStrategyobject
{
  "type": "RollingUpdate"
}
Deployment update strategy. Must be `Recreate` alongside a `ReadWriteOnce` PersistentVolume; see `persistence`.
grafana.serviceobject
{
  "annotations": {},
  "enabled": true,
  "loadBalancerSourceRanges": [],
  "port": 80,
  "targetPort": 3000,
  "type": "ClusterIP"
}
Service exposing Grafana. `ClusterIP` is reachable only from inside the cluster, which is the safe default and the reason a fresh install needs `kubectl port-forward`. Prefer `ingress` over a `LoadBalancer` Service: Grafana is ordinary HTTP that wants host-based routing and a certificate, which is what Ingress is for.

A LoadBalancer Service with no loadBalancerSourceRanges is an error at render time unless connections.grafana.allowPublicAccess is set.

grafana.ingressobject
{
  "annotations": {},
  "enabled": false,
  "hosts": null,
  "ingressClassName": "",
  "labels": {},
  "path": "/",
  "pathType": "Prefix",
  "tls": null
}
Ingress for Grafana. The preferred way to make Grafana reachable. Terminate TLS here: Grafana authenticates with a session cookie and, without TLS, that cookie and the admin password cross the network in the clear. A render-time check warns when an Ingress carries no `tls` block.

Set grafana.ini.server.root_url to the same URL — Grafana builds share links, alert notification links, and OAuth redirect URIs from it, and all three break silently when it disagrees with the Ingress host.

grafana."grafana.ini"object
{
  "analytics": {
    "check_for_updates": false,
    "reporting_enabled": false
  },
  "date_formats": {
    "default_timezone": "UTC"
  },
  "plugins": {
    "preinstall_auto_update": false
  }
}
Grafana's own configuration file, as YAML. Rendered verbatim into `grafana.ini`, so any section Grafana understands can be set here: authentication, SMTP, feature toggles, unified alerting, user provisioning. The chart sets only the handful below and merges anything you add over them.

Secrets must NOT be inlined — this block renders into a ConfigMap, so a value here lands in plaintext in the release manifest, in helm get values, and in whatever Git repo holds your values file. Use Grafana’s own expansion instead: $__file{/path} for a mounted Secret (see extraSecretMounts) or $__env{VAR} for one injected with envValueFrom. The subchart’s assertNoLeakedSecrets check fails the render if you forget.

OIDC single sign-on, as an example — this is the shape, not a default:

grafana:
  grafana.ini:
    server:
      root_url: https://grafana.example.com
    auth:
      oauth_auto_login: true
    auth.generic_oauth:
      enabled: true
      name: SSO
      allow_sign_up: true
      client_id: <client-id>
      client_secret: $__file{/etc/secrets/grafana-oidc/client-secret}
      scopes: openid profile email groups
      auth_url: https://idp.example.com/oauth2/v1/authorize
      token_url: https://idp.example.com/oauth2/v1/token
      api_url: https://idp.example.com/oauth2/v1/userinfo
      allowed_domains: example.com
      # Map an IdP group claim onto a Grafana role, so membership is the
      # provisioning mechanism and nobody is added by hand.
      role_attribute_path: contains(groups[*], 'sre') && 'Admin' || 'Viewer'
  extraSecretMounts:
    - name: grafana-oidc
      secretName: grafana-oidc
      mountPath: /etc/secrets/grafana-oidc
      readOnly: true

Every provider, the role-mapping rules, and the break-glass path are in Dashboards > Grafana > Authentication.

grafana.networkPolicyobject
{
  "allowExternal": true,
  "egress": {
    "enabled": false
  },
  "enabled": true,
  "explicitIpBlocks": [],
  "explicitNamespacesSelector": {},
  "ingress": true
}
NetworkPolicy for Grafana.

The subchart’s policy is rigid: it emits exactly one ingress rule, on service.targetPort, and either allows every source or a fixed set of them. There is no way to add a second port through values. That constrains what this can say, so it is worth being precise about what it does say.

Ingress: 3000 from anywhere, everything else closed. allowExternal stays true because Grafana is the one component in this stack a human is meant to reach, and the ways they reach it are all things a podSelector cannot see — an ingress-controller pod in another namespace, a cloud load balancer’s source IPs, kubectl port-forward, which arrives from the API server. Turning it off is worthwhile once you know your path in; explicitNamespacesSelector takes the ingress controller’s namespace and explicitIpBlocks takes a load balancer’s health-check and client ranges. profiles/grafana-ingress.values.yaml is where that belongs.

What it closes is the rest of the pod: 6060 (pprof) and 9094 (the unified-alerting gossip port) stop accepting connections. pprof should not be reachable in production, so that one is the point. 9094 is not — it matters as soon as you run multiple replicas and set grafana.ini.unified_alerting.ha_peers, which needs the pods to reach each other on it, and this template cannot express a second port.

You do not have to turn this off to get HA alerting. The chart renders the missing rule itself, as networkPolicies.grafanaGossip, and it appears exactly when ha_peers is set. Policies are additive, so the two sit side by side. Turning that off while HA alerting is configured is what the render warns about; turning this one off takes the supplement with it, since a lone gossip policy would isolate the pods and leave only 9094 open.

Egress is left unrestricted (egress.enabled: false renders a policy with no Egress policy type at all, rather than an empty allow). Grafana dials the things it is configured to dial: Loki and Thanos in-namespace, a PostgreSQL state database wherever it lives, an OIDC provider on the internet, and grafana.com on every start, for the plugins it preinstalls (see grafana.ini.plugins) and any listed in plugins. The first two are knowable here and the rest are not, and a partial allowlist would fail as a dashboard that renders empty with no error — the worst failure mode this stack has.

grafana.networkPolicy.ingressbooltrueEmit the ingress rule. False renders a policy with no rules at all, which — with egress off too — is an empty spec, not a deny.
grafana.networkPolicy.allowExternalbooltrueAccept from any source. See the note above before turning this off.
grafana.networkPolicy.explicitNamespacesSelectorobject
{}
Namespaces allowed in when `allowExternal` is false. The ingress controller's namespace goes here.
grafana.networkPolicy.explicitIpBlockslist
[]
CIDRs allowed in when `allowExternal` is false. Load-balancer client and health-check ranges go here.
grafana.networkPolicy.egress.enabledboolfalseLeave off. See the note above: a partial egress allowlist for Grafana fails as empty panels rather than as an error.
grafana.pluginslist
[]
Grafana plugins to install at container start. Each entry is downloaded from grafana.com when the pod starts, so this needs egress to `grafana.com` on 443 and adds that download to every start and every restart — a startup dependency on a third-party service.

For anything load-bearing, bake plugins into the image instead and point image at it. That is also the only option on a hardened base image, which ships no shell for grafana cli to run in. Pin an exact plugin version either way (name@version); an unpinned plugin silently changes underneath a pinned Grafana.

grafana.imageRendererobject
{
  "enabled": false
}
Grafana Image Renderer, for server-side PNG rendering of panels. Deliberately off, and it should stay off in production: the renderer is a headless Chromium that fetches arbitrary URLs on Grafana's behalf, which makes it both a large attack surface and a server-side request forgery pivot into the cluster network. A render-time check warns when it is enabled.
grafana.testFrameworkobject
{
  "enabled": false
}
Helm test hooks shipped by the subchart. Off: it pulls a `bats` image this chart does not otherwise use or pin, and the e2e suite covers what it asserts.
grafana.assertNoLeakedSecretsbooltrueFail the render when a secret is inlined into `grafana.ini`. Leave this on. It is the guard that keeps database and OAuth credentials out of the ConfigMap; see the note on `grafana.ini`.
grafana.serviceMonitorobject
{
  "enabled": true
}
Scrape Grafana's own metrics.

Alertmanager#

Bundled Alertmanager: the one notification surface for both rulers.

This block shapes the workload — replicas, gossip, storage, scheduling and hardening. Where alerts go is configured under alerting, which the chart renders into this Alertmanager’s configuration. See Alert Architecture for how the two fit together.

Upstream reference:

KeyTypeDefaultDescription
alertmanager.fullnameOverridestring"alertmanager"Resource names, pinned rather than derived from the release name.

Matches Loki, Thanos, Grafana and Alloy, whose names are pinned the same way. Everything that addresses Alertmanager — both rulers, the Grafana datasource, the certificate SANs — can then name it literally, and the subchart’s rule of collapsing <release>-alertmanager to <release> when the release name contains alertmanager no longer matters. Changing it renames the StatefulSet, whose volumes do not follow; see Upgrading.

alertmanager.imageobject
{
  "repository": "quay.io/prometheus/alertmanager",
  "tag": "v0.34.2"
}
Alertmanager image, pinned here rather than inherited from the subchart's `appVersion`.

So Renovate bumps Alertmanager on its own cadence, with Alertmanager’s own release notes, instead of only when a chart release happens to carry a new appVersion. Grafana is pinned the same way. This chart’s repository carries the registry host; there is no separate registry key. The profiles under profiles/registry/ repoint repository and keep this tag.

alertmanager.priorityClassNamestring"monitoring-scalable"Scheduling priority. See the Priority classes section.
alertmanager.replicaCountint2Replica count. Two replicas gossiping is the default, not a hardening step.

A single notifier is lost to an ordinary node drain, holds the only copy of every silence, and cannot report its own absence. Two replicas gossip silences and the notification log to each other and deduplicate, so each notification goes out once and either replica can send it.

Two rather than three. Gossip tolerates a partition by notifying from both sides rather than by electing a leader, so a third replica adds little availability, and it adds a third copy of every notification whenever gossip is broken.

Above one, the subchart passes --cluster.peer for every ordinal and opens the mesh ports on the headless Service. Both rulers address that headless Service, not the load-balanced one, so that every replica receives every alert: gossip replicates silences and the notification log, not alerts. See thanos.ruler.alertmanagers and loki.loki.rulerConfig.

alertmanager.extraArgsobject
{
  "cluster.label": "materialize-monitoring",
  "config.file": "/etc/alertmanager/config/alertmanager.yml",
  "data.retention": "120h",
  "web.route-prefix": "/"
}
Flags passed to Alertmanager as `--=`.
FlagWhy it is set
config.filePoints at the configuration the chart renders from alerting. Load-bearing: without it Alertmanager starts on the image’s built-in example, whose one receiver posts to localhost.
cluster.labelStamped on every gossip message, so a peer from another Alertmanager cluster that inherits a recycled pod IP is rejected rather than merged.
data.retentionHow long silences and notification-log entries are kept after they expire. The upstream default, set here so that it is visible.
web.route-prefixKeeps every endpoint at /. Unset, Alertmanager takes its route prefix from the path of baseURL, which would move the API the rulers post to, the probes, the reload endpoint and the datasource.

Changing cluster.label on a running cluster splits the peers until the rollout finishes, and every notification in that window goes out twice. Set it once. This is a map, so adding a flag here keeps the others.

alertmanager.configobject
{
  "enabled": false
}
Use the configuration the chart renders, not the subchart's.

The subchart renders config into a ConfigMap with toYaml and no tpl, so nothing in it can be computed. The severity presets, the receiver classes and the render-time checks all have to be, so the chart renders the configuration itself from alerting, as the alertmanager-config Secret, and mounts it below. Turning this back on fails the render: the subchart would mount its ConfigMap over /etc/alertmanager, the directory that holds the chart’s.

alertmanager.extraVolumeslist
[
  {
    "name": "mzmon-config",
    "secret": {
      "secretName": "alertmanager-config"
    }
  },
  {
    "name": "mzmon-amtool",
    "secret": {
      "items": [
        {
          "key": "amtool.yml",
          "path": "config.yml"
        }
      ],
      "secretName": "alertmanager-config"
    }
  }
]
Mount the configuration the chart renders. Load-bearing, with `extraArgs.config.file`.

The second volume puts an amtool configuration at /etc/amtool/config.yml, where amtool looks by default, rendered from the same Secret. It carries the URL and, when alerting.server.tls is on, the CA that issued certFile (the ca.crt beside it), so kubectl exec ... -- amtool silence query works unchanged in every TLS phase. It presents a client certificate only when clientAuth requires one. Mounted as a directory rather than with subPath, so it follows changes to the Secret.

alertmanager.extraSecretMountslist
[
  {
    "mountPath": "/etc/alertmanager/secrets/alertmanager-receivers",
    "name": "alertmanager-receivers",
    "optional": true,
    "readOnly": true,
    "secretName": "alertmanager-receivers"
  },
  {
    "mountPath": "/etc/mzmon/tls",
    "name": "mzmon-tls",
    "optional": true,
    "readOnly": true,
    "secretName": "mzmon-alertmanager-tls"
  }
]
Secrets holding receiver credentials, for `*_file` fields to read.

Receiver credentials are referenced by path, never inlined; see alerting.assertNoInlineCredentials. One Secret is mounted by default: create alertmanager-receivers in the Alertmanager namespace, and reference each of its keys as /etc/alertmanager/secrets/alertmanager-receivers/<key>.

It is optional, so the pods start before it exists. Alertmanager reads a *_file credential each time it sends, so creating or rotating the Secret needs no restart. The kubelet refreshes the mounted copy within about a minute.

To mount further Secrets, add entries under /etc/alertmanager/secrets/. This is a list, and Helm replaces lists, so restate the default entries when adding one. The render fails when a receiver names a *_file path that no mount covers.

The second entry is Alertmanager’s own certificate, which the chart issues as mzmon-alertmanager-tls when certificates is on. Mounted unconditionally and optionally, so the same values work before issuance, during it and after, and turning alerting.server.tls on changes no volume.

alertmanager.configmapReloadobject
{
  "enabled": true,
  "extraArgs": {
    "watched-dir": "/etc/alertmanager/config"
  },
  "extraVolumeMounts": [
    {
      "mountPath": "/etc/alertmanager/config",
      "name": "mzmon-config",
      "readOnly": true
    }
  ],
  "image": {
    "repository": "quay.io/prometheus-operator/prometheus-config-reloader",
    "tag": "v0.94.2"
  },
  "resources": {
    "limits": {
      "memory": "64Mi"
    },
    "requests": {
      "cpu": "5m",
      "memory": "16Mi"
    }
  },
  "securityContext": {
    "allowPrivilegeEscalation": false,
    "capabilities": {
      "drop": [
        "ALL"
      ]
    },
    "readOnlyRootFilesystem": true,
    "runAsGroup": 65534,
    "runAsNonRoot": true,
    "runAsUser": 65534
  }
}
Reload the configuration in place when `alerting` changes.

The subchart rolls its pods on a checksum of the ConfigMap it renders, and it no longer renders one, so without this sidecar a routing change would reach the running pods only at their next restart. The sidecar watches the mounted directory and POSTs /-/reload. A configuration Alertmanager rejects leaves the previous one running, and alertmanager_config_last_reload_successful drops to 0.

The image is pinned here, like Alertmanager’s, so Renovate tracks it directly. Every profile under profiles/registry/ repoints it.

alertmanager.podAnnotationsobject
{
  "kubectl.kubernetes.io/default-container": "alertmanager"
}
Pod annotations. The subchart puts the reloader sidecar first, so this makes `kubectl logs` and `kubectl exec` land in Alertmanager rather than in the sidecar.
alertmanager.podSecurityContextobject
{
  "fsGroup": 65534,
  "fsGroupChangePolicy": "OnRootMismatch",
  "runAsGroup": 65534,
  "runAsNonRoot": true,
  "runAsUser": 65534,
  "seccompProfile": {
    "type": "RuntimeDefault"
  }
}
Pod security context. `fsGroupChangePolicy: OnRootMismatch` skips the recursive `chown` of the volume on every start once its ownership is already right.
alertmanager.securityContextobject
{
  "allowPrivilegeEscalation": false,
  "capabilities": {
    "drop": [
      "ALL"
    ]
  },
  "readOnlyRootFilesystem": true,
  "runAsGroup": 65534,
  "runAsNonRoot": true,
  "runAsUser": 65534
}
Container security context.

A read-only root filesystem is safe: Alertmanager writes only under --storage.path, which is the volume, and reads its configuration and credentials from mounts. The subchart ships readOnlyRootFilesystem commented out, and a validator warns when it is turned off.

alertmanager.automountServiceAccountTokenboolfalseMount no ServiceAccount token. Alertmanager reads nothing from the Kubernetes API. Cloud identity does not need it: the EKS webhook behind IRSA projects a token volume of its own, whatever this says.
alertmanager.serviceAccount.annotationsobject
{}
Annotations on Alertmanager's ServiceAccount (`alertmanager`): the IRSA role for `sns_configs`.

Amazon SNS is the one Alertmanager integration that authenticates with the pod’s own cloud identity; everything else, including email through SES or Azure Communication Services, takes a credential from a Secret. On EKS, eks.amazonaws.com/role-arn names a role with sns:Publish on the topic, trusting system:serviceaccount:<namespace>:alertmanager. EKS Pod Identity needs no annotation. See Alert Channels.

alertmanager.livenessProbeobject
{
  "httpGet": {
    "path": "/-/healthy",
    "port": "http"
  }
}
Liveness on `/-/healthy` rather than the subchart's `/`, which serves the UI.
alertmanager.readinessProbeobject
{
  "httpGet": {
    "path": "/-/ready",
    "port": "http"
  }
}
Readiness on `/-/ready`.

A starting replica joins the mesh and pulls its peer’s silences and notification log before it serves, so a Ready replica already holds the replicated state. A StatefulSet rollout replaces one replica at a time and waits on this, which is what keeps a rolling update from losing silences.

alertmanager.resourcesobject
{
  "limits": {
    "memory": "256Mi"
  },
  "requests": {
    "cpu": "10m",
    "memory": "64Mi"
  }
}
Resource requests and limits.

Alertmanager holds active alerts, silences and the notification log in memory, and a quiet install idles near 15Mi. Two replicas carry the alert volume a Materialize deployment produces, so there is no sizing profile. No CPU limit, following the rest of the chart. The memory limit is the one setting that can take both replicas down together during an alert storm, so it is set well above the request; raise it for very large alert or silence populations rather than lowering it.

alertmanager.podDisruptionBudgetobject
{
  "maxUnavailable": 1
}
PodDisruptionBudget, `maxUnavailable: 1`, matching Loki, Thanos and Grafana. A drain evicts one replica at a time, and the other keeps notifying.
alertmanager.topologySpreadConstraintslist
[
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/name": "alertmanager"
      }
    },
    "maxSkew": 1,
    "nodeTaintsPolicy": "Honor",
    "topologyKey": "topology.kubernetes.io/zone",
    "whenUnsatisfiable": "DoNotSchedule"
  },
  {
    "labelSelector": {
      "matchLabels": {
        "app.kubernetes.io/name": "alertmanager"
      }
    },
    "maxSkew": 1,
    "topologyKey": "kubernetes.io/hostname",
    "whenUnsatisfiable": "ScheduleAnyway"
  }
]
Topology spread: hard across zones, soft across hosts.

Two replicas in one zone are one notifier as far as a zone outage is concerned, and a zone outage is the failure a second replica exists to survive. So the zone rule is DoNotSchedule, like Thanos Receive’s. A replica that cannot satisfy it goes Pending, which is the signal the cluster autoscaler reads to add a node in the deficient zone.

Each replica’s volume is zonal, and that is compatible with a hard rule only under volumeBindingMode: WaitForFirstConsumer, the default for every managed-cloud CSI class. The volume is then created where the scheduler placed the pod, so each replica lands in its own zone and stays there. Under Immediate binding both volumes can be provisioned in one zone before scheduling, and the second replica stays Pending while the first keeps notifying.

Two deliberate differences from Receive:

  • No minDomains. A single-zone cluster schedules both replicas, so only a cluster whose nodes carry no zone label needs no-zone-spread (or min_zones = 0 on Terraform).
  • No matchLabelKeys: [controller-revision-hash]. It would let a new replica ignore an old-revision peer when it is first scheduled — which is what an upgrade from one replica to two does — and land in that peer’s zone. When the peer is then replaced, its volume pins it to the same zone, the skew is 2, and it stays Pending permanently. A StatefulSet replaces a pod before scheduling its successor, so the rollout deadlock matchLabelKeys prevents on a Deployment does not arise here.

The selector names app.kubernetes.io/name alone because the subchart renders this through toYaml, not tpl, so the release name is not available. Spread is namespace-scoped, and this chart assumes one Alertmanager per namespace.

alertmanager.persistence.enabledbooltrueKeep a volume per replica for silences and the notification log.

Gossip replicates both between the replicas, so the volume is what survives losing both at once — an uninstall and reinstall, a namespace-wide restart, a cluster-wide outage. Silences are human-created and exist nowhere else. The notification log is what stops the first evaluation after such an event from re-sending every notification that already went out.

This and size are immutable after install. They render into the StatefulSet’s volumeClaimTemplates, which Kubernetes refuses to change, so an upgrade that changes either fails. Changing them means deleting the StatefulSet with --cascade=orphan first.

alertmanager.persistence.sizestring"4Gi"Volume size. Sized by cloud disk minimums, not by Alertmanager, which needs kilobytes. The subchart default of 50Mi is below the 4 GiB floor on GCP Hyperdisk and Azure managed disks, so provisioning fails there.
alertmanager.serviceMonitorobject
{
  "enabled": true,
  "relabelings": [
    {
      "action": "drop",
      "regex": ".*-headless",
      "sourceLabels": [
        "__meta_kubernetes_service_name"
      ]
    }
  ]
}
Scrape Alertmanager's own metrics through the Alloy gateway.

Delivery failures (alertmanager_notifications_failed_total), mesh membership (alertmanager_cluster_members) and reload health (alertmanager_config_last_reload_successful) are observable only with this on.

Both Services the subchart renders carry identical labels, so the monitor selects the headless one too and would scrape every replica twice. The relabeling drops the headless Service’s targets.

alertmanager.baseURLstring""Alertmanager's own external URL (`--web.external-url`). Leave it empty unless Alertmanager itself is exposed.

Alertmanager’s built-in notification templates link to its alert list (<baseURL>/#/alerts) from it. The chart’s templates (alerting.defaultTemplates) link to Grafana instead whenever Grafana’s URL is known, and fall back to the alert list and a pre-filled silence (<baseURL>/#/silences/new) only when it is not and this is set.

Do not point it at Grafana. Grafana serves neither path, so every link lands on Grafana’s home page. Set alerting.grafanaURL, or Grafana’s root_url, for links into Grafana.

Unset, Alertmanager’s own links name the pod’s address, which is unreachable from outside the cluster, and the chart’s templates leave them out. Set this when Alertmanager is exposed, behind authentication, to the address operators reach it at. A path is fine: extraArgs.web.route-prefix keeps the endpoints at /, so an ingress serving a sub-path strips the prefix before forwarding.

Kube State Metrics#

kube-state-metrics for Kubernetes resource-state metrics consumed by Materialize-adjacent dashboards.

Upstream reference:

KeyTypeDefaultDescription
kube-state-metrics.priorityClassNamestring"monitoring-scalable"Scheduling priority. See the Priority classes section.
kube-state-metrics.imageobject
{
  "registry": "registry.k8s.io",
  "repository": "kube-state-metrics/kube-state-metrics",
  "tag": "v2.20.0"
}
kube-state-metrics image, pinned here rather than inherited from the subchart's `appVersion`. So Renovate bumps it on its own cadence, with its own release notes. The subchart prefixes `v` to `appVersion`; a pinned tag is used verbatim. `global.imageRegistry` outranks `registry` in this chart.
kube-state-metrics.metricLabelsAllowlistlist
[
  "nodes=[karpenter.sh/nodepool,eks.amazonaws.com/nodegroup,cloud.google.com/gke-nodepool,kubernetes.azure.com/agentpool,node.kubernetes.io/instance-type,topology.kubernetes.io/zone]",
  "pods=[cluster.environmentd.materialize.cloud/cluster-id,cluster.environmentd.materialize.cloud/replica-id,cluster.environmentd.materialize.cloud/size,materialize.cloud/environment-id]"
]
Node and pod labels copied onto `kube_node_labels` and `kube_pod_labels`.

kube-state-metrics publishes each label as label_<key>, with every character outside [a-zA-Z0-9_] mapped to _. It publishes neither family until an entry names a label for that resource.

EntryLabelsRead by
nodesThe node pool, by whichever label the provisioner sets (Karpenter, an EKS managed node group, a GKE node pool or an AKS agent pool), the instance type and the zoneThe Infrastructure Autoscaling dashboard, which groups nodes by all three
podsA replica pod’s Materialize cluster id, replica id and replica size, and the environment idAny join from a kube_pod_* family to a Materialize cluster or replica, such as cost or requests per replica

The pods labels arrive under the canonical names cluster_id, replica_id, replica_size and environment_id rather than as label_*. prometheus.monitor.http.metricRelabelings renames them. Only clusterd pods carry the first three. No released Materialize operator sets materialize.cloud/environment-id yet, so environment_id is absent until one does.

Cardinality. Each family has one series per object, whatever the list names, because the labels are carried on that one series. A broader list costs index size and series churn rather than series count: a label whose value changes on a running object ends one series and starts another. pods=[*] copies every label on every pod in the cluster, including the per-rollout hashes and whatever a workload owner chose to set, so name labels individually instead.

Extending the list. Helm replaces a list rather than merging it, so an override restates both entries. Leaving out nodes empties every per-pool panel with nothing else failing. The Terraform module’s kube_state_metrics_pod_labels appends to the pods entry without restating it. An ownership label for cost-center allocation is the expected addition, and arrives as label_<key>.

kube-state-metrics.customResourceStateobject
{
  "config": {
    "kind": "CustomResourceStateMetrics",
    "spec": {
      "resources": [
        {
          "groupVersionKind": {
            "group": "materialize.cloud",
            "kind": "Materialize",
            "version": "v1alpha1"
          },
          "labelsFromPath": {
            "environment_id": [
              "spec",
              "environmentId"
            ],
            "environment_name": [
              "metadata",
              "name"
            ],
            "environment_namespace": [
              "metadata",
              "namespace"
            ]
          },
          "metricNamePrefix": "kube_mz",
          "metrics": [
            {
              "each": {
                "info": {
                  "labelsFromPath": {
                    "environment_name": [
                      "metadata",
                      "name"
                    ]
                  }
                },
                "type": "Info"
              },
              "help": "1 for each Materialize environment, naming it.",
              "name": "environment_info"
            },
            {
              "each": {
                "gauge": {
                  "labelsFromPath": {
                    "generation": [
                      "activeGeneration"
                    ]
                  },
                  "path": [
                    "status"
                  ],
                  "valueFrom": [
                    "activeGeneration"
                  ]
                },
                "type": "Gauge"
              },
              "help": "The deployment generation the Materialize environment serves.",
              "name": "environment_active_generation"
            }
          ]
        }
      ]
    }
  },
  "enabled": true
}
Environment facts that kube-state-metrics reads off each `Materialize` resource.

kube-state-metrics publishes one series of each per materializes.materialize.cloud resource:

MetricValueLabels
kube_mz_environment_info1environment_id (spec.environmentId), environment_name (metadata.name) and environment_namespace (metadata.namespace)
kube_mz_environment_active_generationThe deployment generation the environment serves, status.activeGenerationThe same, and generation, the same number as a label

The active generation is the one the operator last promoted. During a rollout it is the older generation, until the new one has caught up and been promoted. A rollout that never promotes leaves the older one serving, so the highest generation in the environment’s pod names doesn’t stand in for it. generation lets a query join the series to the generation in a pod’s name.

The environment’s namespace is environment_namespace. namespace and pod on these series name the kube-state-metrics pod, as on every node-level kube_* series. kube-state-metrics also adds customresource_group, customresource_kind and customresource_version. Read kube_mz_environment_info through group by (cluster, environment_id, environment_name, environment_namespace), which drops those labels.

Pinned to v1alpha1. kube-state-metrics takes one entry per resource, and a wildcard version lists every resource once for each version the CRD serves. Every Materialize operator serves v1alpha1, and where the v1 CRD is installed too, listing v1alpha1 returns the resources created as v1 as well.

kube-state-metrics watches the cluster’s CRDs, so it publishes nothing until the Materialize CRD exists, and picks it up when it does. rbac.extraRules grants it list and watch on materializes.

kube-state-metrics.rbac.extraRuleslist
[
  {
    "apiGroups": [
      "materialize.cloud"
    ],
    "resources": [
      "materializes"
    ],
    "verbs": [
      "list",
      "watch"
    ]
  }
]
Extra ClusterRole rules for kube-state-metrics: list and watch on `Materialize` resources, for `customResourceState`.
kube-state-metrics.networkPolicyobject
{
  "egress": [
    {
      "ports": [
        {
          "port": 53,
          "protocol": "UDP"
        },
        {
          "port": 53,
          "protocol": "TCP"
        }
      ],
      "to": [
        {
          "namespaceSelector": {}
        }
      ]
    },
    {
      "ports": [
        {
          "port": 443,
          "protocol": "TCP"
        },
        {
          "port": 6443,
          "protocol": "TCP"
        }
      ]
    }
  ],
  "enabled": true,
  "ingress": [
    {
      "from": [
        {
          "podSelector": {
            "matchLabels": {
              "app.kubernetes.io/name": "alloy-gateway"
            }
          }
        }
      ],
      "ports": [
        {
          "port": 8080,
          "protocol": "TCP"
        },
        {
          "port": 8081,
          "protocol": "TCP"
        }
      ]
    }
  ]
}
NetworkPolicy for kube-state-metrics.

Ingress is narrowed to the Alloy gateway, which is what scrapes it (prometheus.operator.servicemonitors in the gateway pipeline) — the same shape as node-exporter.networkPolicy, and unlike that one it is actually enforced, because these pods are not host-networked.

8081 is listed alongside 8080 even though nothing serves it at the default selfMonitor.enabled: false. A closed port that nothing dials costs nothing; the alternative is that turning selfMonitor on produces a scrape that fails silently against a policy nobody thought to update.

Egress is restricted, and this is the one component where that is safe to do, because its destination set is closed: DNS, and the API server. It reads nothing else — the whole workload is a watch on the API and an exposition endpoint. Deny egress outright (as node-exporter does) and it never lists a single object; the pod stays Ready and every kube_* series simply disappears.

443/6443 to 0.0.0.0/0 rather than something tighter because the API server’s address is the one thing a chart cannot derive. In-cluster it is the kubernetes.default.svc ClusterIP, which most CNIs evaluate against the endpoint behind it — and on a managed control plane that endpoint is outside the cluster network entirely. Narrow the CIDR to your control plane’s where you know it.

kube-state-metrics.prometheus.monitor.httpobject
{
  "honorLabels": true,
  "metricRelabelings": [
    {
      "regex": "(.+)",
      "sourceLabels": [
        "label_cluster_environmentd_materialize_cloud_cluster_id"
      ],
      "targetLabel": "cluster_id"
    },
    {
      "regex": "(.+)",
      "sourceLabels": [
        "label_cluster_environmentd_materialize_cloud_replica_id"
      ],
      "targetLabel": "replica_id"
    },
    {
      "regex": "(.+)",
      "sourceLabels": [
        "label_cluster_environmentd_materialize_cloud_size"
      ],
      "targetLabel": "replica_size"
    },
    {
      "regex": "(.+)",
      "sourceLabels": [
        "label_materialize_cloud_environment_id"
      ],
      "targetLabel": "environment_id"
    },
    {
      "action": "labeldrop",
      "regex": "label_cluster_environmentd_materialize_cloud_(cluster_id|replica_id|size)|label_materialize_cloud_environment_id"
    }
  ]
}
Keep kube-state-metrics' own labels when they collide with the scrape's.

Load-bearing, not a preference.

Every kube_* series carries its own namespace, pod and container labels describing the object it reports on — that is the entire point of the exporter. Those names collide with the target labels of the scrape, which describe the kube-state-metrics pod itself. Without this, Prometheus resolves the collision by renaming the exporter’s labels to exported_namespace / exported_pod and writing the target’s identity into namespace / pod.

The result is not missing data, which is why it went unnoticed: every series arrives, up is 1 and scrape_samples_scraped is healthy, but every series reads namespace="<the monitoring namespace>". kube_pod_info collapses from one series per pod to a single identity, and any query written as kube_*{namespace=…} — which is all of ours — silently matches nothing.

Both endpoints, so turning selfMonitor on does not reintroduce it on the half nobody was looking at. The subchart defaults both to false.

Asserted by the e2e suite: kube_state::labels_are_honored.

kube-state-metrics.prometheus.monitor.http.metricRelabelingslist
[
  {
    "regex": "(.+)",
    "sourceLabels": [
      "label_cluster_environmentd_materialize_cloud_cluster_id"
    ],
    "targetLabel": "cluster_id"
  },
  {
    "regex": "(.+)",
    "sourceLabels": [
      "label_cluster_environmentd_materialize_cloud_replica_id"
    ],
    "targetLabel": "replica_id"
  },
  {
    "regex": "(.+)",
    "sourceLabels": [
      "label_cluster_environmentd_materialize_cloud_size"
    ],
    "targetLabel": "replica_size"
  },
  {
    "regex": "(.+)",
    "sourceLabels": [
      "label_materialize_cloud_environment_id"
    ],
    "targetLabel": "environment_id"
  },
  {
    "action": "labeldrop",
    "regex": "label_cluster_environmentd_materialize_cloud_(cluster_id|replica_id|size)|label_materialize_cloud_environment_id"
  }
]
Rename the Materialize pod labels on `kube_pod_labels` to their canonical names.
Pod labelPublished as
cluster.environmentd.materialize.cloud/cluster-idcluster_id
cluster.environmentd.materialize.cloud/replica-idreplica_id
cluster.environmentd.materialize.cloud/sizereplica_size
materialize.cloud/environment-idenvironment_id

cluster_id and replica_id are the names mz_cluster_info and mz_replica_info already carry, so a join through kube_pod_labels needs no label_replace. Without the rename the keys would be label_cluster_environmentd_materialize_cloud_cluster_id and its siblings. The label_* form is dropped rather than kept beside the canonical one, because nothing read it before these labels were allowlisted.

Each copy matches (.+), so it fires only where the pod label is present. With the default (.*) it would also fire where the label is absent and write an empty value, which removes the canonical label from a series that carries its own: kube_mz_environment_info would lose its environment_id. Restate these rules when overriding the list, since Helm replaces it.

Node Exporter#

node-exporter for hardware and OS metrics from every node.

Upstream reference:

Run as its own DaemonSet rather than folded into the Alloy agent’s prometheus.exporter.unix, so its resource envelope stays known and separate: a metrics regression cannot then starve log collection out of a shared limit. See the Terraform modules design doc for the full argument.

KeyTypeDefaultDescription
node-exporter.fullnameOverridestring"node-exporter"Standard Helm full-name override. We use a static name for deterministic relations.
node-exporter.namespaceOverridestringnilNamespace override.
node-exporter.priorityClassNamestring"monitoring-critical"Scheduling priority. See the Priority classes section. Critical: a node with no node-exporter is a node nothing else reports on, and the gap does not backfill.
node-exporter.imageobject
{
  "distroless": true,
  "registry": "quay.io",
  "repository": "prometheus/node-exporter",
  "tag": "v1.12.1"
}
Container image. Split into registry / repository / tag so Renovate's `helm-values` manager can bump the exporter independently of the subchart version — the two move on different cadences and a chart release is not a prerequisite for a node_exporter CVE fix. `distroless: true` appends `-distroless` to the tag, giving an image with no shell and no package manager. node_exporter reads the host's `/proc`, `/sys` and `/` — a shell in that container is a materially better foothold than a shell in most others, and nothing in our configuration needs one.
node-exporter.resourcesobject
{
  "limits": {
    "memory": "64Mi"
  },
  "requests": {
    "cpu": "10m",
    "memory": "64Mi"
  }
}
Resource envelope, matching what Materialize runs in production today.

Deliberately no CPU limit. This is a DaemonSet that does nothing between scrapes and then bursts for the length of one collection; a CPU limit turns that burst into CFS throttling, which shows up as scrape timeouts on exactly the loaded nodes where you most need the sample. The 10m request is what it averages, not what a scrape costs.

Memory is request == limit, which is the opposite call for the opposite reason: node_exporter’s working set is flat and bounded (~20-30 MiB for this collector set), so a limit equal to the request costs nothing and makes the per-node footprint a number the cluster autoscaler can actually plan with. This lands the pod in Burstable QoS. Guaranteed would require the CPU limit we just argued against; being evicted a little earlier under node pressure is the better trade, and monitoring-critical covers the ordering.

node-exporter.updateStrategyobject
{
  "rollingUpdate": {
    "maxUnavailable": "10%"
  }
}
Rolling update budget for the DaemonSet. Upstream's `maxUnavailable: 1` walks a large fleet one node at a time, which makes an image bump take hours. Metrics are gap-tolerant and the pod restarts in seconds, so a percentage keeps rollout time flat as the fleet grows.
node-exporter.securityContextobject
{
  "seccompProfile": {
    "type": "RuntimeDefault"
  }
}
Pod security context, merged over the subchart's non-root UID 65534.
node-exporter.containerSecurityContextobject
{
  "allowPrivilegeEscalation": false,
  "capabilities": {
    "drop": [
      "ALL"
    ]
  }
}
Container security context, merged over the subchart's read-only root. The exporter runs as UID 65534 and needs no capability to read the host's `/proc`, `/sys` and `/`.
node-exporter.kubeRBACProxyobject
{
  "enabled": false
}
kube-rbac-proxy sidecar, which would put the exporter behind TokenReview/SubjectAccessReview over HTTPS.

Off deliberately. It is a second container on every node, and DaemonSet overhead is the constraint we are managing here — the sidecar’s own request is comparable to node_exporter’s, so it roughly doubles the per-node cost of node metrics to protect an endpoint that exposes no secrets.

What it would buy, for when that trade changes: authenticated scrapes, and (via kubeRBACProxy.tls + tlsSecret) TLS with client-certificate auth, so only a scraper holding a cert signed by the configured CA can read /metrics. That is the shape an in-cluster-mTLS story would use, and it is parked, not rejected — see the cert-manager mTLS item on the roadmap.

Note that with hostNetwork: true the exporter listens on the node’s own interfaces, so today the real perimeter is the node firewall / security group, not anything Kubernetes enforces. Do not treat port 9100 as private just because a NetworkPolicy exists.

node-exporter.networkPolicyobject
{
  "enabled": true,
  "ingress": [
    {
      "from": [
        {
          "podSelector": {
            "matchLabels": {
              "app.kubernetes.io/name": "alloy-gateway"
            }
          }
        }
      ],
      "ports": [
        {
          "port": 9100,
          "protocol": "TCP"
        }
      ]
    }
  ]
}
NetworkPolicy for the exporter.

Ingress is narrowed to the Alloy gateway, which is what actually scrapes it (prometheus.operator.servicemonitors in the gateway pipeline). Egress is denied outright — node_exporter never initiates a connection.

Read the hostNetwork caveat before relying on this. The pods run with hostNetwork: true (required: netdev, netclass, netstat, sockstat and conntrack all read namespaced files under /proc/net, so in a pod netns they would report the pod’s traffic, not the node’s). Most CNIs — Cilium and Calico among them — do not apply pod NetworkPolicy to host-networked pods, because that traffic belongs to the node identity. So this policy is a correct declaration of intent and is enforced where the CNI supports it, but it is not the control that keeps port 9100 private. The node’s firewall is.

The default from selects by pod label within the release namespace. Running the gateway in a different namespace (see profiles/split-namespace.values.yaml) requires replacing this list with one that adds a namespaceSelector.

node-exporter.prometheus.monitor.enabledbooltrueEmit a ServiceMonitor. This is how collection actually happens: the Alloy gateway discovers ServiceMonitors cluster-wide and scrapes the endpoints behind them. Without this the DaemonSet runs and is never read.
node-exporter.extraArgslist
[
  "--collector.disable-defaults",
  "--collector.cpu",
  "--collector.cpufreq",
  "--collector.loadavg",
  "--collector.schedstat",
  "--collector.stat",
  "--collector.pressure",
  "--collector.meminfo",
  "--collector.vmstat",
  "--collector.swap",
  "--collector.vmstat.fields=^(oom_kill|pgpg|pswp|pg.*fault|pgscan|pgsteal|workingset).*$",
  "--collector.diskstats",
  "--collector.filesystem",
  "--collector.filesystem.mount-points-exclude=^/(dev|proc|run/credentials/.+|sys|var/lib/docker/.+|var/lib/containers/storage/.+|var/lib/kubelet/(pods|plugins)/.+|run/containerd/.+)($|/)",
  "--collector.nvme",
  "--collector.xfs",
  "--collector.netdev",
  "--collector.netdev.device-exclude=^(lo|lxc.*|veth.*|cali.*|azv.*|docker.*|br-.*|nodelocal.*|kube-ipvs.*|dummy.*)$",
  "--collector.netclass",
  "--collector.netclass.ignored-devices=^(lo|lxc.*|veth.*|cali.*|azv.*|docker.*|br-.*|nodelocal.*|kube-ipvs.*|dummy.*)$",
  "--collector.netstat",
  "--collector.sockstat",
  "--collector.softnet",
  "--collector.udp_queues",
  "--collector.conntrack",
  "--collector.arp",
  "--collector.os",
  "--collector.uname",
  "--collector.time",
  "--collector.timex",
  "--collector.filefd",
  "--collector.entropy",
  "--collector.hwmon",
  "--collector.selinux",
  "--collector.kernel_hung"
]
Extra arguments to node_exporter, which is where the collector allowlist lives.

--collector.disable-defaults turns everything off; each --collector.<name> then turns one back on. An allowlist rather than a denylist because the default set drifts with each node_exporter release — a new default-on collector should not silently appear in our cardinality budget.

Every name here must exist on Linux. An unknown --collector.* flag is a parse error and node_exporter exits, so a typo or a platform-specific collector crash-loops the DaemonSet on every node at once. In particular boottime is a Darwin/BSD collector; on Linux node_boot_time_seconds comes from stat, which is why stat is in the list and boottime is not.

Chosen for a network- and memory-hungry Rust database that swaps on purpose:

  • Swap, explicitly. swap (per-device swap, off by default upstream) and meminfo (SwapTotal/SwapFree) give the level; vmstat gives the rate (pswpin/pswpout), which is the half that distinguishes healthy deliberate swapping from thrash. pressure (PSI) is the leading indicator for both.
  • Memory reclaim. The vmstat field filter is widened below to admit pgscan_*/pgsteal_*, which separate background reclaim (kswapd, fine) from direct reclaim (direct, an allocating thread is stalled) — the difference between swap working and swap hurting.
  • Network. netdev/netclass for the NICs, netstat for TCP retransmits, sockstat for socket and TCP-memory pressure, softnet for packets the kernel dropped before userspace saw them, udp_queues for DNS, and conntrack for table exhaustion behind NAT gateways.
  • CPU. cpu (including mode="steal"), plus schedstat for run-queue wait — “threads were runnable and did not run”, which is the contention a tightly-optimized dataflow engine feels first — and stat for context switches and blocked processes.
  • Correctness-adjacent. timex for clock sync; a database with timestamp semantics on cloud VMs wants to know when NTP has given up.

Left off on purpose, with the reason, since each is one line to re-add:

  • slabinfo — kernel slab accounting. Costs ~1.5k series per node (every slab cache × several metrics) and needs a root init container to chmod /proc/slabinfo (permissionInitContainer.fixes.slabinfo). meminfo already answers “how much kernel memory” via Slab/SReclaimable/ SUnreclaim; slabinfo only adds which cache, which is forensics you turn on during an investigation.
  • ethtool — on EC2 this is where the ENA allowance counters live (bw_*_allowance_exceeded, pps_allowance_exceeded, conntrack_allowance_exceeded): instance-level network throttling that is invisible in every other metric. Genuinely valuable on AWS and worth enabling there, paired with --collector.ethtool.device-include=^(eth|ens|enp) — held back only because the stat set is driver-specific (gVNIC and Azure expose different counters) and unverified on our images.
  • interrupts, softirqs — per-CPU × per-IRQ, so thousands of series on a large instance. softnet covers the actionable part.
  • tcpstat — parses every socket in /proc/net/tcp, which is most expensive on exactly the busiest nodes. sockstat gives the aggregate cheaply.
  • processes — walks all of /proc each scrape. stat already reports running and blocked counts.
  • edac, rapl, thermal_zone, dmi — hypervisors do not expose ECC, powercap or thermals to guests, and instance type comes from node labels. Worth revisiting on bare metal.
  • meminfo_numa, zoneinfo, buddyinfo — NUMA and fragmentation detail for very large instances; opt in when investigating.
  • ipvs, nfs, nfsd, bonding, mdadm, zfs, btrfs, bcache — none apply to a cloud node running ext4/xfs on network block storage with Cilium replacing kube-proxy. ipvs matters if you run kube-proxy in IPVS mode; nfs if you mount EFS/Filestore.
  • sysctl — no default set, but --collector.sysctl.include=vm.swappiness, vm.overcommit_memory,vm.overcommit_ratio is a cheap guard against AMI drift changing swap behaviour underneath a swap-dependent workload.
  • textfile — the node-local extension point, but it needs a hostPath mount this chart does not create.

Metrics Server#

metrics-server for pod and node resource usage; only needed when the cluster does not already ship one.

Upstream reference:

KeyTypeDefaultDescription
metrics-server.replicasint1Number of replicas for metrics-server.
metrics-server.imageobject
{
  "repository": "registry.k8s.io/metrics-server/metrics-server",
  "tag": "v0.9.1"
}
metrics-server image, pinned here rather than inherited from the subchart's `appVersion`. So Renovate bumps it on its own cadence, with its own release notes. Like Alertmanager, this chart's `repository` carries the registry host, and the subchart prefixes `v` to `appVersion` where a pinned tag is used verbatim.
metrics-server.podSecurityContextobject
{
  "runAsNonRoot": true,
  "seccompProfile": {
    "type": "RuntimeDefault"
  }
}
Pod security context. The subchart ships an empty one beside an already locked-down container context; this makes the pod-level half say the same thing.

PostgreSQL Exporter#

postgres_exporter, watching the PostgreSQL databases this deployment depends on from the inside.

Upstream reference:

Off by default, and in no tag group: it watches databases you name, with credentials you create. Turn it on with tags.postgres-exporter: true (or the postgres-exporter.enabled circuit breaker) and list each database under targets.

One Deployment serves every target. The alloy-gateway scrapes it once per target, on /probe, and the exporter opens a connection to that database for the length of the scrape. So the replica count is not a multiplier on the connections each database sees, and adding a target never restarts the metric pipeline.

The credentials are a Secret you create, named by config.existingSecret.name (default postgres-exporter) in the release namespace, holding one key, postgres_exporter.yml. It carries one auth_modules entry per target, named after the target:

auth_modules:
  consensus:
    type: userpass
    userpass:
      username: mzmon_exporter
      password: <password>
    options:
      sslmode: require
  grafana:
    type: userpass
    userpass:
      username: mzmon_exporter
      password: <password>
    options:
      sslmode: require

postgres_exporter reads credentials for a multi-target scrape from this file and from nowhere else, so the whole file is the Secret rather than a password referenced from it. Create the role it names with the least-privilege grant on the PostgreSQL exporter page.

Until the Secret exists, the pod waits in ContainerCreating with an event naming it.

KeyTypeDefaultDescription
postgres-exporter.targetslist
[]
The databases to watch, one scrape each.
KeyRequiredMeaning
nameyesThe target’s name: its target label, and the auth_modules entry its credentials come from. Letters, digits and hyphens
hostyesThe database host, as the exporter pod resolves it
portnoDefaults to 5432
databasenoThe database the connection opens. Defaults to postgres
authModulenoThe auth_modules entry to use. Defaults to name
targets:
  - name: consensus
    host: mzmon-db.abc123.us-east-1.rds.amazonaws.com
    database: materialize
  - name: grafana
    host: grafana-db.abc123.us-east-1.rds.amazonaws.com

Point database at a database whose tables you want statistics for, and leave it at postgres otherwise. Per-table statistics come from that database alone, at about 29 series per table: the metadata database’s are persist’s consensus and timestamp_oracle, while Grafana’s roughly 90 tables would cost about 2,600 series no panel reads. Everything instance-wide, pg_database_size_bytes among it, covers every database on the instance whichever one the connection opens.

A target is scraped whether or not it is a metadata database. To record it as one, name it in externalDependencies.consensus with flavor: postgres.

postgres-exporter.fullnameOverridestring"postgres-exporter"Standard Helm full-name override. We use a static name for deterministic relations.
postgres-exporter.priorityClassNamestring"monitoring-scalable"Scheduling priority. See the Priority classes section. Scalable: a missed scrape is a gap in a slow-moving series, and the Deployment comes back on its own.
postgres-exporter.imageobject
{
  "registry": "quay.io",
  "repository": "prometheuscommunity/postgres-exporter",
  "tag": "v0.20.1"
}
Container image, pinned here rather than inherited from the subchart's `appVersion`. So Renovate bumps the exporter on its own cadence, with its own release notes.
postgres-exporter.resourcesobject
{
  "limits": {
    "memory": "64Mi"
  },
  "requests": {
    "cpu": "10m",
    "memory": "32Mi"
  }
}
Resource envelope. The exporter is idle between scrapes and holds no state. Memory is request == limit because its working set is small and flat; there is no CPU limit, so a scrape is never throttled into a timeout.
postgres-exporter.config.existingSecretobject
{
  "enabled": true,
  "name": "postgres-exporter"
}
The Secret holding `postgres_exporter.yml`, which you create. See the section introduction for its shape.
postgres-exporter.config.logLevelstring"warn"Exporter log level. `warn`, because at `info` every probe logs a new connection and the server's version, two lines per target per scrape. A target that cannot connect is logged at `error`, so nothing worth reading is lost.
postgres-exporter.config.extraArgslist
[
  "--collector.database_wraparound",
  "--collector.stat_activity_autovacuum"
]
Collectors to turn on beyond the exporter's defaults.
FlagAddsWhy
--collector.database_wraparoundpg_database_wraparound_age_datfrozenxid_seconds, per databaseTransaction-ID age, the wraparound signal. It counts transactions, despite the name
--collector.stat_activity_autovacuumpg_stat_activity_autovacuum_*Autovacuum workers running now, per table

The defaults already cover what the dashboards read besides these: connections by state and the oldest open transaction, max_connections, per-database sizes, per-table dead tuples and vacuum times, and the server version.

postgres-exporter.networkPolicyobject
{
  "enabled": true
}
NetworkPolicy for the exporter. The subchart's policy admits ingress on the exporter's port and leaves egress unrestricted, because the databases it connects to are wherever you put them.