# materialize-monitoring values




<!--
This file is automatically generated from `charts/materialize-monitoring/values.yaml`
and `materialize-monitoring-values.md.gotmpl` by [helm-docs](https://github.com/norwoodj/helm-docs).
Do not edit it by hand.

To update: edit `values.yaml` (or this template), then run
`make docs/content/reference/helm/materialize-monitoring-values.md`.
-->

# `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

* <https://github.com/MaterializeInc/materialize-monitoring/tree/main/charts/materialize-monitoring>

## 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

| Repository | Name | Version |
|------------|------|---------|
| https://grafana.github.io/helm-charts | alloy(alloy-agent) | 1.12.1 |
| https://grafana.github.io/helm-charts | alloy(alloy-gateway) | 1.12.1 |
| https://kubernetes-sigs.github.io/metrics-server | metrics-server | 3.14.0 |
| [oci://ghcr.io/grafana-community/helm-charts](https://github.com/grafana-community/helm-charts) | grafana | 12.11.1 |
| [oci://ghcr.io/grafana-community/helm-charts](https://github.com/grafana-community/helm-charts) | loki | 18.11.0 |
| [oci://ghcr.io/grafana/helm-charts](https://github.com/grafana/helm-charts) | grafana-operator | 5.24.0 |
| [oci://ghcr.io/prometheus-community/charts](https://github.com/prometheus-community/helm-charts) | alertmanager | 1.42.0 |
| [oci://ghcr.io/prometheus-community/charts](https://github.com/prometheus-community/helm-charts) | kube-state-metrics | 8.4.0 |
| [oci://ghcr.io/prometheus-community/charts](https://github.com/prometheus-community/helm-charts) | prometheus-node-exporter(node-exporter) | 4.56.1 |
| [oci://ghcr.io/thanos-community/helm-charts](https://github.com/thanos-community/helm-charts) | thanos | 0.30.0 |

## Values {#values}

### Globals

Values that are passed to all subcharts

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">global<wbr>.imageRegistry</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">Override the registry for all images in this chart and its subcharts. Leave empty to use upstream defaults.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">global<wbr>.imagePullSecrets</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">global<wbr>.clusterDomain</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"cluster.local"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
  </tbody>
</table>

### Main chart configuration

Configuration for the main chart

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">nameOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">Standard Helm name override. Note that in umbrella charts, fullname may be shorted. See fullnameOverride.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">fullnameOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"mzmon"</code></td>
      <td class="helm-value-desc">Standard Helm fullname override. Note that in umbrella charts, this may be shortened to avoid long prefixes. (and have more stable names in resources)
</td>
    </tr>
    <tr>
      <td class="helm-value-key">namespaceOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">Namespace override for default workloads.
</td>
    </tr>
  </tbody>
</table>

### 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 tag          | Charts                                              |
| ------------------ | --------------------------------------------------- |
| `default`          | `pipeline` + `bundled-backends` + `managed-grafana` |
|                    | groups + `kube-state-metrics` (recommended stack)   |
| `pipeline`         | `alloy-agent`, `alloy-gateway`                      |
| `bundled-backends` | `loki`, `thanos`, `alertmanager`                    |
| `managed-grafana`  | `grafana`, `grafana-operator`                       |
| `cluster-metrics`  | `kube-state-metrics`, `node-exporter`,              |
|                    | `metrics-server`                                    |
| `crds`             | `prometheus-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.)

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">tags<wbr>.default</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Enable all recommended defaults. You can `--set tags.default=false` to disable all services and explicitly enable others.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">tags<wbr>.pipeline</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Enable both Alloy releases (agent + gateway).
</td>
    </tr>
    <tr>
      <td class="helm-value-key">tags<wbr>.bundled-backends</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Enable Loki, Thanos, and Alertmanager as a group. (Grafana is in the `managed-grafana` group.)
</td>
    </tr>
    <tr>
      <td class="helm-value-key">tags<wbr>.cluster-metrics</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Enable kube-state-metrics and metrics-server as a group.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">tags<wbr>.alloy-agent</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Per-chart override: enable just the Alloy agent. OR'd with `tags.default` / `tags.pipeline`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">tags<wbr>.alloy-gateway</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Per-chart override: enable just the Alloy gateway. OR'd with `tags.default` / `tags.pipeline`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">tags<wbr>.loki</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Per-chart override: enable just Loki. OR'd with `tags.default` / `tags.bundled-backends`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">tags<wbr>.thanos</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Per-chart override: enable just Thanos. OR'd with `tags.default` / `tags.bundled-backends`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">tags<wbr>.grafana-standalone</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Per-chart override: enable just Grafana standalone. OR'd with `tags.default` / `tags.managed-grafana`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">tags<wbr>.grafana-operator</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Per-chart override: enable just Grafana operator. OR'd with `tags.default` / `tags.managed-grafana`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">tags<wbr>.alertmanager</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Per-chart override: enable just Alertmanager. OR'd with `tags.default` / `tags.bundled-backends`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">tags<wbr>.kube-state-metrics</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Per-chart override: enable just kube-state-metrics. OR'd with `tags.default` / `tags.cluster-metrics`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">tags<wbr>.node-exporter</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Per-chart override: enable just node-exporter. OR'd with `tags.default` / `tags.cluster-metrics`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">tags<wbr>.metrics-server</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Per-chart override: enable just metrics-server. OR'd with `tags.cluster-metrics`.
</td>
    </tr>
  </tbody>
</table>

### 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**:

| Class                 | Used by                                          | Losing a pod means                                      |
| --------------------- | ------------------------------------------------ | ------------------------------------------------------- |
| `monitoring-critical` | `alloy-agent`, `node-exporter`, `alloy-gateway`   | a blind spot with no replica to cover it                |
| `monitoring-scalable` | `loki`, `thanos`, `grafana`, `alertmanager`, KSM  | reduced 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.

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">priorityClasses<wbr>.create</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Create 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">priorityClasses<wbr>.critical</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "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
}</pre>
</td>
      <td class="helm-value-desc">Priority for components whose loss creates an unrecoverable blind spot — the per-node collectors and the egress gateway.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">priorityClasses<wbr>.scalable</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "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
}</pre>
</td>
      <td class="helm-value-desc">Priority for replicated or buffered components, where losing a pod costs capacity rather than visibility.
</td>
    </tr>
  </tbody>
</table>

### 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:

| Component            | Key                              |
| -------------------- | -------------------------------- |
| Alloy agent          | `alloy-agent.networkPolicy`      |
| Alloy gateway        | `alloy-gateway.networkPolicy`    |
| Loki                 | `loki.networkPolicy`             |
| Thanos               | `thanos.global.networkPolicies`  |
| Grafana              | `grafana.networkPolicy`          |
| kube-state-metrics   | `kube-state-metrics.networkPolicy` |
| node-exporter        | `node-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.

| Key | Meaning |
| --- | --- |
| `enabled` | Render this policy. Unset follows `networkPolicies.enabled`. |
| `ingress.ports` | Ports opened to the rest of the component's namespace. A bare number means TCP; `{port, protocol}` spells out anything else. |
| `ingress.allowExternal` | Drop the source restriction entirely, so the ports above accept connections from any namespace and from outside the cluster. |
| `ingress.extra` | Raw `NetworkPolicyIngressRule` entries, appended verbatim. The hook for a cross-namespace source. |
| `egress.inNamespace` | Reach the other pods of this release, on any port. |
| `egress.dns` | Reach cluster DNS. Rendered as a policy of its own. |
| `egress.external.ports` / `.cidrs` | Ports and CIDRs outside the cluster. Ports with no CIDRs means those ports *anywhere*. |
| `egress.extra` | Raw `NetworkPolicyEgressRule` entries, appended verbatim. |

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">networkPolicies<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Render the policies in this block. Off skips all three at once; the per-component `enabled` keys below override it either way.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">networkPolicies<wbr>.alertmanager</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "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"
      }
    ]
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">networkPolicies<wbr>.grafana-operator</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "egress": {
    "dns": true,
    "external": {
      "cidrs": [
        "0.0.0.0/0"
      ],
      "ports": [
        443,
        6443
      ]
    },
    "extra": [],
    "inNamespace": true
  },
  "enabled": null,
  "ingress": {
    "allowExternal": false,
    "extra": [],
    "ports": [
      9090
    ]
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">networkPolicies<wbr>.grafanaGossip</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": null,
  "port": 9094
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">networkPolicies<wbr>.metrics-server</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "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
    ]
  }
}</pre>
</td>
      <td class="helm-value-desc">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`.
</td>
    </tr>
  </tbody>
</table>

### 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](https://materializeinc.github.io/materialize-monitoring/operating/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

| | Internal | External |
| --- | --- | --- |
| Names | `$svc`, `$svc.$ns`, `$svc.$ns.svc`, `$svc.$ns.svc.$clusterDomain`, `localhost` | the public DNS name the load balancer answers on |
| Typical issuer | a self-signed root, or a private CA | ACME, or a private CA that signs your public names |
| Who verifies it | the stack's own components | a 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 `issuerRef`s 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`.

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">certificates<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Render the cert-manager resources in this section.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">certificates<wbr>.internal</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "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"
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">certificates<wbr>.internal<wbr>.issuerRef</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "group": "cert-manager.io",
  "kind": "ClusterIssuer",
  "name": ""
}</pre>
</td>
      <td class="helm-value-desc">An existing cert-manager issuer to sign internal certificates. `kind` is `Issuer` or `ClusterIssuer`. Leave `name` empty to use `selfSigned` instead.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">certificates<wbr>.internal<wbr>.selfSigned</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "caSecretNamespace": "cert-manager",
  "commonName": "mzmon-internal-ca",
  "duration": "43800h",
  "enabled": false,
  "kind": "ClusterIssuer",
  "renewBefore": "2160h",
  "secretName": "mzmon-internal-ca"
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">certificates<wbr>.internal<wbr>.selfSigned<wbr>.commonName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"mzmon-internal-ca"</code></td>
      <td class="helm-value-desc">Subject common name on the generated root.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">certificates<wbr>.internal<wbr>.selfSigned<wbr>.duration</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"43800h"</code></td>
      <td class="helm-value-desc">Lifetime of the **root**, not of the leaves. Long, because rotating a root means re-trusting it everywhere at once.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">certificates<wbr>.internal<wbr>.selfSigned<wbr>.secretName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"mzmon-internal-ca"</code></td>
      <td class="helm-value-desc">Secret the generated root's key pair lands in.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">certificates<wbr>.internal<wbr>.selfSigned<wbr>.caSecretNamespace</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"cert-manager"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">certificates<wbr>.external</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "dnsNames": [],
  "issuerRef": {
    "group": "cert-manager.io",
    "kind": "ClusterIssuer",
    "name": ""
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">certificates<wbr>.external<wbr>.dnsNames</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">Public DNS names to put on the certificate.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">certificates<wbr>.duration</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"2160h"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">certificates<wbr>.components</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "alertmanager": {
    "enabled": null,
    "extraDnsNames": [],
    "extraIpAddresses": [],
    "secretName": "",
    "services": [
      "mzmon-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-query-frontend"
    ]
  },
  "thanos": {
    "enabled": null,
    "extraDnsNames": [],
    "extraIpAddresses": [],
    "secretName": "",
    "services": [
      "thanos-receive",
      "thanos-query"
    ]
  }
}</pre>
</td>
      <td class="helm-value-desc">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:

| Key | Meaning |
| --- | --- |
| `enabled` | Render this certificate. Unset follows `certificates.enabled`. |
| `services` | Service names the SAN ladder is built for. |
| `extraDnsNames` | Appended verbatim — an ingress host, a mesh name. |
| `extraIpAddresses` | Appended to the IP SANs, which already carry `127.0.0.1`. |
| `secretName` | Where the material lands. Defaults to `<release>-<component>-tls`. |
</td>
    </tr>
  </tbody>
</table>

### Materialize Integration

Materialize-specific configuration values.

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">materialize<wbr>.namespaces</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">Namespaces to scrape Materialize workloads from. Empty list means all namespaces the chart can read.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.environmentd<wbr>.serviceMonitor</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "metricEndpoints": [],
  "selector": {},
  "targetLabels": []
}</pre>
</td>
      <td class="helm-value-desc">ServiceMonitor/PodMonitor configuration for environmentd
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.environmentd<wbr>.serviceMonitor<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Deploy a PodMonitor to collect Materialize metrics.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.environmentd<wbr>.serviceMonitor<wbr>.selector</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">Override for default selector
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.environmentd<wbr>.serviceMonitor<wbr>.targetLabels</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">Override for default labels to copy from the pod into metrics
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.environmentd<wbr>.serviceMonitor<wbr>.metricEndpoints</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">Override for default metric endpoints
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.environmentdSQL<wbr>.serviceMonitor</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "metricEndpoints": [],
  "selector": {},
  "targetLabels": []
}</pre>
</td>
      <td class="helm-value-desc">ServiceMonitor/PodMonitor configuration for environmentd-sql
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.environmentdSQL<wbr>.serviceMonitor<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Deploy a PodMonitor to collect Materialize metrics.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.environmentdSQL<wbr>.serviceMonitor<wbr>.selector</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">Override for default selector
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.environmentdSQL<wbr>.serviceMonitor<wbr>.targetLabels</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">Override for default labels to copy from the pod into metrics
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.environmentdSQL<wbr>.serviceMonitor<wbr>.metricEndpoints</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">Override for default metric endpoints
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.clusterd<wbr>.serviceMonitor</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "metricEndpoints": [],
  "selector": {},
  "targetLabels": []
}</pre>
</td>
      <td class="helm-value-desc">ServiceMonitor/PodMonitor configuration for clusterd
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.clusterd<wbr>.serviceMonitor<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Deploy a PodMonitor to collect Materialize metrics.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.clusterd<wbr>.serviceMonitor<wbr>.selector</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">Override for default selector
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.clusterd<wbr>.serviceMonitor<wbr>.targetLabels</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">Override for default labels to copy from the pod into metrics
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.clusterd<wbr>.serviceMonitor<wbr>.metricEndpoints</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">Override for default metric endpoints
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize<wbr>.deploymentMode</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"self-managed"</code></td>
      <td class="helm-value-desc">Deployment mode normalization hint. One of: `self-managed`, `cloud`. Drives relabeling rules in the pipeline.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize-system<wbr>.namespace</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"materialize-environment"</code></td>
      <td class="helm-value-desc">The namespace materialize-system was deployed into
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize-system<wbr>.serviceMonitor</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true
}</pre>
</td>
      <td class="helm-value-desc">ServiceMonitor/PodMonitor configuration TODO: nothing uses this yet
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize-operator<wbr>.namespace</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"materialize"</code></td>
      <td class="helm-value-desc">The namespace materialize-operator was deployed into
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize-operator<wbr>.serviceMonitor</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "metricEndpoints": [],
  "selector": {},
  "targetLabels": []
}</pre>
</td>
      <td class="helm-value-desc">ServiceMonitor/PodMonitor configuration
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize-operator<wbr>.serviceMonitor<wbr>.selector</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">Override for default selector
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize-operator<wbr>.serviceMonitor<wbr>.targetLabels</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">Override for default labels to copy from the pod into metrics
</td>
    </tr>
    <tr>
      <td class="helm-value-key">materialize-operator<wbr>.serviceMonitor<wbr>.metricEndpoints</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">Override for default metric endpoints
</td>
    </tr>
  </tbody>
</table>

### 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.

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">pipeline<wbr>.preValidateJob<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Run a hook/dependency to validate rendered configs before rolling them out
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.preValidateJob<wbr>.annotations</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "helm.sh/hook": "pre-install,pre-upgrade",
  "helm.sh/hook-delete-policy": "before-hook-creation"
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.preValidateJob<wbr>.backoffLimit</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>1</code></td>
      <td class="helm-value-desc">Number of times to retry the job before failing the release.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.preValidateJob<wbr>.podSecurityContext</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "fsGroup": 473,
  "runAsGroup": 473,
  "runAsUser": 473
}</pre>
</td>
      <td class="helm-value-desc">Security context for the pre-validate job pod. This is the hardened recommendation with the alloy user.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.preValidateJob<wbr>.containerSecurityContext</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "allowPrivilegeEscalation": false,
  "capabilities": {
    "drop": [
      "ALL"
    ]
  },
  "readOnlyRootFilesystem": true,
  "runAsGroup": 473,
  "runAsNonRoot": true,
  "runAsUser": 473
}</pre>
</td>
      <td class="helm-value-desc">Security context for the pre-validate job container. This is the hardened recommendation with the alloy user.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.env</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.env<wbr>.CLUSTER_NAME</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"default"</code></td>
      <td class="helm-value-desc">Name of the cluster to discriminate workloads from different sources.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.env<wbr>.GATEWAY_LOG_LEVEL</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"info"</code></td>
      <td class="helm-value-desc">Level for the gateway logs.
</td>
    </tr>
  </tbody>
</table>

#### Log configuration

Configuration for log behavior

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.agent<wbr>.rateLimit</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>5000</code></td>
      <td class="helm-value-desc">Rate limit for alloy agent incoming pod logs. This is per agent.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.agent<wbr>.burst</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>20000</code></td>
      <td class="helm-value-desc">Burst limit for alloy agent incoming pod logs.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.agent<wbr>.destination<wbr>.loki<wbr>.url</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"http://alloy-gateway.{{ include \"mzmon.alloyGateway.namespace\" $ }}.svc:3100/loki/api/v1/push"</code></td>
      <td class="helm-value-desc">alloy-gateway push endpoint URL.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.agent<wbr>.destination<wbr>.loki<wbr>.retries</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "maxBackoffPeriod": "5m",
  "maxBackoffRetries": 10,
  "minBackoffPeriod": "1s",
  "retryOnHttp429": true
}</pre>
</td>
      <td class="helm-value-desc">Retry configuration.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.agent<wbr>.destination<wbr>.loki<wbr>.authType</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"none"</code></td>
      <td class="helm-value-desc">Type of authentication to use with the alloy-gateway endpoint. Use none if no authentication is required.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.agent<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Whether to enable TLS for alloy-gateway dest.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.agent<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.verify</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Whether to verify the TLS certificate for the alloy-gateway dest.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.agent<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.ca</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">Certificate Authority (CA) PEM contents for TLS.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.agent<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.cert</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">Client certificate PEM contents for TLS.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.agent<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.key</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">Client private key PEM contents for TLS.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.agent<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.caFile</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.agent<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.serverName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">Alternative SNI (Server Name Indication) to specify.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.agent<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.minVersion</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"TLS13"</code></td>
      <td class="helm-value-desc">Minimum TLS version to allow. Use TLS12 if you need better compat. TLS11 and TLS10 are not recommended for production.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.server</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "tls": {
    "certFile": "",
    "clientAuth": "NoClientCert",
    "clientCAFile": "",
    "enabled": false,
    "keyFile": "",
    "minVersion": "TLS13",
    "reloadInterval": "1m"
  }
}</pre>
</td>
      <td class="helm-value-desc">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:

| Listener | Port | Configured by |
| --- | --- | --- |
| `loki.source.api` | 3100 | `pipeline.logging.gateway.server.tls` |
| `otelcol.receiver.otlp` | 4317 / 4318 | `pipeline.logging.gateway.server.tls` |
| `prometheus.receive_http` | 9090 | `pipeline.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`:

| Value | Meaning |
| --- | --- |
| `NoClientCert` | do not ask (phase 1) |
| `RequestClientCert` | ask, do not verify |
| `RequireAnyClientCert` | require, do not verify against a CA |
| `VerifyClientCertIfGiven` | verify what is presented, allow none (phase 2) |
| `RequireAndVerifyClientCert` | require 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.server<wbr>.tls<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Serve TLS on this role's listeners.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.server<wbr>.tls<wbr>.certFile</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.server<wbr>.tls<wbr>.clientCAFile</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">CA used to verify client certificates. Required by every `clientAuth` value except `NoClientCert`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.server<wbr>.tls<wbr>.clientAuth</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"NoClientCert"</code></td>
      <td class="helm-value-desc">How hard to insist on a client certificate. See the table above.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.server<wbr>.tls<wbr>.minVersion</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"TLS13"</code></td>
      <td class="helm-value-desc">Minimum TLS version the listener accepts.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.server<wbr>.tls<wbr>.reloadInterval</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"1m"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Enable writing to a loki destination. By default, we use the in-cluster loki
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.url</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"http://loki-distributor.{{ include \"mzmon.loki.namespace\" $ }}.svc:3100/loki/api/v1/push"</code></td>
      <td class="helm-value-desc">Loki push endpoint URL.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.retries</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "maxBackoffPeriod": "5m",
  "maxBackoffRetries": 10,
  "minBackoffPeriod": "1s",
  "retryOnHttp429": true
}</pre>
</td>
      <td class="helm-value-desc">Retry configuration.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.authType</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"none"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.basicAuth</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "password": "",
  "passwordEnv": "GATEWAY_LOKI_DEST_PASSWORD",
  "username": "",
  "usernameEnv": "GATEWAY_LOKI_DEST_USERNAME"
}</pre>
</td>
      <td class="helm-value-desc">Configuration for auth when using authType=basicAuth You will need to provide alloy-gateway.alloy.agent.extraEnv TODO: add a check for this
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.bearer</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "token": "",
  "tokenEnv": "GATEWAY_LOKI_DEST_BEARER_TOKEN"
}</pre>
</td>
      <td class="helm-value-desc">Configuration for bearer token when using authType=bearer This is used for bearer type tokens.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.oauth2</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "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"
}</pre>
</td>
      <td class="helm-value-desc">Configuration for OAuth2 when using authType=oauth2
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Whether to enable TLS for the loki destination.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.verify</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Whether to verify the TLS certificate for the loki destination.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.ca</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">Certificate Authority (CA) PEM contents for TLS.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.cert</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">Client certificate PEM contents for TLS.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.key</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">Client private key PEM contents for TLS.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.caFile</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.serverName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">Alternative SNI (Server Name Indication) to specify.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.loki<wbr>.tls<wbr>.minVersion</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"TLS13"</code></td>
      <td class="helm-value-desc">Minimum TLS version to allow. Use TLS12 if you need better compat. TLS11 and TLS10 are not recommended for production.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Enable 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.tenancy<wbr>.staticTenant</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"loki"</code></td>
      <td class="helm-value-desc">Default tenant to write logs to. This is used when tenantMap values is set to `static`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.logging<wbr>.tenancy<wbr>.tenantMap</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "audit": "static",
  "default": "static",
  "environment": "static",
  "infra": "static"
}</pre>
</td>
      <td class="helm-value-desc">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).
</td>
    </tr>
  </tbody>
</table>

#### Metrics configuration

Configuration for metrics behavior

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.kubelet</td>
      <td class="helm-value-type">h5</td>
      <td class="helm-value-default"><code>{"scrapeInterval":"60s", "tlsInsecureSkipVerify":false}</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.kubelet<wbr>.scrapeInterval</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"60s"</code></td>
      <td class="helm-value-desc">Scrape interval for the kubelet cAdvisor endpoint. The dominant cost lever — roughly 6.7k series per node per scrape.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.kubelet<wbr>.tlsInsecureSkipVerify</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Skip 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.denyMetrics</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">Denylist of metrics that are excluded from being exported These are |'d in a regex pattern
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.server</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "tls": {
    "certFile": "",
    "clientAuth": "NoClientCert",
    "clientCAFile": "",
    "enabled": false,
    "keyFile": "",
    "minVersion": "TLS13",
    "reloadInterval": "1m"
  }
}</pre>
</td>
      <td class="helm-value-desc">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:

| Listener | Port | Configured by |
| --- | --- | --- |
| `loki.source.api` | 3100 | `pipeline.logging.gateway.server.tls` |
| `otelcol.receiver.otlp` | 4317 / 4318 | `pipeline.logging.gateway.server.tls` |
| `prometheus.receive_http` | 9090 | `pipeline.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`:

| Value | Meaning |
| --- | --- |
| `NoClientCert` | do not ask (phase 1) |
| `RequestClientCert` | ask, do not verify |
| `RequireAnyClientCert` | require, do not verify against a CA |
| `VerifyClientCertIfGiven` | verify what is presented, allow none (phase 2) |
| `RequireAndVerifyClientCert` | require 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.server<wbr>.tls<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Serve TLS on this role's listeners.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.server<wbr>.tls<wbr>.certFile</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.server<wbr>.tls<wbr>.clientCAFile</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">CA used to verify client certificates. Required by every `clientAuth` value except `NoClientCert`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.server<wbr>.tls<wbr>.clientAuth</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"NoClientCert"</code></td>
      <td class="helm-value-desc">How hard to insist on a client certificate. See the table above.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.server<wbr>.tls<wbr>.minVersion</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"TLS13"</code></td>
      <td class="helm-value-desc">Minimum TLS version the listener accepts.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.server<wbr>.tls<wbr>.reloadInterval</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"1m"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
  </tbody>
</table>

##### 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.

| Field | Default | Meaning |
| --- | --- | --- |
| `enabled` | `true` | Write to this destination. |
| `url` | — | Remote-write endpoint. Required when enabled. |
| `minMetricImportance` | `all` | Tier floor: `essential`, `recommended`, `extended`, `diagnostic`, `all`. |
| `unfilteredMetricsEnv` | `GATEWAY_UNFILTERED_PROM_METRICS_<NAME>` | Env var carrying the tier allowlist regex. |
| `urlEnv` | `GATEWAY_PROM_DEST_<NAME>` | Env var carrying the endpoint URL. |
| `externalLabels` | `{cluster: CLUSTER_NAME}` | `external_labels` on the component. Values are Alloy expressions. |
| `authType` | `none` | `none`, `basicAuth`, `bearer`, `oauth2`, or `sigv4`. |
| `basicAuth` / `bearer` / `oauth2` / `sigv4` | see below | Credentials for the chosen `authType`. |
| `tls` | off | Client 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`](https://github.com/MaterializeInc/materialize-monitoring/blob/main/charts/materialize-monitoring/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.

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Enable 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)
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.googleCloudExporter<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Enable writing to a Google Cloud Monitoring / Cloud Logging destination.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.googleCloudExporter<wbr>.compression</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"gzip"</code></td>
      <td class="helm-value-desc">Compression for logs/metrics Only gzip is supported for Google Cloud Monitoring / Logging.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.googleCloudExporter<wbr>.minMetricImportance</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"recommended"</code></td>
      <td class="helm-value-desc">Only export metrics with the specified importance level. Values are "essential", "recommended", "extended", "diagnostic", "all"
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.googleCloudExporter<wbr>.handlers</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  "otelcol.exporter.googlecloud.destination.input"
]</pre>
</td>
      <td class="helm-value-desc">Handlers to use for the Google Cloud exporter.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.googleCloudExporter<wbr>.config</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>An `otelcol.exporter.googlecloud.destination` definition.</code></td>
      <td class="helm-value-desc">Raw configuration for an otelcol.exporter.googlecloud block. The default config uses Workload Identity Federation (WIF) to authenticate to GCP.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.datadogExporter<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Enable writing to a Datadog destination.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.datadogExporter<wbr>.compression</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"gzip"</code></td>
      <td class="helm-value-desc">Compression for logs/metrics Only gzip is supported for Datadog.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.datadogExporter<wbr>.minMetricImportance</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"recommended"</code></td>
      <td class="helm-value-desc">Only export metrics with the specified importance level. Values are "essential", "recommended", "extended", "diagnostic", "all"
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.datadogExporter<wbr>.hostMetadata<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Whether to include host metadata in the Datadog exporter. FIXME: how do we support this in an Agent->Gateway architecture?
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.datadogExporter<wbr>.handlers</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  "otelcol.exporter.datadog.destination.input"
]</pre>
</td>
      <td class="helm-value-desc">Handlers to use for the Datadog exporter.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.datadogExporter<wbr>.config</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>An `otelcol.exporter.datadog.destination` definition.</code></td>
      <td class="helm-value-desc">Raw configuration for an otelcol.exporter.datadog block.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.otlpExporter<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Enable 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.otlpExporter<wbr>.url</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">OTLP push endpoint URL. This does not need a protocol prefix (http:// or https://)
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.otlpExporter<wbr>.protocol</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"grpc"</code></td>
      <td class="helm-value-desc">Protocol to use for OTLP. Use grpc for gRPC protocol. Use http for HTTP protocol.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.otlpExporter<wbr>.compression</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"gzip"</code></td>
      <td class="helm-value-desc">Compression to use Use gzip for better compatibility. Use snappy for better performance.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.otlpExporter<wbr>.minMetricImportance</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"all"</code></td>
      <td class="helm-value-desc">Only export metrics with the specified importance level. Values are "essential", "recommended", "extended", "diagnostic", "all"
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.otlpExporter<wbr>.handlers</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  "otelcol.exporter.otlp[http].destination.input"
]</pre>
</td>
      <td class="helm-value-desc">Handlers to use for the OTLP exporter. Update this if your config was customized.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.otlpExporter<wbr>.config</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><pre>{{- $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 }}
    }
}
</pre></td>
      <td class="helm-value-desc">Raw configuration for an otelcol.exporter.otlp block. Use this if you need to configure multiple destinations or use an alternative endpoint entirely.
</td>
    </tr>
  </tbody>
</table>

##### OpenTelemetry Authentication Configuration

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

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.auth<wbr>.authType</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"none"</code></td>
      <td class="helm-value-desc">Type of authentication to use with the OpenTelemetry destination. Valid values are: `none`, `basic`, `bearer`, `headers`, `awsSigv4`, and `custom`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.auth<wbr>.basic<wbr>.config</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>An `otelcol.auth.basic.oteldest` definition.</code></td>
      <td class="helm-value-desc">Raw configuration for an otelcol.auth.basic block. This uses the GATEWAY_OTEL_DEST_USERNAME/GATEWAY_OTEL_DEST_PASSWORD env vars.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.auth<wbr>.bearer<wbr>.config</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>An `otelcol.auth.bearer.oteldest` definition.</code></td>
      <td class="helm-value-desc">Raw configuration for an otelcol.auth.bearer block. This uses the GATEWAY_OTEL_DEST_BEARER_TOKEN env var.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.auth<wbr>.headers<wbr>.headers</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.auth<wbr>.headers<wbr>.config</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>An `otelcol.auth.headers.oteldest` definition built from `headers`.</code></td>
      <td class="helm-value-desc">Raw configuration for an otelcol.auth.headers block.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.auth<wbr>.awsSigv4<wbr>.region</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">Override the region to sign requests for.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.auth<wbr>.awsSigv4<wbr>.roleArn</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">Override the role ARN to assume for signing requests.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.auth<wbr>.awsSigv4<wbr>.config</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>An `otelcol.auth.sigv4.oteldest` definition.</code></td>
      <td class="helm-value-desc">Raw configuration for an otelcol.auth.sigv4 block.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.auth<wbr>.custom<wbr>.handler</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"{{ fail \"Be sure to set this\" }}"</code></td>
      <td class="helm-value-desc">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".
</td>
    </tr>
    <tr>
      <td class="helm-value-key">pipeline<wbr>.metrics<wbr>.gateway<wbr>.destination<wbr>.otel<wbr>.auth<wbr>.custom<wbr>.config</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><pre>// THIS IS AN EXAMPLE
otelcol.auth.basic "oteldest" {
    client_auth {
        username = sys.env("GATEWAY_OTEL_DEST_USERNAME")
        password = sys.env("GATEWAY_OTEL_DEST_PASSWORD")
    }
}
</pre></td>
      <td class="helm-value-desc">Raw configuration for a custom auth handler.
</td>
    </tr>
  </tbody>
</table>

### Monitoring configurations

Configuration for dashboards, rules, and alerts

Underlying content is generated into `pre-rendered/`
from the sources under `packages/` and embedded via `.Files.Get`.

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">dashboards<wbr>.config<wbr>.grafana<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Install the bundled Grafana dashboards. Requires the Grafana operator or a writable Grafana instance.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">dashboards<wbr>.config<wbr>.grafana<wbr>.mode</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"operator"</code></td>
      <td class="helm-value-desc">Grafana deployment mode, either "standalone" (the bundled Grafana chart) or "operator" (a separate Grafana Operator instance).
</td>
    </tr>
    <tr>
      <td class="helm-value-key">dashboards<wbr>.config<wbr>.grafana<wbr>.manifest<wbr>.resyncPeriod</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"5m"</code></td>
      <td class="helm-value-desc">Time to sync the dashboard from the manifest
</td>
    </tr>
    <tr>
      <td class="helm-value-key">dashboards<wbr>.config<wbr>.grafana<wbr>.manifest<wbr>.instanceSelector</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">dashboards<wbr>.config<wbr>.grafana<wbr>.manifest<wbr>.allowCrossNamespaceImport</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>inferred</code></td>
      <td class="helm-value-desc">Allow dashboards 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 dashboards, 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">dashboards<wbr>.config<wbr>.grafana<wbr>.manifest<wbr>.apiTarget</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"dashboard.grafana.app/v2"</code></td>
      <td class="helm-value-desc">Dashboard API Version (v2 or v2beta1)
</td>
    </tr>
  </tbody>
</table>

##### 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.

| Field | Default | Meaning |
| --- | --- | --- |
| `create` | `true` | Render 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`. |

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">dashboards<wbr>.config<wbr>.datadog<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Install the bundled Datadog dashboards. Requires Datadog API credentials configured out-of-band.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">dashboards<wbr>.selected</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  "env-*",
  "infra-*"
]</pre>
</td>
      <td class="helm-value-desc">List of dashboard patterns to render
</td>
    </tr>
  </tbody>
</table>

#### Rule configuration

Configuration for rules

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">config<wbr>.rules<wbr>.prometheus<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Install the bundled Prometheus recording and alerting rules as PrometheusRule resources.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">config<wbr>.rules<wbr>.loki<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Install the bundled Loki rules.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">config<wbr>.rules<wbr>.thanos<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Install the bundled Thanos rules.
</td>
    </tr>
  </tbody>
</table>

#### Alert configuration

Configuration for alerts

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">config<wbr>.alerts<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Install the bundled Alertmanager routing and templates.
</td>
    </tr>
  </tbody>
</table>

#### Scraper configuration

Configuration for scrapers

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">config<wbr>.scrapers<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Install ServiceMonitors / Alloy scrape configs for Materialize and adjacent components.
</td>
    </tr>
  </tbody>
</table>

#### Grafana connection configuration

How to talk to a grafana instance

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">connections<wbr>.grafana<wbr>.mode</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"bundled"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.grafana<wbr>.labels</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.grafana<wbr>.allowPublicAccess</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Acknowledge 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`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.grafana<wbr>.operator</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">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.

```yaml
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
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.grafana<wbr>.external<wbr>.url</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">External grafana uri
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.grafana<wbr>.external<wbr>.adminPassword</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">Secret for Grafana admin password
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.grafana<wbr>.external<wbr>.adminUser</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">Secret for Grafana admin user
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.grafana<wbr>.external<wbr>.apiKey</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">Secret for Grafana API key
</td>
    </tr>
  </tbody>
</table>

#### 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.

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Install the bundled datasources. Requires the Grafana operator.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.resyncPeriod</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"5m"</code></td>
      <td class="helm-value-desc">How often the operator re-pushes each datasource.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.editable</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Whether 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.enabled</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>follows `thanos.enabled</code></td>
      <td class="helm-value-desc">Provision 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.name</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"Thanos"</code></td>
      <td class="helm-value-desc">Datasource name, as shown in Grafana.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.uid</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"mzmon-thanos"</code></td>
      <td class="helm-value-desc">Stable datasource UID.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.url</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"http://thanos-query.{{ include \"mzmon.thanos.namespace\" $ }}.svc:9090"</code></td>
      <td class="helm-value-desc">Thanos Query endpoint. Rendered with `tpl`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.isDefault</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Make 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.tls</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "caPem": "",
  "caSecret": {
    "key": "ca.crt",
    "name": ""
  },
  "clientCert": {
    "certKey": "tls.crt",
    "keyKey": "tls.key",
    "secretName": ""
  },
  "enabled": null,
  "serverName": ""
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.tls<wbr>.enabled</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>follows the URL scheme</code></td>
      <td class="helm-value-desc">Verify 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.tls<wbr>.caPem</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.tls<wbr>.caSecret</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "key": "ca.crt",
  "name": ""
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.tls<wbr>.clientCert</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "certKey": "tls.crt",
  "keyKey": "tls.key",
  "secretName": ""
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.tls<wbr>.serverName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">SNI to send, when it differs from the URL host.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.jsonData</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">Extra `jsonData`, merged over the chart's defaults (`prometheusType: Thanos`, `httpMethod: POST`).
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.secureJsonData</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">Inline `secureJsonData`. Prefer `valuesFrom` for real secrets — this renders into the release manifest.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.thanos<wbr>.valuesFrom</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">Secret- or ConfigMap-sourced field injection, passed through to the `GrafanaDatasource`. This is the supported way to supply credentials.

```yaml
valuesFrom:
  - targetPath: secureJsonData.basicAuthPassword
    valueFrom:
      secretKeyRef:
        name: thanos-basic-auth
        key: password
```
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.enabled</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>follows `loki.enabled</code></td>
      <td class="helm-value-desc">Provision the Loki datasource. Unset follows whether the bundled Loki is enabled.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.name</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"Loki"</code></td>
      <td class="helm-value-desc">Datasource name, as shown in Grafana.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.uid</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"mzmon-loki"</code></td>
      <td class="helm-value-desc">Stable datasource UID.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.url</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"http://loki-query-frontend.{{ include \"mzmon.loki.namespace\" $ }}.svc:3100"</code></td>
      <td class="helm-value-desc">Loki read endpoint. Rendered with `tpl`. The Loki gateway is disabled by default, so reads go to the query frontend directly (see `loki.gateway.enabled`).
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.tenant</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>follows `pipeline.logging.tenancy.staticTenant</code></td>
      <td class="helm-value-desc">Tenant 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`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.tls</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "caPem": "",
  "caSecret": {
    "key": "ca.crt",
    "name": ""
  },
  "clientCert": {
    "certKey": "tls.crt",
    "keyKey": "tls.key",
    "secretName": ""
  },
  "enabled": null,
  "serverName": ""
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.tls<wbr>.enabled</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>follows the URL scheme</code></td>
      <td class="helm-value-desc">Verify 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.tls<wbr>.caPem</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.tls<wbr>.caSecret</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "key": "ca.crt",
  "name": ""
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.tls<wbr>.clientCert</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "certKey": "tls.crt",
  "keyKey": "tls.key",
  "secretName": ""
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.tls<wbr>.serverName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>""</code></td>
      <td class="helm-value-desc">SNI to send, when it differs from the URL host.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.jsonData</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">Extra `jsonData`, merged over the chart's defaults (the tenant header name, and `timeout`).
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.secureJsonData</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">Inline `secureJsonData`. Prefer `valuesFrom` for real secrets — this renders into the release manifest.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">connections<wbr>.datasources<wbr>.loki<wbr>.valuesFrom</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">Secret- or ConfigMap-sourced field injection, passed through to the `GrafanaDatasource`. This is the supported way to supply credentials.
</td>
    </tr>
  </tbody>
</table>

#### 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.

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">cleanup<wbr>.grafanaOperator<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Run 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">cleanup<wbr>.grafanaOperator<wbr>.kinds</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  "grafanamanifests.grafana.integreatly.org",
  "grafanadatasources.grafana.integreatly.org"
]</pre>
</td>
      <td class="helm-value-desc">Resource types to delete, as `<resource>.<group>`. 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 (`GrafanaFolder`, `GrafanaAlertRuleGroup`, and so on) with the chart's instance label.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">cleanup<wbr>.grafanaOperator<wbr>.timeout</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"2m"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">cleanup<wbr>.grafanaOperator<wbr>.backoffLimit</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>1</code></td>
      <td class="helm-value-desc">Number of times to retry the job before failing the uninstall.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">cleanup<wbr>.grafanaOperator<wbr>.activeDeadlineSeconds</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>420</code></td>
      <td class="helm-value-desc">Hard 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">cleanup<wbr>.grafanaOperator<wbr>.annotations</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "helm.sh/hook": "pre-delete",
  "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded",
  "helm.sh/hook-weight": "0"
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">cleanup<wbr>.grafanaOperator<wbr>.image</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "pullPolicy": "IfNotPresent",
  "pullSecrets": [],
  "registry": "registry.k8s.io",
  "repository": "kubectl",
  "tag": "v1.35.6"
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">cleanup<wbr>.grafanaOperator<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "limits": {
    "cpu": "100m",
    "memory": "64Mi"
  },
  "requests": {
    "cpu": "50m",
    "memory": "32Mi"
  }
}</pre>
</td>
      <td class="helm-value-desc">Resources for the cleanup job. It issues one API call and waits, so this is deliberately small.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">cleanup<wbr>.grafanaOperator<wbr>.podSecurityContext</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "runAsGroup": 65532,
  "runAsNonRoot": true,
  "runAsUser": 65532,
  "seccompProfile": {
    "type": "RuntimeDefault"
  }
}</pre>
</td>
      <td class="helm-value-desc">Security context for the cleanup job pod.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">cleanup<wbr>.grafanaOperator<wbr>.containerSecurityContext</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "allowPrivilegeEscalation": false,
  "capabilities": {
    "drop": [
      "ALL"
    ]
  },
  "readOnlyRootFilesystem": true,
  "runAsGroup": 65532,
  "runAsNonRoot": true,
  "runAsUser": 65532
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">cleanup<wbr>.grafanaOperator<wbr>.nodeSelector</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">Node selector for the cleanup job.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">cleanup<wbr>.grafanaOperator<wbr>.tolerations</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
  </tbody>
</table>

### Bundled subchart configurations

Configuration for bundled subcharts

#### Alloy Agent

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

Upstream reference:
  * https://github.com/grafana/alloy/tree/main/operations/helm/charts/alloy
  * https://github.com/grafana/alloy/blob/main/operations/helm/charts/alloy/values.yaml

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.fullnameOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"alloy-agent"</code></td>
      <td class="helm-value-desc">Standard Helm full-name override. We use a static name for deterministic relations.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.namespaceOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>nil</code></td>
      <td class="helm-value-desc">Namespace override.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.crds</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "create": false
}</pre>
</td>
      <td class="helm-value-desc">Control for the PodLogs crd.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.global<wbr>.podSecurityContext</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "fsGroup": 473,
  "runAsGroup": 473,
  "runAsUser": 473
}</pre>
</td>
      <td class="helm-value-desc">Security context for the alloy agent pods.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.alloy<wbr>.stabilityLevel</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"generally-available"</code></td>
      <td class="helm-value-desc">Stability level of alloy components.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.alloy<wbr>.extraEnv</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  {
    "name": "GOMEMLIMIT",
    "value": "240MiB"
  }
]</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.alloy<wbr>.envFrom</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  {
    "configMapRef": {
      "name": "mzmon-alloy-agent-env"
    }
  },
  {
    "secretRef": {
      "name": "mzmon-alloy-agent-env",
      "optional": true
    }
  }
]</pre>
</td>
      <td class="helm-value-desc">Environment variable configmaps/secrets to pass to the alloy agent pod.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.alloy<wbr>.mounts</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "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
}</pre>
</td>
      <td class="helm-value-desc">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:

| Host | Journal | Covered by |
| --- | --- | --- |
| Bottlerocket (EKS) | `/var/log/journal/<machine-id>/`, persistent | `varlog` |
| `kindest/node` | `/run/log/journal/<machine-id>/`, volatile | `runlogjournal` |

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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.alloy<wbr>.mounts<wbr>.extra[3]</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "mountPath": "/etc/mzmon/tls",
  "name": "mzmon-tls",
  "readOnly": true
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.alloy<wbr>.securityContext</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "allowPrivilegeEscalation": false,
  "capabilities": {
    "drop": [
      "ALL"
    ]
  },
  "readOnlyRootFilesystem": true,
  "runAsGroup": 473,
  "runAsNonRoot": false,
  "runAsUser": 0
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.alloy<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "limits": {
    "cpu": "250m",
    "memory": "300Mi"
  },
  "requests": {
    "cpu": "100m",
    "memory": "300Mi"
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.controller<wbr>.extraAnnotations</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">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`
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.controller<wbr>.tolerations</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  {
    "effect": "NoSchedule",
    "operator": "Exists"
  }
]</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.controller<wbr>.priorityClassName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"monitoring-critical"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.networkPolicy</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "egress": null,
  "enabled": true,
  "flavor": "kubernetes",
  "ingress": [
    {
      "from": [
        {
          "podSelector": {
            "matchLabels": {
              "app.kubernetes.io/name": "alloy-gateway"
            }
          }
        }
      ],
      "ports": [
        {
          "port": 12345,
          "protocol": "TCP"
        }
      ]
    }
  ],
  "policyTypes": [
    "Ingress"
  ]
}</pre>
</td>
      <td class="helm-value-desc">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`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-agent<wbr>.networkPolicy<wbr>.egress</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>nil</code></td>
      <td class="helm-value-desc">Null 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`.
</td>
    </tr>
  </tbody>
</table>

#### Alloy Gateway

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

Upstream reference:
  * https://github.com/grafana/alloy/tree/main/operations/helm/charts/alloy
  * https://github.com/grafana/alloy/blob/main/operations/helm/charts/alloy/values.yaml

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.fullnameOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"alloy-gateway"</code></td>
      <td class="helm-value-desc">Standard Helm full-name override. We use a static name for deterministic relations.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.namespaceOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>nil</code></td>
      <td class="helm-value-desc">Namespace override.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.crds</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "create": false
}</pre>
</td>
      <td class="helm-value-desc">Control for the PodLogs crd.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.global<wbr>.podSecurityContext</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "fsGroup": 473,
  "runAsGroup": 473,
  "runAsUser": 473
}</pre>
</td>
      <td class="helm-value-desc">Security context for the alloy gateway pods.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.alloy<wbr>.stabilityLevel</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"generally-available"</code></td>
      <td class="helm-value-desc">Stability level of alloy components.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.alloy<wbr>.extraEnv</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  {
    "name": "GOMEMLIMIT",
    "value": "600MiB"
  }
]</pre>
</td>
      <td class="helm-value-desc">Extra environment variables to pass to the alloy gateway pod. `GOMEMLIMIT` at ~80% of the memory limit, for the same reason as the agent's. The gateway now carries the kubelet cAdvisor scrape, so its heap scales with node count — keep this in step with the limit.  It is the ceiling the GC works against, so it also sets where the gateway idles. Leaving it near the old limit while raising `resources` would waste the new headroom; leaving it *above* the limit forfeits the whole point, since the runtime would only start collecting hard after the kubelet has already OOM-killed the pod.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.alloy<wbr>.envFrom</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  {
    "configMapRef": {
      "name": "mzmon-alloy-gateway-env"
    }
  },
  {
    "secretRef": {
      "name": "mzmon-alloy-gateway-env",
      "optional": true
    }
  }
]</pre>
</td>
      <td class="helm-value-desc">Environment variable configmaps/secrets to pass to the alloy gateway pod.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.alloy<wbr>.extraPorts</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  {
    "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
  }
]</pre>
</td>
      <td class="helm-value-desc">Ports to expose from alloy-gateway.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.alloy<wbr>.mounts</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "extra": [
    {
      "mountPath": "/tmp",
      "name": "tmp"
    },
    {
      "mountPath": "/etc/mzmon/tls",
      "name": "mzmon-tls",
      "readOnly": true
    }
  ],
  "varlog": false
}</pre>
</td>
      <td class="helm-value-desc">Volume mounts to expose to alloy gateway.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.alloy<wbr>.mounts<wbr>.extra[1]</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "mountPath": "/etc/mzmon/tls",
  "name": "mzmon-tls",
  "readOnly": true
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.alloy<wbr>.securityContext</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "allowPrivilegeEscalation": false,
  "capabilities": {
    "drop": [
      "ALL"
    ]
  },
  "readOnlyRootFilesystem": true,
  "runAsGroup": 473,
  "runAsNonRoot": true,
  "runAsUser": 473
}</pre>
</td>
      <td class="helm-value-desc">Security context for the alloy gateway containers.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.alloy<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "limits": {
    "cpu": "500m",
    "memory": "768Mi"
  },
  "requests": {
    "cpu": "500m",
    "memory": "768Mi"
  }
}</pre>
</td>
      <td class="helm-value-desc">Resources for the alloy gateway containers. Memory is the gateway's binding constraint and the only axis that actually relieves it — see the `targetMemoryUtilizationPercentage` note below for why adding replicas does not. Sized for the floor a CPU-scaled gateway settles at: at `minReplicas` each pod carries the whole scrape fan-out rather than a shard of it, so the per-pod working set is higher than it looks at a scaled-out replica count. Raise this, and `GOMEMLIMIT` with it, as node count grows.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.controller<wbr>.extraAnnotations</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">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`
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.controller<wbr>.priorityClassName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"monitoring-critical"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.controller<wbr>.autoscaling<wbr>.horizontal<wbr>.targetMemoryUtilizationPercentage</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>0</code></td>
      <td class="helm-value-desc">Memory 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 is the wrong *signal* for this component, because scaling out does not relieve it. The gateway's footprint is dominated by fixed per-process cost, not by per-replica load: measured 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. Each new replica adds a whole baseline to save a few Mi on its peers, so a memory-driven scale-out makes cluster memory pressure *worse*.  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.  Relieve gateway memory vertically instead: raise `resources` and keep `GOMEMLIMIT` in step (see both, above). That is also what the node-count scaling in the `GOMEMLIMIT` note means in practice.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.serviceAccount<wbr>.create</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Create a service account for alloy-gateway.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.serviceAccount<wbr>.annotations</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.networkPolicy</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "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"
  ]
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alloy-gateway<wbr>.networkPolicy<wbr>.egress</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>nil</code></td>
      <td class="helm-value-desc">See the note above: unrestricted, deliberately.
</td>
    </tr>
  </tbody>
</table>

#### Loki

Bundled Loki backend for logs.

Upstream reference:
  * https://github.com/grafana-community/helm-charts/tree/main/charts/loki
  * https://github.com/grafana-community/helm-charts/blob/main/charts/loki/values.yaml

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">loki<wbr>.fullnameOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"loki"</code></td>
      <td class="helm-value-desc">Standard Helm full-name override. We use a static name for deterministic relations.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.namespaceOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>nil</code></td>
      <td class="helm-value-desc">Namespace override.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.global<wbr>.priorityClassName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"monitoring-scalable"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.deploymentMode</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"Distributed"</code></td>
      <td class="helm-value-desc">How loki is deployed. We prefer to run in Distributed/Microservices mode.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.networkPolicy<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Whether to enable a network policy for loki In production, this is recommended to be enabled.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.networkPolicy<wbr>.metrics</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "namespaceSelector": {}
}</pre>
</td>
      <td class="helm-value-desc">Selector for incoming traffic to metric endpoints. This must be configured manually (usually set to `kubernetes.io/metadata.name: monitoring`).
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.networkPolicy<wbr>.ingress</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "namespaceSelector": {}
}</pre>
</td>
      <td class="helm-value-desc">Selector for incoming traffic to the read/write endpoints. This must be configured manually (usually set to `kubernetes.io/metadata.name: monitoring`).
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.networkPolicy<wbr>.externalStorage</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "cidrs": [
    "0.0.0.0/0"
  ],
  "ports": [
    443,
    80
  ]
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.storage<wbr>.bucketNames</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "chunks": "\u003cREPLACE-ME\u003e",
  "ruler": "\u003cREPLACE-ME\u003e"
}</pre>
</td>
      <td class="helm-value-desc">Bucket names for object storage. These are required to be populated.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.storage<wbr>.use_thanos_objstore</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Use the thanos object store client
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.storage<wbr>.object_store</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "s3": {
    "endpoint": "s3.amazonaws.com"
  },
  "type": "s3"
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.schemaConfig</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "configs": [
    {
      "from": "2024-01-01",
      "index": {
        "period": "24h",
        "prefix": "loki_index_"
      },
      "object_store": "s3",
      "schema": "v13",
      "store": "tsdb"
    }
  ]
}</pre>
</td>
      <td class="helm-value-desc">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).
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.commonConfig</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "replication_factor": 3
}</pre>
</td>
      <td class="helm-value-desc">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.)
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.limits_config</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "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
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.limits_config<wbr>.retention_period</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"30d"</code></td>
      <td class="helm-value-desc">Default retention before the compactor deletes logs. Upstream defaults to infinite retention; we set a real bound.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.limits_config<wbr>.ingestion_rate_mb</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>10</code></td>
      <td class="helm-value-desc">Per-tenant ingestion rate / burst, in MB. Per environment, not the fleet aggregate.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.limits_config<wbr>.max_global_streams_per_user</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>10000</code></td>
      <td class="helm-value-desc">Active-stream ceiling per tenant; a cardinality guard.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.limits_config<wbr>.reject_old_samples</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Reject writes too far in the past.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.limits_config<wbr>.volume_enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Enable the log-volume endpoints Grafana's logs drilldown relies on.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.compactor</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "delete_request_store": "s3",
  "retention_delete_delay": "8h",
  "retention_enabled": true
}</pre>
</td>
      <td class="helm-value-desc">Compactor *configuration* (distinct from the compactor deployment below). Retention is enforced by the compactor and is OFF in upstream defaults.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.compactor<wbr>.delete_request_store</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"s3"</code></td>
      <td class="helm-value-desc">Where delete requests are stored; matches the object-store backend.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.loki<wbr>.compactor<wbr>.retention_delete_delay</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"8h"</code></td>
      <td class="helm-value-desc">Grace period before retention/deletes actually remove data.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.gateway<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Disable gateway by default. We recommend using alloy-gateway for loki writes. Use the query-frontend for loki reads.
</td>
    </tr>
  </tbody>
</table>

##### Loki Microservice Configuration

Configuration for each loki microservice.

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

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">loki<wbr>.distributor<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Enable 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.distributor<wbr>.replicas</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>nil</code></td>
      <td class="helm-value-desc">Number of replicas for the distributor microservice. If autoscaling is enabled, this should be set to null.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.distributor<wbr>.kind</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"Deployment"</code></td>
      <td class="helm-value-desc">Type of workload for the distributor.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.distributor<wbr>.autoscaling</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "maxReplicas": 4,
  "minReplicas": 2,
  "targetCPUUtilizationPercentage": 60,
  "targetMemoryUtilizationPercentage": 75
}</pre>
</td>
      <td class="helm-value-desc">Configuration for autoscaling of distributor For production, this is recommended to be enabled. We provide opinionated defaults.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.distributor<wbr>.service<wbr>.type</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"ClusterIP"</code></td>
      <td class="helm-value-desc">Service type for the distributor microservice. Without a gateway, this is the exposed write component.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.distributor<wbr>.podDisruptionBudget</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "minAvailable": 1
}</pre>
</td>
      <td class="helm-value-desc">Configuration for pod disruption budget for the distributor microservice.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.distributor<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "requests": {
    "cpu": "150m",
    "memory": "256Mi"
  }
}</pre>
</td>
      <td class="helm-value-desc">Resource requests (medium install). Tune per profile.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ingester<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Enable 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ingester<wbr>.replicas</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>3</code></td>
      <td class="helm-value-desc">Number 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ingester<wbr>.kind</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"StatefulSet"</code></td>
      <td class="helm-value-desc">Ingesters are stateful.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ingester<wbr>.persistence<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Run 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ingester<wbr>.terminationGracePeriodSeconds</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>60</code></td>
      <td class="helm-value-desc">Termination 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ingester<wbr>.affinity</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "podAntiAffinity": {
    "requiredDuringSchedulingIgnoredDuringExecution": null
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ingester<wbr>.topologySpreadConstraints</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  {
    "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"
  }
]</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ingester<wbr>.zoneAwareReplication<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Enable zone-aware replication for the ingester microservice. Not needed at our size: topologySpreadConstraints give the AZ spread without the complexity.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ingester<wbr>.podDisruptionBudget</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "maxUnavailable": 1
}</pre>
</td>
      <td class="helm-value-desc">PDB for the ingester. Protect ingest quorum across rollouts and node drains. Do not set maxUnavailable >= 2 to avoid potential quorum loss.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ingester<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "requests": {
    "cpu": "500m",
    "memory": "1Gi"
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.querier<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Enable the querier microservice. Querier is required. It executes LogQL, reading recent data from ingesters and historical data from object storage.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.querier<wbr>.replicas</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>nil</code></td>
      <td class="helm-value-desc">Replicas are managed by autoscaling; leave null.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.querier<wbr>.autoscaling</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "maxReplicas": 4,
  "minReplicas": 2,
  "targetCPUUtilizationPercentage": 60,
  "targetMemoryUtilizationPercentage": 75
}</pre>
</td>
      <td class="helm-value-desc">Autoscale the stateless read workers with query load.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.querier<wbr>.podDisruptionBudget</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "minAvailable": 1
}</pre>
</td>
      <td class="helm-value-desc">PDB for querier.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.querier<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "requests": {
    "cpu": "250m",
    "memory": "512Mi"
  }
}</pre>
</td>
      <td class="helm-value-desc">Resource requests. This assumes a medium install by default.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.queryFrontend<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Enable the query-frontend microservice. Query-frontend is required. It queues and splits queries and caches results.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.queryFrontend<wbr>.replicas</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>nil</code></td>
      <td class="helm-value-desc">Static replicas for query-frontend when autoscaling is not enabled.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.queryFrontend<wbr>.kind</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"Deployment"</code></td>
      <td class="helm-value-desc">Kind of workload for query-frontend. Deployments are recommended for stateless components.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.queryFrontend<wbr>.autoscaling</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "maxReplicas": 4,
  "minReplicas": 2,
  "targetCPUUtilizationPercentage": 60,
  "targetMemoryUtilizationPercentage": 75
}</pre>
</td>
      <td class="helm-value-desc">Configure autoscaling for query-frontend.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.queryFrontend<wbr>.podDisruptionBudget</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "minAvailable": 1
}</pre>
</td>
      <td class="helm-value-desc">PDB for query-frontend.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.queryFrontend<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "requests": {
    "cpu": "100m",
    "memory": "256Mi"
  }
}</pre>
</td>
      <td class="helm-value-desc">Resource requests for query-frontend.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.queryScheduler<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Enable 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.queryScheduler<wbr>.podDisruptionBudget</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "minAvailable": 1
}</pre>
</td>
      <td class="helm-value-desc">PDB for query-scheduler.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.queryScheduler<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "requests": {
    "cpu": "100m",
    "memory": "256Mi"
  }
}</pre>
</td>
      <td class="helm-value-desc">Resources for the query-scheduler microservice.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.compactor<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Enable the compactor. Compactor is required and MUST be a singleton: it compacts the index and enforces retention against shared object storage.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.compactor<wbr>.replicas</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>1</code></td>
      <td class="helm-value-desc">Compactor replicas. This MUST be one, since it runs as a singleton.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.compactor<wbr>.kind</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"StatefulSet"</code></td>
      <td class="helm-value-desc">Kind of workload for the compactor. This must be a statefulset.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.compactor<wbr>.persistence<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Run 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.compactor<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "requests": {
    "cpu": "250m",
    "memory": "512Mi"
  }
}</pre>
</td>
      <td class="helm-value-desc">Resources for the compactor.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.indexGateway<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Enable the index-gateway. Index-gateway is required: it serves index lookups so queriers do not each download the whole index from object storage.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.indexGateway<wbr>.persistence</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": false
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.indexGateway<wbr>.podDisruptionBudget</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "minAvailable": 1
}</pre>
</td>
      <td class="helm-value-desc">PDB for index-gateway.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.indexGateway<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "requests": {
    "cpu": "200m",
    "memory": "512Mi"
  }
}</pre>
</td>
      <td class="helm-value-desc">Resources for the index-gateway.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ruler<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Enable the ruler. The ruler evaluates LogQL alerting and recording rules. Recording-rule samples are remote-written back through alloy-gateway to the metric store.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ruler<wbr>.persistence<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Keep 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ruler<wbr>.podDisruptionBudget</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "minAvailable": 1
}</pre>
</td>
      <td class="helm-value-desc">PDB for the ruler.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.ruler<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "requests": {
    "cpu": "100m",
    "memory": "256Mi"
  }
}</pre>
</td>
      <td class="helm-value-desc">Resources for the ruler.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.chunksCache</td>
      <td class="helm-value-type">h5</td>
      <td class="helm-value-default"><code>{"allocatedMemory":2048, "priorityClassName":"monitoring-scalable"}</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.resultsCache</td>
      <td class="helm-value-type">h5</td>
      <td class="helm-value-default"><code>{"priorityClassName":"monitoring-scalable"}</code></td>
      <td class="helm-value-desc">Query results cache (memcached).
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.monitoring<wbr>.serviceMonitor<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Enable a ServiceMonitor for the loki microservices.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.lokiCanary</td>
      <td class="helm-value-type">h5</td>
      <td class="helm-value-default"><code>{"enabled":true, "kind":"Deployment", "lokiurl":"loki-query-frontend:3100", "priorityClassName":"monitoring-scalable", "push":false}</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">loki<wbr>.lokiCanary<wbr>.priorityClassName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"monitoring-scalable"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
  </tbody>
</table>

#### Thanos

Bundled Thanos backend for long-term metrics.

Upstream reference:
  * https://github.com/thanos-community/helm-charts/tree/master/charts/thanos
  * https://github.com/thanos-community/helm-charts/blob/master/charts/thanos/values.yaml

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">thanos<wbr>.fullnameOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"thanos"</code></td>
      <td class="helm-value-desc">Standard Helm full-name override. We use a static name for deterministic relations.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.namespaceOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>nil</code></td>
      <td class="helm-value-desc">Namespace override.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.global<wbr>.priorityClassName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"monitoring-scalable"</code></td>
      <td class="helm-value-desc">Scheduling priority for every Thanos pod. See the Priority classes section.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.global<wbr>.pdb</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "maxUnavailable": 1
}</pre>
</td>
      <td class="helm-value-desc">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`).
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.global<wbr>.networkPolicies</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">NetworkPolicies 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.query</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "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"
    }
  ]
}</pre>
</td>
      <td class="helm-value-desc">Thanos Query configuration. Query provides a PromQL query endpoint.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.query<wbr>.autoscaling</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "maxReplicas": 5,
  "minReplicas": 2,
  "targetCPUUtilizationPercentage": 80
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.query<wbr>.topologySpreadConstraints</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  {
    "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"
  }
]</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.query<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "requests": {
    "cpu": "500m",
    "memory": "1Gi"
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.receive<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Enable Thanos receiver. Receive provides a Prometheus remote_write-compatible endpoint.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.receive<wbr>.mode</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"standalone"</code></td>
      <td class="helm-value-desc">Whether to split receive distributors from ingesters. mode=split is not very stable in the helm chart
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.receive<wbr>.replicaCount</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>3</code></td>
      <td class="helm-value-desc">Number of receive replicas.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.receive<wbr>.tsdb</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "retention": "6h"
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.receive<wbr>.extraArgs</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  "--receive.replication-factor=3"
]</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.receive<wbr>.vpa</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": false
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.receive<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "limits": {
    "ephemeral-storage": "6Gi"
  },
  "requests": {
    "cpu": "500m",
    "ephemeral-storage": "4Gi",
    "memory": "4Gi"
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.receive<wbr>.topologySpreadConstraints</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  {
    "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"
  }
]</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.receive<wbr>.persistence</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": false
}</pre>
</td>
      <td class="helm-value-desc">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 `replica` external label, which the Compactor deduplicates. 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.storegateway</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "extraArgs": [
    "--store.limits.request-series=5000000",
    "--store.limits.request-samples=200000000"
  ],
  "persistence": {
    "enabled": true,
    "size": "10Gi"
  },
  "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"
    }
  ]
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.storegateway<wbr>.persistence</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "size": "10Gi"
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.storegateway<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "requests": {
    "cpu": "500m",
    "memory": "3Gi"
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.storegateway<wbr>.topologySpreadConstraints</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  {
    "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"
  }
]</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.storegateway<wbr>.extraArgs</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  "--store.limits.request-series=5000000",
  "--store.limits.request-samples=200000000"
]</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.compactor</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><em>enabled with default retention policies</em></td>
      <td class="helm-value-desc">Thanos Compactor configuration. Compactor provides block compaction and downsampling.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.compactor<wbr>.retention</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "resolution1h": "365d",
  "resolution5m": "90d",
  "resolutionRaw": "30d"
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.compactor<wbr>.vpa</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": false
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.compactor<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "requests": {
    "cpu": 1,
    "memory": "2Gi"
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.compactor<wbr>.persistence</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "size": "50Gi"
}</pre>
</td>
      <td class="helm-value-desc">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".
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.queryFrontend</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "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"
    }
  ]
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.queryFrontend<wbr>.autoscaling</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "maxReplicas": 5,
  "minReplicas": 2,
  "targetCPUUtilizationPercentage": 80
}</pre>
</td>
      <td class="helm-value-desc">Horizontal autoscaling for Query Frontend. Stateless like Query, so the same reasoning applies. Inert until `queryFrontend.enabled` is true.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.queryFrontend<wbr>.topologySpreadConstraints</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  {
    "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"
  }
]</pre>
</td>
      <td class="helm-value-desc">Topology spread for Query Frontend: soft on both axes, same reasoning as Query. Inert until `enabled` is true.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.queryFrontend<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "requests": {
    "cpu": "250m",
    "memory": "512Mi"
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">thanos<wbr>.ruler</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": false
}</pre>
</td>
      <td class="helm-value-desc">Thanos Ruler configuration. Ruler provides alerting and recording rules evaluation.
</td>
    </tr>
  </tbody>
</table>

#### Grafana Operator

Bundled Grafana-operator for managing Grafana instances.

Upstream references:
  * https://grafana.github.io/grafana-operator/docs/installation/helm/#values
  * https://github.com/grafana/grafana-operator/blob/master/deploy/helm/grafana-operator/values.yaml

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">grafana-operator<wbr>.priorityClassName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"monitoring-scalable"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana-operator<wbr>.fullnameOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"grafana-operator"</code></td>
      <td class="helm-value-desc">Standard Helm full-name override. We use a static name for deterministic relations.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana-operator<wbr>.namespaceOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>nil</code></td>
      <td class="helm-value-desc">Namespace override.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana-operator<wbr>.crds</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "immutable": true
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
  </tbody>
</table>

#### Grafana Instance

Bundled Grafana for dashboard rendering.

Upstream reference:
  * https://github.com/grafana-community/helm-charts/blob/main/charts/grafana/values.yaml

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](https://materializeinc.github.io/materialize-monitoring/operating/production-best-practices/#grafana)
for the full checklist.

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">grafana<wbr>.fullnameOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"grafana"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.namespaceOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>nil</code></td>
      <td class="helm-value-desc">Namespace override.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.priorityClassName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"monitoring-scalable"</code></td>
      <td class="helm-value-desc">Scheduling priority. See the Priority classes section.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.image</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "pullPolicy": "IfNotPresent",
  "registry": "docker.io",
  "repository": "grafana/grafana",
  "tag": "13.2.2"
}</pre>
</td>
      <td class="helm-value-desc">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:

  * Chainguard Images — https://images.chainguard.dev/directory/image/grafana/overview
  * Docker Hardened Images — https://docs.docker.com/dhi/ (subscription;
    images land in your own org namespace, so `registry` is yours)

`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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.replicas</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>1</code></td>
      <td class="helm-value-desc">Number 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.autoscaling</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": false,
  "maxReplicas": 5,
  "minReplicas": 1,
  "targetCPU": "60",
  "targetMemory": ""
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.podDisruptionBudget</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "maxUnavailable": 1
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "limits": {
    "memory": "1Gi"
  },
  "requests": {
    "cpu": "100m",
    "memory": "256Mi"
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.persistence</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "accessModes": [
    "ReadWriteOnce"
  ],
  "enabled": false,
  "size": "10Gi"
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.deploymentStrategy</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "type": "RollingUpdate"
}</pre>
</td>
      <td class="helm-value-desc">Deployment update strategy. Must be `Recreate` alongside a `ReadWriteOnce` PersistentVolume; see `persistence`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.service</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "annotations": {},
  "enabled": true,
  "loadBalancerSourceRanges": [],
  "port": 80,
  "targetPort": 3000,
  "type": "ClusterIP"
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.ingress</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "annotations": {},
  "enabled": false,
  "hosts": null,
  "ingressClassName": "",
  "labels": {},
  "path": "/",
  "pathType": "Prefix",
  "tls": null
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>."grafana<wbr>.ini"</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "analytics": {
    "check_for_updates": false,
    "reporting_enabled": false
  },
  "date_formats": {
    "default_timezone": "UTC"
  }
}</pre>
</td>
      <td class="helm-value-desc">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:

```yaml
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](https://materializeinc.github.io/materialize-monitoring/dashboards/grafana/auth/).
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.networkPolicy</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "allowExternal": true,
  "egress": {
    "enabled": false
  },
  "enabled": true,
  "explicitIpBlocks": [],
  "explicitNamespacesSelector": {},
  "ingress": true
}</pre>
</td>
      <td class="helm-value-desc">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, `grafana.com`
if `plugins` is non-empty. 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.networkPolicy<wbr>.ingress</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Emit the ingress rule. False renders a policy with no rules at all, which — with egress off too — is an empty spec, not a deny.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.networkPolicy<wbr>.allowExternal</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Accept from any source. See the note above before turning this off.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.networkPolicy<wbr>.explicitNamespacesSelector</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{}</pre>
</td>
      <td class="helm-value-desc">Namespaces allowed in when `allowExternal` is false. The ingress controller's namespace goes here.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.networkPolicy<wbr>.explicitIpBlocks</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">CIDRs allowed in when `allowExternal` is false. Load-balancer client and health-check ranges go here.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.networkPolicy<wbr>.egress<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>false</code></td>
      <td class="helm-value-desc">Leave off. See the note above: a partial egress allowlist for Grafana fails as empty panels rather than as an error.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.plugins</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[]</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.imageRenderer</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": false
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.testFramework</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": false
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.assertNoLeakedSecrets</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Fail 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`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">grafana<wbr>.serviceMonitor</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true
}</pre>
</td>
      <td class="helm-value-desc">Scrape Grafana's own metrics.
</td>
    </tr>
  </tbody>
</table>

#### Alertmanager

Bundled Alertmanager for routing alerts emitted by the rule packages.

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">alertmanager<wbr>.priorityClassName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"monitoring-scalable"</code></td>
      <td class="helm-value-desc">Scheduling priority. See the Priority classes section.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">alertmanager<wbr>.persistence<wbr>.size</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"4Gi"</code></td>
      <td class="helm-value-desc">Volume for silences and the notification log. 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.
</td>
    </tr>
  </tbody>
</table>

#### Kube State Metrics

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

Upstream reference:
   * https://github.com/prometheus-community/helm-charts/blob/main/charts/kube-state-metrics/values.yaml

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">kube-state-metrics<wbr>.priorityClassName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"monitoring-scalable"</code></td>
      <td class="helm-value-desc">Scheduling priority. See the Priority classes section.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">kube-state-metrics<wbr>.networkPolicy</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "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"
        }
      ]
    }
  ]
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">kube-state-metrics<wbr>.prometheus<wbr>.monitor<wbr>.http</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "honorLabels": true
}</pre>
</td>
      <td class="helm-value-desc">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`.
</td>
    </tr>
  </tbody>
</table>

#### Node Exporter

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

Upstream reference:
   * https://github.com/prometheus-community/helm-charts/blob/main/charts/prometheus-node-exporter/values.yaml
   * https://github.com/prometheus/node_exporter#collectors

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.

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">node-exporter<wbr>.fullnameOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"node-exporter"</code></td>
      <td class="helm-value-desc">Standard Helm full-name override. We use a static name for deterministic relations.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">node-exporter<wbr>.namespaceOverride</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>nil</code></td>
      <td class="helm-value-desc">Namespace override.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">node-exporter<wbr>.priorityClassName</td>
      <td class="helm-value-type">string</td>
      <td class="helm-value-default"><code>"monitoring-critical"</code></td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">node-exporter<wbr>.image</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "distroless": true,
  "registry": "quay.io",
  "repository": "prometheus/node-exporter",
  "tag": "v1.12.1"
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">node-exporter<wbr>.resources</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "limits": {
    "memory": "64Mi"
  },
  "requests": {
    "cpu": "10m",
    "memory": "64Mi"
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">node-exporter<wbr>.updateStrategy</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "rollingUpdate": {
    "maxUnavailable": "10%"
  }
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">node-exporter<wbr>.kubeRBACProxy</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": false
}</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">node-exporter<wbr>.networkPolicy</td>
      <td class="helm-value-type">object</td>
      <td class="helm-value-default"><pre>
{
  "enabled": true,
  "ingress": [
    {
      "from": [
        {
          "podSelector": {
            "matchLabels": {
              "app.kubernetes.io/name": "alloy-gateway"
            }
          }
        }
      ],
      "ports": [
        {
          "port": 9100,
          "protocol": "TCP"
        }
      ]
    }
  ]
}</pre>
</td>
      <td class="helm-value-desc">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`.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">node-exporter<wbr>.prometheus<wbr>.monitor<wbr>.enabled</td>
      <td class="helm-value-type">bool</td>
      <td class="helm-value-default"><code>true</code></td>
      <td class="helm-value-desc">Emit 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.
</td>
    </tr>
    <tr>
      <td class="helm-value-key">node-exporter<wbr>.extraArgs</td>
      <td class="helm-value-type">list</td>
      <td class="helm-value-default"><pre>
[
  "--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"
]</pre>
</td>
      <td class="helm-value-desc">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.
</td>
    </tr>
  </tbody>
</table>

#### Metrics Server

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

Upstream reference:
   * https://github.com/kubernetes-sigs/metrics-server/blob/master/charts/metrics-server/values.yaml

<table class="helm-values">
  <thead>
    <th>Key</th><th>Type</th><th>Default</th><th>Description</th>
  </thead>
  <tbody>    <tr>
      <td class="helm-value-key">metrics-server<wbr>.replicas</td>
      <td class="helm-value-type">int</td>
      <td class="helm-value-default"><code>1</code></td>
      <td class="helm-value-desc">Number of replicas for metrics-server.
</td>
    </tr>
  </tbody>
</table>

