materialize-monitoring Helm Reference#
Unified observability stack for Materialize: Alloy-based pipeline, optional bundled backends (Loki / Thanos / Grafana / Alertmanager), and the dashboards / alerts / rules that consume the Materialize metrics surface.
Source Code#
Requirements#
Kubernetes: >=1.27.0-0
Storage Requirements#
Using Thanos or Loki (both enabled by default) requires object storage (such as an AWS S3 bucket) for long-term storage.
In development, Thanos may use a PVC for less-reliable storage. Loki however will not run without object storage. You may consider Garage or RustFS or MinIO for manually provisioned object storage.
Included Subcharts#
| 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 | grafana | 12.11.1 |
| oci://ghcr.io/grafana-community/helm-charts | loki | 18.11.0 |
| oci://ghcr.io/grafana/helm-charts | grafana-operator | 5.24.0 |
| oci://ghcr.io/prometheus-community/charts | alertmanager | 1.42.0 |
| oci://ghcr.io/prometheus-community/charts | kube-state-metrics | 8.4.0 |
| oci://ghcr.io/prometheus-community/charts | prometheus-node-exporter(node-exporter) | 4.56.1 |
| oci://ghcr.io/thanos-community/helm-charts | thanos | 0.30.0 |
Values#
Globals#
Values that are passed to all subcharts
| Key | Type | Default | Description |
|---|---|---|---|
| global | string | "" | Override the registry for all images in this chart and its subcharts. Leave empty to use upstream defaults. |
| global | list | [] | 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:
|
| global | string | "cluster.local" | The cluster's DNS domain.
`cluster.local` is a **default, not a fact** — clusters get built with
`--cluster-domain=cluster.internal` or a site-specific domain often enough
that hardcoding it is a reliable way to ship a feature that works everywhere
we test and nowhere a customer runs. It lives under One subchart is not covered by that propagation. Load-bearing for |
Main chart configuration#
Configuration for the main chart
| Key | Type | Default | Description |
|---|---|---|---|
| nameOverride | string | "" | Standard Helm name override. Note that in umbrella charts, fullname may be shorted. See fullnameOverride. |
| fullnameOverride | string | "mzmon" | Standard Helm fullname override. Note that in umbrella charts, this may be shortened to avoid long prefixes. (and have more stable names in resources) |
| namespaceOverride | string | "" | Namespace override for default workloads. |
Subchart enablement (Helm tags)#
Group toggles for subchart enablement. Subchart enablement has two layers, in precedence order:
- Per-chart circuit breaker (
<chart>.enabled). Each subchart dependency carries acondition: <chart>.enabledinChart.yaml. The correspondingenabled:key is left commented-out under each subchart block invalues.yaml; uncommenting it force-includes (true) or force-excludes (false) that chart regardless of any tag state. Helm evaluatescondition:first and only falls through to tags when the path is absent from values, so this is a hard override. - Helm tags (the
tags:block). When no<chart>.enabledis set, enablement is decided by tags. Each dependency inChart.yamlcarries the masterdefaulttag, 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.)
| Key | Type | Default | Description |
|---|---|---|---|
| tags | bool | true | Enable all recommended defaults. You can `--set tags.default=false` to disable all services and explicitly enable others. |
| tags | bool | false | Enable both Alloy releases (agent + gateway). |
| tags | bool | false | Enable Loki, Thanos, and Alertmanager as a group. (Grafana is in the `managed-grafana` group.) |
| tags | bool | false | Enable kube-state-metrics and metrics-server as a group. |
| tags | bool | false | Per-chart override: enable just the Alloy agent. OR'd with `tags.default` / `tags.pipeline`. |
| tags | bool | false | Per-chart override: enable just the Alloy gateway. OR'd with `tags.default` / `tags.pipeline`. |
| tags | bool | false | Per-chart override: enable just Loki. OR'd with `tags.default` / `tags.bundled-backends`. |
| tags | bool | false | Per-chart override: enable just Thanos. OR'd with `tags.default` / `tags.bundled-backends`. |
| tags | bool | false | Per-chart override: enable just Grafana standalone. OR'd with `tags.default` / `tags.managed-grafana`. |
| tags | bool | false | Per-chart override: enable just Grafana operator. OR'd with `tags.default` / `tags.managed-grafana`. |
| tags | bool | false | Per-chart override: enable just Alertmanager. OR'd with `tags.default` / `tags.bundled-backends`. |
| tags | bool | false | Per-chart override: enable just kube-state-metrics. OR'd with `tags.default` / `tags.cluster-metrics`. |
| tags | bool | false | Per-chart override: enable just node-exporter. OR'd with `tags.default` / `tags.cluster-metrics`. |
| tags | bool | false | Per-chart override: enable just metrics-server. OR'd with `tags.cluster-metrics`. |
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.
| Key | Type | Default | Description |
|---|---|---|---|
| priorityClasses | bool | true | 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. |
| priorityClasses | object |
{
"description": "Materialize monitoring components whose loss creates a blind spot with no replica to cover it. Never preempts other workloads.",
"name": "monitoring-critical",
"preemptionPolicy": "Never",
"value": 1000000
} | Priority for components whose loss creates an unrecoverable blind spot — the per-node collectors and the egress gateway. |
| priorityClasses | object |
{
"description": "Materialize monitoring backends that are replicated or write-buffered. Evicted before the critical tier; never preempts other workloads.",
"name": "monitoring-scalable",
"preemptionPolicy": "Never",
"value": 1000
} | Priority for replicated or buffered components, where losing a pod costs capacity rather than visibility. |
Network policies#
NetworkPolicies for the components whose own charts ship none. Most of the stack polices itself, and each component is configured in its own block further down:
| 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 inkind, 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.networkPolicycarries the same caveat at greater length, and it is the same caveat. - Rules written with
podSelectoralone stop at the namespace boundary. Underprofiles/split-namespace.values.yamlthe components no longer share a namespace, so those rules match nothing — addnamespaceSelectorentries through theextrahooks 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. |
| Key | Type | Default | Description |
|---|---|---|---|
| networkPolicies | bool | true | Render the policies in this block. Off skips all three at once; the per-component `enabled` keys below override it either way. |
| networkPolicies | object |
{
"egress": {
"dns": true,
"external": {
"cidrs": [
"0.0.0.0/0"
],
"ports": [
443,
587,
465
]
},
"extra": [],
"inNamespace": true
},
"enabled": null,
"ingress": {
"allowExternal": false,
"extra": [],
"ports": [
9093,
{
"port": 9094,
"protocol": "TCP"
},
{
"port": 9094,
"protocol": "UDP"
}
]
}
} | Alertmanager: reachable from the release namespace, free to egress to notification providers. Ingress covers
Both are listed even at the default 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 — |
| networkPolicies | object |
{
"egress": {
"dns": true,
"external": {
"cidrs": [
"0.0.0.0/0"
],
"ports": [
443,
6443
]
},
"extra": [],
"inNamespace": true
},
"enabled": null,
"ingress": {
"allowExternal": false,
"extra": [],
"ports": [
9090
]
}
} | grafana-operator: metrics scraped by the Alloy gateway, egress to the API server and to whichever Grafana it reconciles. Ingress is only the metrics port. Egress carries
|
| networkPolicies | object |
{
"enabled": null,
"port": 9094
} | Grafana's unified-alerting gossip port, which the `grafana` subchart's own policy cannot open. A supplement, not a replacement: 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 |
| networkPolicies | object |
{
"egress": {
"dns": true,
"external": {
"cidrs": [
"0.0.0.0/0"
],
"ports": [
10250,
443,
6443
]
},
"extra": [],
"inNamespace": true
},
"enabled": null,
"ingress": {
"allowExternal": true,
"extra": [],
"ports": [
10250
]
}
} | metrics-server: serves the metrics API to the API server, scrapes every kubelet. Both directions are broad, and both for the same reason: the peers are the
control plane and the nodes, neither of which has an address this chart can
select. 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 |
Certificates#
cert-manager Certificate resources for in-cluster TLS.
Off by default, and cert-manager is never a hard dependency. With
enabled: false this section renders nothing at all, which is what keeps a
hardening feature from becoming a new prerequisite for an install that does not
want it.
When it is on, the chart renders one Certificate per component with the full
SAN ladder for that component’s Services. It does not turn TLS on anywhere:
issuing a certificate and using it are separate switches, on purpose. Each hop
moves through the phases in
Securing
under its own flag, once that component’s renewal behaviour has been proven.
Gated on values, never on .Capabilities. The obvious implementation is to
probe the API server for cert-manager.io/v1, and it is wrong: the same chart
would then render differently under helm template, the tier-0 Terraform render
check, and an ArgoCD server-side diff than it does under a live install — which
is exactly the class of bug the render tests exist to catch. A missing CRD with
this flag on is a clear apply-time failure with a name in it.
Two issuers, because they cannot be one#
| 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 issuerRefs for that
reason and no other.
The external certificate is only needed for an L4 load balancer, which
passes TCP through and leaves TLS to terminate at the pod — so the material has
to exist in the cluster. An L7 load balancer terminating with a cloud-managed
certificate (ACM, Google Certificate Manager, Azure Key Vault) attaches it by
ARN or resource ID and the key never enters the cluster; for that shape, leave
external unset and pass the annotation through grafana.service.annotations.
| Key | Type | Default | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| certificates | bool | false | Render the cert-manager resources in this section. | ||||||||||||
| certificates | object |
{
"issuerRef": {
"group": "cert-manager.io",
"kind": "ClusterIssuer",
"name": ""
},
"selfSigned": {
"caSecretNamespace": "cert-manager",
"commonName": "mzmon-internal-ca",
"duration": "43800h",
"enabled": false,
"kind": "ClusterIssuer",
"renewBefore": "2160h",
"secretName": "mzmon-internal-ca"
}
} | The internal mesh: certificates the stack's components present to each other, carrying in-cluster DNS names. Supply an issuer with 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 | ||||||||||||
| certificates | object |
{
"group": "cert-manager.io",
"kind": "ClusterIssuer",
"name": ""
} | An existing cert-manager issuer to sign internal certificates. `kind` is `Issuer` or `ClusterIssuer`. Leave `name` empty to use `selfSigned` instead. | ||||||||||||
| certificates | object |
{
"caSecretNamespace": "cert-manager",
"commonName": "mzmon-internal-ca",
"duration": "43800h",
"enabled": false,
"kind": "ClusterIssuer",
"renewBefore": "2160h",
"secretName": "mzmon-internal-ca"
} | Have the chart bootstrap a self-signed root and issue from it. Renders the three resources cert-manager needs for a private CA: a
Convenient rather than principled — a real deployment usually has a PKI
already, and
| ||||||||||||
| certificates | string | "mzmon-internal-ca" | Subject common name on the generated root. | ||||||||||||
| certificates | string | "43800h" | Lifetime of the **root**, not of the leaves. Long, because rotating a root means re-trusting it everywhere at once. | ||||||||||||
| certificates | string | "mzmon-internal-ca" | Secret the generated root's key pair lands in. | ||||||||||||
| certificates | string | "cert-manager" | Namespace that Secret is created in.
**cert-manager's cluster resource namespace, not this release's.** A
`ClusterIssuer` of kind `ca` reads its Secret from wherever cert-manager
was told its cluster resources live — `cert-manager` unless the install
overrode `--cluster-resource-namespace` — regardless of where the
`Certificate` that produced it was created. Render the CA into the
release namespace and the issuer sits `False` with `secret not found`
while the Secret it wants is one namespace away. Only read for | ||||||||||||
| certificates | object |
{
"dnsNames": [],
"issuerRef": {
"group": "cert-manager.io",
"kind": "ClusterIssuer",
"name": ""
}
} | The browser-facing name, for a Grafana behind an L4 load balancer. Only Grafana takes an external certificate today, because it is the only
component in the stack meant for a human and the only one this chart helps
expose. Set Leave | ||||||||||||
| certificates | list | [] | Public DNS names to put on the certificate. | ||||||||||||
| certificates | string | "2160h" | Lifetime of each component certificate, and how long before expiry cert-manager renews it. Short durations are the only mitigation available for the fact that nothing here can check revocation — `otelcol.receiver.otlp` has no CRL support, and in-cluster there is no load balancer to act as a checkpoint, so revoking means re-issuing the CA. They also raise the cost of a component that does not reload its certificate cleanly, which is why no hop turns on until its rotation behaviour is proven. 90 days with 30 days of headroom is a starting point rather than a researched one. | ||||||||||||
| certificates | object |
{
"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"
]
}
} | 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.
Each entry takes:
|
Materialize Integration#
Materialize-specific configuration values.
| Key | Type | Default | Description |
|---|---|---|---|
| materialize | list | [] | Namespaces to scrape Materialize workloads from. Empty list means all namespaces the chart can read. |
| materialize | object |
{
"enabled": true,
"metricEndpoints": [],
"selector": {},
"targetLabels": []
} | ServiceMonitor/PodMonitor configuration for environmentd |
| materialize | bool | true | Deploy a PodMonitor to collect Materialize metrics. |
| materialize | object |
{} | Override for default selector |
| materialize | list | [] | Override for default labels to copy from the pod into metrics |
| materialize | list | [] | Override for default metric endpoints |
| materialize | object |
{
"enabled": true,
"metricEndpoints": [],
"selector": {},
"targetLabels": []
} | ServiceMonitor/PodMonitor configuration for environmentd-sql |
| materialize | bool | true | Deploy a PodMonitor to collect Materialize metrics. |
| materialize | object |
{} | Override for default selector |
| materialize | list | [] | Override for default labels to copy from the pod into metrics |
| materialize | list | [] | Override for default metric endpoints |
| materialize | object |
{
"enabled": true,
"metricEndpoints": [],
"selector": {},
"targetLabels": []
} | ServiceMonitor/PodMonitor configuration for clusterd |
| materialize | bool | true | Deploy a PodMonitor to collect Materialize metrics. |
| materialize | object |
{} | Override for default selector |
| materialize | list | [] | Override for default labels to copy from the pod into metrics |
| materialize | list | [] | Override for default metric endpoints |
| materialize | string | "self-managed" | Deployment mode normalization hint. One of: `self-managed`, `cloud`. Drives relabeling rules in the pipeline. |
| materialize-system | string | "materialize-environment" | The namespace materialize-system was deployed into |
| materialize-system | object |
{
"enabled": true
} | ServiceMonitor/PodMonitor configuration TODO: nothing uses this yet |
| materialize-operator | string | "materialize" | The namespace materialize-operator was deployed into |
| materialize-operator | object |
{
"enabled": true,
"metricEndpoints": [],
"selector": {},
"targetLabels": []
} | ServiceMonitor/PodMonitor configuration |
| materialize-operator | object |
{} | Override for default selector |
| materialize-operator | list | [] | Override for default labels to copy from the pod into metrics |
| materialize-operator | list | [] | Override for default metric endpoints |
Pipeline configuration#
Pipeline configuration values that drive Alloy behavior and defaults.
The Alloy pipeline is the flagship deliverable of this chart.
Pipeline configuration is expressed in values, rendered into ConfigMaps under
templates/pipelines/, and consumed by the Alloy agent and gateway
release instances.
| Key | Type | Default | Description |
|---|---|---|---|
| pipeline | bool | true | Run a hook/dependency to validate rendered configs before rolling them out |
| pipeline | object |
{
"helm.sh/hook": "pre-install,pre-upgrade",
"helm.sh/hook-delete-policy": "before-hook-creation"
} | Job specific annotations This should be used to control when the job is executed. The default is as a helm hook, but setting any other annotation overrides this. |
| pipeline | int | 1 | Number of times to retry the job before failing the release. |
| pipeline | object |
{
"fsGroup": 473,
"runAsGroup": 473,
"runAsUser": 473
} | Security context for the pre-validate job pod. This is the hardened recommendation with the alloy user. |
| pipeline | object |
{
"allowPrivilegeEscalation": false,
"capabilities": {
"drop": [
"ALL"
]
},
"readOnlyRootFilesystem": true,
"runAsGroup": 473,
"runAsNonRoot": true,
"runAsUser": 473
} | Security context for the pre-validate job container. This is the hardened recommendation with the alloy user. |
| pipeline | object |
{} | Environment variables to set in agent and gateway pods. These support tpl rendering. Since these are injected into an envFrom, any env entries take precedence. |
| pipeline | string | "default" | Name of the cluster to discriminate workloads from different sources. |
| pipeline | string | "info" | Level for the gateway logs. |
Log configuration#
Configuration for log behavior
| Key | Type | Default | Description | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| pipeline | int | 5000 | Rate limit for alloy agent incoming pod logs. This is per agent. | ||||||||||||||||||||||||
| pipeline | int | 20000 | Burst limit for alloy agent incoming pod logs. | ||||||||||||||||||||||||
| pipeline | string | "http://alloy-gateway.{{ include \"mzmon.alloyGateway.namespace\" $ }}.svc:3100/loki/api/v1/push" | alloy-gateway push endpoint URL. | ||||||||||||||||||||||||
| pipeline | object |
{
"maxBackoffPeriod": "5m",
"maxBackoffRetries": 10,
"minBackoffPeriod": "1s",
"retryOnHttp429": true
} | Retry configuration. | ||||||||||||||||||||||||
| pipeline | string | "none" | Type of authentication to use with the alloy-gateway endpoint. Use none if no authentication is required. | ||||||||||||||||||||||||
| pipeline | bool | false | Whether to enable TLS for alloy-gateway dest. | ||||||||||||||||||||||||
| pipeline | bool | true | Whether to verify the TLS certificate for the alloy-gateway dest. | ||||||||||||||||||||||||
| pipeline | string | "" | Certificate Authority (CA) PEM contents for TLS. | ||||||||||||||||||||||||
| pipeline | string | "" | Client certificate PEM contents for TLS. | ||||||||||||||||||||||||
| pipeline | string | "" | Client private key PEM contents for TLS. | ||||||||||||||||||||||||
| pipeline | string | "" | 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
Inline PEM stays supported as the bring-your-own-PKI escape hatch. | ||||||||||||||||||||||||
| pipeline | string | "" | Alternative SNI (Server Name Indication) to specify. | ||||||||||||||||||||||||
| pipeline | string | "TLS13" | Minimum TLS version to allow. Use TLS12 if you need better compat. TLS11 and TLS10 are not recommended for production. | ||||||||||||||||||||||||
| pipeline | object |
{
"tls": {
"certFile": "",
"clientAuth": "NoClientCert",
"clientCAFile": "",
"enabled": false,
"keyFile": "",
"minVersion": "TLS13",
"reloadInterval": "1m"
}
} | Server-side TLS on this role's listeners.
The other half of the `destination.*.tls` blocks: those configure Alloy
as a *client*, this configures it as a *server*. A configured client
against an unconfigured server is TLS off, which is why the two have to
move together. The gateway has three listeners, not two, and they are split across the two pipeline trees the same way the listeners are:
Unlike Loki’s server, Alloy’s listeners are not probed by the kubelet on
the same port — the readiness probe is on | ||||||||||||||||||||||||
| pipeline | bool | false | Serve TLS on this role's listeners. | ||||||||||||||||||||||||
| pipeline | string | "" | Certificate and key the listener presents. Files rather than inline PEM, because cert-manager renews by rewriting the Secret and Alloy re-reads the file on new connections. | ||||||||||||||||||||||||
| pipeline | string | "" | CA used to verify client certificates. Required by every `clientAuth` value except `NoClientCert`. | ||||||||||||||||||||||||
| pipeline | string | "NoClientCert" | How hard to insist on a client certificate. See the table above. | ||||||||||||||||||||||||
| pipeline | string | "TLS13" | Minimum TLS version the listener accepts. | ||||||||||||||||||||||||
| pipeline | string | "1m" | How often the OTLP listeners re-read their certificate files. `otelcol.receiver.otlp` only — the `loki.source.api` listener has no equivalent and re-reads on new connections instead. This is the best renewal behaviour of anything in the stack, so it is worth setting: without it a long-lived gRPC stream can outlive the certificate that established it. | ||||||||||||||||||||||||
| pipeline | bool | true | Enable writing to a loki destination. By default, we use the in-cluster loki | ||||||||||||||||||||||||
| pipeline | string | "http://loki-distributor.{{ include \"mzmon.loki.namespace\" $ }}.svc:3100/loki/api/v1/push" | Loki push endpoint URL. | ||||||||||||||||||||||||
| pipeline | object |
{
"maxBackoffPeriod": "5m",
"maxBackoffRetries": 10,
"minBackoffPeriod": "1s",
"retryOnHttp429": true
} | Retry configuration. | ||||||||||||||||||||||||
| pipeline | string | "none" | Type of authentication to use with the loki endpoint. Use none if no authentication is required. Use basicAuth for username/password. Use bearer for bearer token. Use oauth2 for OAuth2 client credentials. | ||||||||||||||||||||||||
| pipeline | object |
{
"password": "",
"passwordEnv": "GATEWAY_LOKI_DEST_PASSWORD",
"username": "",
"usernameEnv": "GATEWAY_LOKI_DEST_USERNAME"
} | Configuration for auth when using authType=basicAuth You will need to provide alloy-gateway.alloy.agent.extraEnv TODO: add a check for this | ||||||||||||||||||||||||
| pipeline | object |
{
"token": "",
"tokenEnv": "GATEWAY_LOKI_DEST_BEARER_TOKEN"
} | Configuration for bearer token when using authType=bearer This is used for bearer type tokens. | ||||||||||||||||||||||||
| pipeline | object |
{
"clientId": "",
"clientIdEnv": "GATEWAY_LOKI_DEST_OAUTH2_CLIENT_ID",
"clientSecret": "",
"clientSecretEnv": "GATEWAY_LOKI_DEST_OAUTH2_CLIENT_SECRET",
"scopes": [],
"tokenUrl": "",
"tokenUrlEnv": "GATEWAY_LOKI_DEST_OAUTH2_TOKEN_URL"
} | Configuration for OAuth2 when using authType=oauth2 | ||||||||||||||||||||||||
| pipeline | bool | false | Whether to enable TLS for the loki destination. | ||||||||||||||||||||||||
| pipeline | bool | true | Whether to verify the TLS certificate for the loki destination. | ||||||||||||||||||||||||
| pipeline | string | "" | Certificate Authority (CA) PEM contents for TLS. | ||||||||||||||||||||||||
| pipeline | string | "" | Client certificate PEM contents for TLS. | ||||||||||||||||||||||||
| pipeline | string | "" | Client private key PEM contents for TLS. | ||||||||||||||||||||||||
| pipeline | string | "" | 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
Inline PEM stays supported as the bring-your-own-PKI escape hatch. | ||||||||||||||||||||||||
| pipeline | string | "" | Alternative SNI (Server Name Indication) to specify. | ||||||||||||||||||||||||
| pipeline | string | "TLS13" | Minimum TLS version to allow. Use TLS12 if you need better compat. TLS11 and TLS10 are not recommended for production. | ||||||||||||||||||||||||
| pipeline | bool | false | 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. | ||||||||||||||||||||||||
| pipeline | string | "loki" | Default tenant to write logs to. This is used when tenantMap values is set to `static`. | ||||||||||||||||||||||||
| pipeline | object |
{
"audit": "static",
"default": "static",
"environment": "static",
"infra": "static"
} | Type of tenancy used to write logs. Use static to write to the staticTenant value (recommended). Use byEnvironment to separate by the environment name. Use byNamespace to use the namespace of the source pod as the tenant. Use byLabel to use a label (tenantLabel) to specifically identify the tenant. Use none to disable tenancy (only use if loki does not have tenancy). |
Metrics configuration#
Configuration for metrics behavior
| Key | Type | Default | Description | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| pipeline | h5 | {"scrapeInterval":"60s", "tlsInsecureSkipVerify":false} | Node-local container metrics, scraped from each node's kubelet. The gateway scrapes Coverage is not the tradeoff it was assumed to be: a GKE kubelet serves 69
distinct Needs | ||||||||||||||||||||||||
| pipeline | string | "60s" | Scrape interval for the kubelet cAdvisor endpoint. The dominant cost lever — roughly 6.7k series per node per scrape. | ||||||||||||||||||||||||
| pipeline | bool | false | 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 | ||||||||||||||||||||||||
| pipeline | list | [] | Denylist of metrics that are excluded from being exported These are |'d in a regex pattern | ||||||||||||||||||||||||
| pipeline | object |
{
"tls": {
"certFile": "",
"clientAuth": "NoClientCert",
"clientCAFile": "",
"enabled": false,
"keyFile": "",
"minVersion": "TLS13",
"reloadInterval": "1m"
}
} | Server-side TLS on this role's listeners.
The other half of the `destination.*.tls` blocks: those configure Alloy
as a *client*, this configures it as a *server*. A configured client
against an unconfigured server is TLS off, which is why the two have to
move together. The gateway has three listeners, not two, and they are split across the two pipeline trees the same way the listeners are:
Unlike Loki’s server, Alloy’s listeners are not probed by the kubelet on
the same port — the readiness probe is on | ||||||||||||||||||||||||
| pipeline | bool | false | Serve TLS on this role's listeners. | ||||||||||||||||||||||||
| pipeline | string | "" | Certificate and key the listener presents. Files rather than inline PEM, because cert-manager renews by rewriting the Secret and Alloy re-reads the file on new connections. | ||||||||||||||||||||||||
| pipeline | string | "" | CA used to verify client certificates. Required by every `clientAuth` value except `NoClientCert`. | ||||||||||||||||||||||||
| pipeline | string | "NoClientCert" | How hard to insist on a client certificate. See the table above. | ||||||||||||||||||||||||
| pipeline | string | "TLS13" | Minimum TLS version the listener accepts. | ||||||||||||||||||||||||
| pipeline | string | "1m" | How often the OTLP listeners re-read their certificate files. `otelcol.receiver.otlp` only — the `loki.source.api` listener has no equivalent and re-reads on new connections instead. This is the best renewal behaviour of anything in the stack, so it is worth setting: without it a long-lived gRPC stream can outlive the certificate that established it. |
Prometheus remote-write destinations.#
Prometheus remote-write destinations, as a map of name to destination.
A map, not a single destination. Each key names one destination and
becomes the label on that destination’s Alloy components, so the name is
visible in component_id on the gateway’s own metrics and in its UI.
The default map holds one entry, thanos, pointing at the bundled
in-cluster Thanos Receive.
Every destination gets its own prometheus.remote_write component
rather than a second endpoint block on a shared one. That costs a WAL
per destination and buys the two things a shared component cannot give:
- Its own
minMetricImportance. The tier filter is aprometheus.relabelupstream of the component, so a destination onessentialwrites a WAL holding only essential series. A shared component can only filter withwrite_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.
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.
| Key | Type | Default | Description |
|---|---|---|---|
| pipeline | bool | false | 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) |
| pipeline | bool | false | Enable writing to a Google Cloud Monitoring / Cloud Logging destination. |
| pipeline | string | "gzip" | Compression for logs/metrics Only gzip is supported for Google Cloud Monitoring / Logging. |
| pipeline | string | "recommended" | Only export metrics with the specified importance level. Values are "essential", "recommended", "extended", "diagnostic", "all" |
| pipeline | list | [ "otelcol.exporter.googlecloud.destination.input" ] | Handlers to use for the Google Cloud exporter. |
| pipeline | string | An `otelcol.exporter.googlecloud.destination` definition. | Raw configuration for an otelcol.exporter.googlecloud block. The default config uses Workload Identity Federation (WIF) to authenticate to GCP. |
| pipeline | bool | false | Enable writing to a Datadog destination. |
| pipeline | string | "gzip" | Compression for logs/metrics Only gzip is supported for Datadog. |
| pipeline | string | "recommended" | Only export metrics with the specified importance level. Values are "essential", "recommended", "extended", "diagnostic", "all" |
| pipeline | bool | false | Whether to include host metadata in the Datadog exporter. FIXME: how do we support this in an Agent->Gateway architecture? |
| pipeline | list | [ "otelcol.exporter.datadog.destination.input" ] | Handlers to use for the Datadog exporter. |
| pipeline | string | An `otelcol.exporter.datadog.destination` definition. | Raw configuration for an otelcol.exporter.datadog block. |
| pipeline | bool | false | 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. |
| pipeline | string | "" | OTLP push endpoint URL. This does not need a protocol prefix (http:// or https://) |
| pipeline | string | "grpc" | Protocol to use for OTLP. Use grpc for gRPC protocol. Use http for HTTP protocol. |
| pipeline | string | "gzip" | Compression to use Use gzip for better compatibility. Use snappy for better performance. |
| pipeline | string | "all" | Only export metrics with the specified importance level. Values are "essential", "recommended", "extended", "diagnostic", "all" |
| pipeline | list | [ "otelcol.exporter.otlp[http].destination.input" ] | Handlers to use for the OTLP exporter. Update this if your config was customized. |
| pipeline | string | {{- $exporterType := ternary "otlp" "otlphttp" ( eq .Values.pipeline.metrics.gateway.destination.otel.otlpExporter.protocol "grpc" ) }}
otelcol.exporter.{{ $exporterType }} "destination" {
client {
endpoint = {{ .Values.pipeline.metrics.gateway.destination.otel.otlpExporter.url | required "destination.otlp.url must be set" | quote }}
compression = {{ .Values.pipeline.metrics.gateway.destination.otel.otlpExporter.compression | quote }}
{{- if ( include "mzmon.alloyGateway.otelDest.authEnabled" $ ) }}
auth = {{ include "mzmon.alloyGateway.otelDest.authHandler" $ }}
{{- end }}
}
}
| Raw configuration for an otelcol.exporter.otlp block. Use this if you need to configure multiple destinations or use an alternative endpoint entirely. |
OpenTelemetry Authentication Configuration#
Configuration for OpenTelemetry/OTLP destinations. This is only needed for destinations that require an auth handler (like otlpExporter).
| Key | Type | Default | Description |
|---|---|---|---|
| pipeline | string | "none" | Type of authentication to use with the OpenTelemetry destination. Valid values are: `none`, `basic`, `bearer`, `headers`, `awsSigv4`, and `custom`. |
| pipeline | string | An `otelcol.auth.basic.oteldest` definition. | Raw configuration for an otelcol.auth.basic block. This uses the GATEWAY_OTEL_DEST_USERNAME/GATEWAY_OTEL_DEST_PASSWORD env vars. |
| pipeline | string | An `otelcol.auth.bearer.oteldest` definition. | Raw configuration for an otelcol.auth.bearer block. This uses the GATEWAY_OTEL_DEST_BEARER_TOKEN env var. |
| pipeline | list | [] | 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 Each entry names a header with
Unlike the Loki and Prometheus destinations, there is no
|
| pipeline | string | An `otelcol.auth.headers.oteldest` definition built from `headers`. | Raw configuration for an otelcol.auth.headers block. |
| pipeline | string | "" | Override the region to sign requests for. |
| pipeline | string | "" | Override the role ARN to assume for signing requests. |
| pipeline | string | An `otelcol.auth.sigv4.oteldest` definition. | Raw configuration for an otelcol.auth.sigv4 block. |
| pipeline | string | "{{ fail \"Be sure to set this\" }}" | Handler for a custom auth handler (Advanced escape hatch). This should point to the definition you used. Most auth types use `.handler` for their capsule export. If you need multiple handlers, you can skip this field and instead modify your otlpExporter.config to not use "mzmon.alloyGateway.otelDest.authHandler". |
| pipeline | string | // THIS IS AN EXAMPLE
otelcol.auth.basic "oteldest" {
client_auth {
username = sys.env("GATEWAY_OTEL_DEST_USERNAME")
password = sys.env("GATEWAY_OTEL_DEST_PASSWORD")
}
}
| Raw configuration for a custom auth handler. |
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.
| Key | Type | Default | Description |
|---|---|---|---|
| dashboards | bool | true | Install the bundled Grafana dashboards. Requires the Grafana operator or a writable Grafana instance. |
| dashboards | string | "operator" | Grafana deployment mode, either "standalone" (the bundled Grafana chart) or "operator" (a separate Grafana Operator instance). |
| dashboards | string | "5m" | Time to sync the dashboard from the manifest |
| dashboards | object |
{} | 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. |
| dashboards | string | inferred | 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. |
| dashboards | string | "dashboard.grafana.app/v2" | Dashboard API Version (v2 or v2beta1) |
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. |
| Key | Type | Default | Description |
|---|---|---|---|
| dashboards | bool | false | Install the bundled Datadog dashboards. Requires Datadog API credentials configured out-of-band. |
| dashboards | list | [ "env-*", "infra-*" ] | List of dashboard patterns to render |
Rule configuration#
Configuration for rules
| Key | Type | Default | Description |
|---|---|---|---|
| config | bool | true | Install the bundled Prometheus recording and alerting rules as PrometheusRule resources. |
| config | bool | false | Install the bundled Loki rules. |
| config | bool | false | Install the bundled Thanos rules. |
Alert configuration#
Configuration for alerts
| Key | Type | Default | Description |
|---|---|---|---|
| config | bool | true | Install the bundled Alertmanager routing and templates. |
Scraper configuration#
Configuration for scrapers
| Key | Type | Default | Description |
|---|---|---|---|
| config | bool | true | Install ServiceMonitors / Alloy scrape configs for Materialize and adjacent components. |
Grafana connection configuration#
How to talk to a grafana instance
| Key | Type | Default | Description |
|---|---|---|---|
| connections | string | "bundled" | How this establishes its connection to Grafana. `bundled` (default) targets the Grafana deployed by the bundled `grafana` subchart; the URL and admin-credential Secret are derived from it. `external` targets a Grafana you already run — Grafana Cloud, a shared platform Grafana, another cluster — and requires `external.url` plus either `external.apiKey` or `external.adminUser` + `external.adminPassword`. `operator` hands the instance lifecycle to grafana-operator itself, which builds it from the operator's own defaults and is not yet production-ready. |
| connections | object |
{} | Additional labels applied to the Grafana instance, and to the `instanceSelector` of every Grafana resource this chart targets at it. Merged over a static `monitoring.materialize.cloud/grafana-instance: mzmon` label, which is what keeps the selector non-empty — grafana-operator reads an empty `matchLabels` as *every* instance, not none. Add to this to narrow the selector further, e.g. to scope per release when two `materialize-monitoring` releases share a cluster. |
| connections | bool | false | 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 |
| connections | object |
{} | 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 Reference: https://grafana.github.io/grafana-operator/docs/api/#grafanaspec |
| connections | string | "" | External grafana uri |
| connections | object |
{} | Secret for Grafana admin password |
| connections | object |
{} | Secret for Grafana admin user |
| connections | object |
{} | Secret for Grafana API key |
Datasource configuration#
Datasources provisioned into the Grafana instance
Provisioned as GrafanaDatasource resources, pushed into the same instance
the dashboards target. url values are rendered with tpl, so they may
reference chart helpers.
| Key | Type | Default | Description |
|---|---|---|---|
| connections | bool | true | Install the bundled datasources. Requires the Grafana operator. |
| connections | string | "5m" | How often the operator re-pushes each datasource. |
| connections | bool | false | 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. |
| connections | string | follows `thanos.enabled | 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. |
| connections | string | "Thanos" | Datasource name, as shown in Grafana. |
| connections | string | "mzmon-thanos" | Stable datasource UID. |
| connections | string | "http://thanos-query.{{ include \"mzmon.thanos.namespace\" $ }}.svc:9090" | Thanos Query endpoint. Rendered with `tpl`. |
| connections | bool | true | 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. |
| connections | object |
{
"caPem": "",
"caSecret": {
"key": "ca.crt",
"name": ""
},
"clientCert": {
"certKey": "tls.crt",
"keyKey": "tls.key",
"secretName": ""
},
"enabled": null,
"serverName": ""
} | TLS for Grafana's connection to this backend.
Grafana does not read certificate material from files. It stores it in
its own database as `secureJsonData`, provisioned through the datasource
— which is why this is the one hop in the stack that **does not renew on
its own**. cert-manager rewriting the Secret changes nothing until the
datasource is re-provisioned; grafana-operator does that every
`connections.datasources.resyncPeriod`, so the material is refreshed on
that cadence rather than on the certificate's.
Prefer this over |
| connections | string | follows the URL scheme | 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. |
| connections | string | "" | 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 |
| connections | object |
{
"key": "ca.crt",
"name": ""
} | Secret holding the CA bundle, referenced rather than inlined.
Rendered as a `valuesFrom` entry on the `GrafanaDatasource` plus a
placeholder for the operator to substitute into. Survives a CA
rotation, which `caPem` does not, so this is the better default
whenever the CA already lives in the cluster — a cert-manager issuer's
own Secret, say. The Secret must be in the Grafana instance’s namespace, which is
not necessarily this release’s under
|
| connections | object |
{
"certKey": "tls.crt",
"keyKey": "tls.key",
"secretName": ""
} | Present a client certificate as well. Only useful against a backend that requires one; Loki cannot (its port is probed by the kubelet), so this is here for an external backend that does. |
| connections | string | "" | SNI to send, when it differs from the URL host. |
| connections | object |
{} | Extra `jsonData`, merged over the chart's defaults (`prometheusType: Thanos`, `httpMethod: POST`). |
| connections | object |
{} | Inline `secureJsonData`. Prefer `valuesFrom` for real secrets — this renders into the release manifest. |
| connections | list | [] | Secret- or ConfigMap-sourced field injection, passed through to the `GrafanaDatasource`. This is the supported way to supply credentials. |
| connections | string | follows `loki.enabled | Provision the Loki datasource. Unset follows whether the bundled Loki is enabled. |
| connections | string | "Loki" | Datasource name, as shown in Grafana. |
| connections | string | "mzmon-loki" | Stable datasource UID. |
| connections | string | "http://loki-query-frontend.{{ include \"mzmon.loki.namespace\" $ }}.svc:3100" | 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`). |
| connections | string | follows `pipeline.logging.tenancy.staticTenant | 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`. |
| connections | object |
{
"caPem": "",
"caSecret": {
"key": "ca.crt",
"name": ""
},
"clientCert": {
"certKey": "tls.crt",
"keyKey": "tls.key",
"secretName": ""
},
"enabled": null,
"serverName": ""
} | TLS for Grafana's connection to this backend.
Grafana does not read certificate material from files. It stores it in
its own database as `secureJsonData`, provisioned through the datasource
— which is why this is the one hop in the stack that **does not renew on
its own**. cert-manager rewriting the Secret changes nothing until the
datasource is re-provisioned; grafana-operator does that every
`connections.datasources.resyncPeriod`, so the material is refreshed on
that cadence rather than on the certificate's.
Prefer this over |
| connections | string | follows the URL scheme | 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. |
| connections | string | "" | 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 |
| connections | object |
{
"key": "ca.crt",
"name": ""
} | Secret holding the CA bundle, referenced rather than inlined.
Rendered as a `valuesFrom` entry on the `GrafanaDatasource` plus a
placeholder for the operator to substitute into. Survives a CA
rotation, which `caPem` does not, so this is the better default
whenever the CA already lives in the cluster — a cert-manager issuer's
own Secret, say. The Secret must be in the Grafana instance’s namespace, which is
not necessarily this release’s under
|
| connections | object |
{
"certKey": "tls.crt",
"keyKey": "tls.key",
"secretName": ""
} | Present a client certificate as well. Only useful against a backend that requires one; Loki cannot (its port is probed by the kubelet), so this is here for an external backend that does. |
| connections | string | "" | SNI to send, when it differs from the URL host. |
| connections | object |
{} | Extra `jsonData`, merged over the chart's defaults (the tenant header name, and `timeout`). |
| connections | object |
{} | Inline `secureJsonData`. Prefer `valuesFrom` for real secrets — this renders into the release manifest. |
| connections | list | [] | Secret- or ConfigMap-sourced field injection, passed through to the `GrafanaDatasource`. This is the supported way to supply credentials. |
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.
| Key | Type | Default | Description |
|---|---|---|---|
| cleanup | bool | true | 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. |
| cleanup | list | [ "grafanamanifests.grafana.integreatly.org", "grafanadatasources.grafana.integreatly.org" ] | Resource types to delete, as ` |
| cleanup | string | "2m" | How long `kubectl delete` waits for the finalizers to clear. This bounds the hook rather than the uninstall: on expiry the objects are already marked for deletion, the hook fails, and Helm stops before removing the operator — which leaves the cluster recoverable instead of wedged. |
| cleanup | int | 1 | Number of times to retry the job before failing the uninstall. |
| cleanup | int | 420 | 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. |
| cleanup | object |
{
"helm.sh/hook": "pre-delete",
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded",
"helm.sh/hook-weight": "0"
} | Job specific annotations. The default makes this a pre-delete hook; setting any annotation replaces that, which is how you would move it to `post-delete` or set an argocd sync wave. |
| cleanup | object |
{
"pullPolicy": "IfNotPresent",
"pullSecrets": [],
"registry": "registry.k8s.io",
"repository": "kubectl",
"tag": "v1.35.6"
} | Image for the cleanup job. Upstream's own kubectl build: distroless, multi-arch, and published beside Kubernetes itself, so it tracks patch releases without a third-party rebuild. Its entrypoint is `/bin/kubectl` and there is no shell in the image — the hook runs one argv and needs nothing else. Keep `tag` within one minor of your API server, per Kubernetes' version-skew policy. |
| cleanup | object |
{
"limits": {
"cpu": "100m",
"memory": "64Mi"
},
"requests": {
"cpu": "50m",
"memory": "32Mi"
}
} | Resources for the cleanup job. It issues one API call and waits, so this is deliberately small. |
| cleanup | object |
{
"runAsGroup": 65532,
"runAsNonRoot": true,
"runAsUser": 65532,
"seccompProfile": {
"type": "RuntimeDefault"
}
} | Security context for the cleanup job pod. |
| cleanup | object |
{
"allowPrivilegeEscalation": false,
"capabilities": {
"drop": [
"ALL"
]
},
"readOnlyRootFilesystem": true,
"runAsGroup": 65532,
"runAsNonRoot": true,
"runAsUser": 65532
} | Security context for the cleanup job container. The image ships `USER 0`; kubectl needs no privileges to call an API server, so it is dropped to nonroot here rather than trusted to the image. |
| cleanup | object |
{} | Node selector for the cleanup job. |
| cleanup | list | [] | Tolerations for the cleanup job. An uninstall has to work even when the ordinary pools are tainted or full, so this is worth widening if scheduling it is ever the thing that fails. |
Bundled subchart configurations#
Configuration for bundled subcharts
Alloy Agent#
Alloy collector instance running close to scrape targets. Pre-egress shaping happens here.
Upstream reference:
- https://github.com/grafana/alloy/tree/main/operations/helm/charts/alloy
- https://github.com/grafana/alloy/blob/main/operations/helm/charts/alloy/values.yaml
| Key | Type | Default | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| alloy-agent | string | "alloy-agent" | Standard Helm full-name override. We use a static name for deterministic relations. | |||||||||
| alloy-agent | string | nil | Namespace override. | |||||||||
| alloy-agent | object |
{
"create": false
} | Control for the PodLogs crd. | |||||||||
| alloy-agent | object |
{
"fsGroup": 473,
"runAsGroup": 473,
"runAsUser": 473
} | Security context for the alloy agent pods. | |||||||||
| alloy-agent | string | "generally-available" | Stability level of alloy components. | |||||||||
| alloy-agent | list |
[
{
"name": "GOMEMLIMIT",
"value": "240MiB"
}
] | Extra environment variables to pass to the alloy agent pod.
| |||||||||
| alloy-agent | list |
[
{
"configMapRef": {
"name": "mzmon-alloy-agent-env"
}
},
{
"secretRef": {
"name": "mzmon-alloy-agent-env",
"optional": true
}
}
] | Environment variable configmaps/secrets to pass to the alloy agent pod. | |||||||||
| alloy-agent | object |
{
"extra": [
{
"mountPath": "/tmp",
"name": "tmp"
},
{
"mountPath": "/run/log/journal",
"name": "runlogjournal",
"readOnly": true
},
{
"mountPath": "/etc/machine-id",
"name": "machineid",
"readOnly": true
},
{
"mountPath": "/etc/mzmon/tls",
"name": "mzmon-tls",
"readOnly": true
}
],
"varlog": true
} | Volume mounts to expose to alloy agent.
Three mounts feed `loki.source.journal`, and journal collection needs all
three. Every way of getting it wrong fails the same silent way: the
component starts, reports healthy, and reads nothing. Where the journal lives is host-specific, so both roots have to be covered. Observed:
Bottlerocket also has an empty Deliberately not mounting
Measured on a Bottlerocket node, controlling for the restart that a mount
change forces: with the mount, 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
( | |||||||||
| alloy-agent | object |
{
"mountPath": "/etc/mzmon/tls",
"name": "mzmon-tls",
"readOnly": true
} | Certificate material from `certificates`, when it is enabled.
`optional: true` is what lets this be unconditional. The Secret does
not exist until `certificates.enabled` is on and cert-manager has
signed, and an optional secret volume that is missing mounts empty
rather than blocking the pod — so the same values work before, during
and after issuance. That holds only while nothing reads the material, which is why the
mTLS profiles flip it to The kubelet refreshes the contents in place on renewal, which is why
the | |||||||||
| alloy-agent | object |
{
"allowPrivilegeEscalation": false,
"capabilities": {
"drop": [
"ALL"
]
},
"readOnlyRootFilesystem": true,
"runAsGroup": 473,
"runAsNonRoot": false,
"runAsUser": 0
} | Security context for the alloy agent containers. The agent MUST run as root in order to be able to read container logs. No capabilities are added and none are needed: everything it reads is reachable by uid 0 under ordinary DAC. | |||||||||
| alloy-agent | object |
{
"limits": {
"cpu": "250m",
"memory": "300Mi"
},
"requests": {
"cpu": "100m",
"memory": "300Mi"
}
} | Resources for the alloy agent containers. Sized for the logs-only agent. This needed ~750Mi while an in-process cAdvisor lived here; that moved to the gateway, so the envelope is back to what the log path actually costs, with headroom over the ~200Mi it ran at before. The CPU limit stays above the request: the agent is bursty, and a limit equal to the request turns that into CFS throttling on the busiest nodes. | |||||||||
| alloy-agent | object |
{} | Extra annotations to apply to the alloy agent pod. If you are using pulumi, be sure to add `config.kubernetes.io/depends-on: job/mzmon-validate-agent` | |||||||||
| alloy-agent | list |
[
{
"effect": "NoSchedule",
"operator": "Exists"
}
] | Taints the agent tolerates out of the box: every `NoSchedule` one. Coverage is correctness for this workload, not a placement preference. A node the DaemonSet never lands on produces no logs and no error, and nothing shows a hole where a node should be — so the default is the same blanket rule `node-exporter` already ships, for the same reason. Enumerating keys instead would make coverage depend on this chart knowing your taints, which it does not: a node pool tainted with anything the list does not name goes silently uncollected. That is the failure this replaced. It is not always silent, either. A bootstrap gate — a `NoSchedule` taint applied to every node at boot and lifted once the DaemonSets are running, which is what `node.materialize.com/daemonsets-not-scheduled` is — deadlocks outright against an agent that does not tolerate it: the taint waits on a pod that is waiting on the taint, and the node never admits any workload. `NoExecute` is deliberately not tolerated here — a node being drained for a problem should shed the agent too. The two exceptions are not the chart's to make: the DaemonSet controller adds `node.kubernetes.io/not-ready` and `node.kubernetes.io/unreachable` (both `NoExecute`) to every DaemonSet pod unconditionally, so the agent keeps running on a node that goes unreachable whatever is written here. Add other `NoExecute` taints through the Terraform module's `tolerations`, which appends to this list. To narrow this — a node pool whose per-node budget genuinely cannot absorb the agent — replace the list rather than adding to it, and record which pools you gave up. Helm overwrites lists, so setting this key at all replaces the whole default. | |||||||||
| alloy-agent | string | "monitoring-critical" | Scheduling priority. See the Priority classes section. Critical: this is a per-node singleton, so an eviction is a log gap on that node with no replica to cover it. | |||||||||
| alloy-agent | object |
{
"egress": null,
"enabled": true,
"flavor": "kubernetes",
"ingress": [
{
"from": [
{
"podSelector": {
"matchLabels": {
"app.kubernetes.io/name": "alloy-gateway"
}
}
}
],
"ports": [
{
"port": 12345,
"protocol": "TCP"
}
]
}
],
"policyTypes": [
"Ingress"
]
} | NetworkPolicy for the agent. Ingress is one port from one peer. Egress is not restricted, and the The default | |||||||||
| alloy-agent | string | nil | 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`. |
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
| Key | Type | Default | Description |
|---|---|---|---|
| alloy-gateway | string | "alloy-gateway" | Standard Helm full-name override. We use a static name for deterministic relations. |
| alloy-gateway | string | nil | Namespace override. |
| alloy-gateway | object |
{
"create": false
} | Control for the PodLogs crd. |
| alloy-gateway | object |
{
"fsGroup": 473,
"runAsGroup": 473,
"runAsUser": 473
} | Security context for the alloy gateway pods. |
| alloy-gateway | string | "generally-available" | Stability level of alloy components. |
| alloy-gateway | list |
[
{
"name": "GOMEMLIMIT",
"value": "600MiB"
}
] | 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. |
| alloy-gateway | list |
[
{
"configMapRef": {
"name": "mzmon-alloy-gateway-env"
}
},
{
"secretRef": {
"name": "mzmon-alloy-gateway-env",
"optional": true
}
}
] | Environment variable configmaps/secrets to pass to the alloy gateway pod. |
| alloy-gateway | list |
[
{
"name": "loki",
"port": 3100,
"protocol": "TCP",
"targetPort": 3100
},
{
"name": "otlp-grpc",
"port": 4317,
"protocol": "TCP",
"targetPort": 4317
},
{
"name": "otlp-http",
"port": 4318,
"protocol": "TCP",
"targetPort": 4318
},
{
"name": "prom",
"port": 9090,
"protocol": "TCP",
"targetPort": 9090
}
] | Ports to expose from alloy-gateway. |
| alloy-gateway | object |
{
"extra": [
{
"mountPath": "/tmp",
"name": "tmp"
},
{
"mountPath": "/etc/mzmon/tls",
"name": "mzmon-tls",
"readOnly": true
}
],
"varlog": false
} | Volume mounts to expose to alloy gateway. |
| alloy-gateway | object |
{
"mountPath": "/etc/mzmon/tls",
"name": "mzmon-tls",
"readOnly": true
} | Certificate material from `certificates`, when it is enabled.
`optional: true` is what lets this be unconditional. The Secret does
not exist until `certificates.enabled` is on and cert-manager has
signed, and an optional secret volume that is missing mounts empty
rather than blocking the pod — so the same values work before, during
and after issuance. That holds only while nothing reads the material, which is why the
mTLS profiles flip it to The kubelet refreshes the contents in place on renewal, which is why
the |
| alloy-gateway | object |
{
"allowPrivilegeEscalation": false,
"capabilities": {
"drop": [
"ALL"
]
},
"readOnlyRootFilesystem": true,
"runAsGroup": 473,
"runAsNonRoot": true,
"runAsUser": 473
} | Security context for the alloy gateway containers. |
| alloy-gateway | object |
{
"limits": {
"cpu": "500m",
"memory": "768Mi"
},
"requests": {
"cpu": "500m",
"memory": "768Mi"
}
} | 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. |
| alloy-gateway | object |
{} | Extra annotations to apply to the alloy gateway pod. If you are using pulumi, be sure to add `config.kubernetes.io/depends-on: job/mzmon-validate-gateway` |
| alloy-gateway | string | "monitoring-critical" | Scheduling priority. See the Priority classes section. Critical despite being a Deployment: every signal in the stack leaves through here, so losing it stops logs and metrics at once. |
| alloy-gateway | int | 0 | 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. |
| alloy-gateway | bool | true | Create a service account for alloy-gateway. |
| alloy-gateway | object |
{} | Extra annotations to set on the alloy-gateway service account. Use `eks.amazonaws.com/role-arn` to set up IRSA. Use `iam.gke.io/gcp-service-account` to set up Workload Identity Federation. |
| alloy-gateway | object |
{
"egress": null,
"enabled": true,
"flavor": "kubernetes",
"ingress": [
{
"from": [
{
"namespaceSelector": {}
}
],
"ports": [
{
"port": 3100,
"protocol": "TCP"
},
{
"port": 4317,
"protocol": "TCP"
},
{
"port": 4318,
"protocol": "TCP"
},
{
"port": 9090,
"protocol": "TCP"
}
]
},
{
"from": [
{
"podSelector": {
"matchLabels": {
"app.kubernetes.io/name": "alloy-gateway"
}
}
}
],
"ports": [
{
"port": 12345,
"protocol": "TCP"
}
]
}
],
"policyTypes": [
"Ingress"
]
} | NetworkPolicy for the gateway. Two ingress rules, because the gateway’s ports fall into two groups with very different audiences. The ingest ports —
Egress is not restricted, and cannot usefully be. The gateway scrapes the
kubelet cAdvisor endpoint on every node — port |
| alloy-gateway | string | nil | See the note above: unrestricted, deliberately. |
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
| Key | Type | Default | Description |
|---|---|---|---|
| loki | string | "loki" | Standard Helm full-name override. We use a static name for deterministic relations. |
| loki | string | nil | Namespace override. |
| loki | string | "monitoring-scalable" | Scheduling priority for Loki. See the Priority classes section. `global` covers every component that renders through the chart's `_pod.tpl` — but *not* the two memcached StatefulSets, which read only their own component key. They are set explicitly further down. This is the same asymmetry `terraform/modules/materialize-monitoring/scheduling.tf` calls out for nodeSelector and tolerations; rendering is what catches it. |
| loki | string | "Distributed" | How loki is deployed. We prefer to run in Distributed/Microservices mode. |
| loki | bool | true | Whether to enable a network policy for loki In production, this is recommended to be enabled. |
| loki | object |
{
"namespaceSelector": {}
} | Selector for incoming traffic to metric endpoints. This must be configured manually (usually set to `kubernetes.io/metadata.name: monitoring`). |
| loki | object |
{
"namespaceSelector": {}
} | Selector for incoming traffic to the read/write endpoints. This must be configured manually (usually set to `kubernetes.io/metadata.name: monitoring`). |
| loki | object |
{
"cidrs": [
"0.0.0.0/0"
],
"ports": [
443,
80
]
} | Outgoing traffic from loki to the object store. This is usually unrestricted, even in many production settings. Adjust if you have a VPCEndpoint in front of S3/STS or are using a non-standard port. |
| loki | object |
{
"chunks": "\u003cREPLACE-ME\u003e",
"ruler": "\u003cREPLACE-ME\u003e"
} | Bucket names for object storage. These are required to be populated. |
| loki | bool | true | Use the thanos object store client |
| loki | object |
{
"s3": {
"endpoint": "s3.amazonaws.com"
},
"type": "s3"
} | Object storage configuration. Modify as needed. Only the block named by `type` is read; it is handed to Loki verbatim, with `bucket_name` filled in from `bucketNames` above. |
| loki | object |
{
"configs": [
{
"from": "2024-01-01",
"index": {
"period": "24h",
"prefix": "loki_index_"
},
"object_store": "s3",
"schema": "v13",
"store": "tsdb"
}
]
} | Schema configuration for the loki TSDB. This is append-only and MUST be copied into projects and mutated on upgrades (if v13 stops being in use). |
| loki | object |
{
"replication_factor": 3
} | Cluster-wide replication factor. Three is our HA floor and the reason at least three ingesters are required. (This matches the upstream default; surfaced here because it is load-bearing.) |
| loki | object |
{
"ingestion_burst_size_mb": 20,
"ingestion_rate_mb": 10,
"max_global_streams_per_user": 10000,
"reject_old_samples": true,
"reject_old_samples_max_age": "168h",
"retention_period": "30d",
"volume_enabled": true
} | Per-tenant limits, sized for a medium install. These are protective caps (per environment), not the expected volume — see Operating > Production Best Practices for the throughput envelope. |
| loki | string | "30d" | Default retention before the compactor deletes logs. Upstream defaults to infinite retention; we set a real bound. |
| loki | int | 10 | Per-tenant ingestion rate / burst, in MB. Per environment, not the fleet aggregate. |
| loki | int | 10000 | Active-stream ceiling per tenant; a cardinality guard. |
| loki | bool | true | Reject writes too far in the past. |
| loki | bool | true | Enable the log-volume endpoints Grafana's logs drilldown relies on. |
| loki | object |
{
"delete_request_store": "s3",
"retention_delete_delay": "8h",
"retention_enabled": true
} | Compactor *configuration* (distinct from the compactor deployment below). Retention is enforced by the compactor and is OFF in upstream defaults. |
| loki | string | "s3" | Where delete requests are stored; matches the object-store backend. |
| loki | string | "8h" | Grace period before retention/deletes actually remove data. |
| loki | bool | false | Disable gateway by default. We recommend using alloy-gateway for loki writes. Use the query-frontend for loki reads. |
Loki Microservice Configuration#
Configuration for each loki microservice.
https://grafana.com/docs/loki/latest/get-started/components/
| Key | Type | Default | Description |
|---|---|---|---|
| loki | bool | true | 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. |
| loki | string | nil | Number of replicas for the distributor microservice. If autoscaling is enabled, this should be set to null. |
| loki | string | "Deployment" | Type of workload for the distributor. |
| loki | object |
{
"enabled": true,
"maxReplicas": 4,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 60,
"targetMemoryUtilizationPercentage": 75
} | Configuration for autoscaling of distributor For production, this is recommended to be enabled. We provide opinionated defaults. |
| loki | string | "ClusterIP" | Service type for the distributor microservice. Without a gateway, this is the exposed write component. |
| loki | object |
{
"enabled": true,
"minAvailable": 1
} | Configuration for pod disruption budget for the distributor microservice. |
| loki | object |
{
"requests": {
"cpu": "150m",
"memory": "256Mi"
}
} | Resource requests (medium install). Tune per profile. |
| loki | bool | true | 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. |
| loki | int | 3 | 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. |
| loki | string | "StatefulSet" | Ingesters are stateful. |
| loki | bool | false | 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. |
| loki | int | 60 | 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. |
| loki | object |
{
"podAntiAffinity": {
"requiredDuringSchedulingIgnoredDuringExecution": null
}
} | Pod affinity for ingesters. Drop the chart's default *hard* per-host anti-affinity so host spread can be soft (see topologySpreadConstraints); zone spread stays hard. We null the nested list rather than the whole `affinity` map: `affinity: {}` is a no-op against the subchart default, and `affinity: null` clears it but makes helm-unittest log a noisy "cannot overwrite table" warning — nulling the list avoids both. |
| loki | list |
[
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "ingester",
"app.kubernetes.io/instance": "{{ .Release.Name }}",
"app.kubernetes.io/name": "{{ include \"loki.name\" . }}"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"minDomains": 2,
"nodeTaintsPolicy": "Honor",
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "DoNotSchedule"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "ingester",
"app.kubernetes.io/instance": "{{ .Release.Name }}",
"app.kubernetes.io/name": "{{ include \"loki.name\" . }}"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
] | Topology spread for ingesters: hard across zones, soft across hosts. A pod that cannot satisfy the hard zone rule goes Pending, which is the signal Karpenter (or the cluster-autoscaler) uses to add a node in the deficient zone — soft rules cannot summon capacity that way. Host spread is soft so pods still schedule when nodes are momentarily scarce. |
| loki | bool | false | Enable zone-aware replication for the ingester microservice. Not needed at our size: topologySpreadConstraints give the AZ spread without the complexity. |
| loki | object |
{
"enabled": true,
"maxUnavailable": 1
} | PDB for the ingester. Protect ingest quorum across rollouts and node drains. Do not set maxUnavailable >= 2 to avoid potential quorum loss. |
| loki | object |
{
"requests": {
"cpu": "500m",
"memory": "1Gi"
}
} | Resource requests (medium install). The memory *limit* is intentionally left unset: an OOM-kill drops in-memory / WAL-buffered logs, so we alert on usage rather than cap hard. |
| loki | bool | true | Enable the querier microservice. Querier is required. It executes LogQL, reading recent data from ingesters and historical data from object storage. |
| loki | string | nil | Replicas are managed by autoscaling; leave null. |
| loki | object |
{
"enabled": true,
"maxReplicas": 4,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 60,
"targetMemoryUtilizationPercentage": 75
} | Autoscale the stateless read workers with query load. |
| loki | object |
{
"enabled": true,
"minAvailable": 1
} | PDB for querier. |
| loki | object |
{
"requests": {
"cpu": "250m",
"memory": "512Mi"
}
} | Resource requests. This assumes a medium install by default. |
| loki | bool | true | Enable the query-frontend microservice. Query-frontend is required. It queues and splits queries and caches results. |
| loki | string | nil | Static replicas for query-frontend when autoscaling is not enabled. |
| loki | string | "Deployment" | Kind of workload for query-frontend. Deployments are recommended for stateless components. |
| loki | object |
{
"enabled": true,
"maxReplicas": 4,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 60,
"targetMemoryUtilizationPercentage": 75
} | Configure autoscaling for query-frontend. |
| loki | object |
{
"enabled": true,
"minAvailable": 1
} | PDB for query-frontend. |
| loki | object |
{
"requests": {
"cpu": "100m",
"memory": "256Mi"
}
} | Resource requests for query-frontend. |
| loki | bool | true | 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. |
| loki | object |
{
"enabled": true,
"minAvailable": 1
} | PDB for query-scheduler. |
| loki | object |
{
"requests": {
"cpu": "100m",
"memory": "256Mi"
}
} | Resources for the query-scheduler microservice. |
| loki | bool | true | Enable the compactor. Compactor is required and MUST be a singleton: it compacts the index and enforces retention against shared object storage. |
| loki | int | 1 | Compactor replicas. This MUST be one, since it runs as a singleton. |
| loki | string | "StatefulSet" | Kind of workload for the compactor. This must be a statefulset. |
| loki | bool | false | 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. |
| loki | object |
{
"requests": {
"cpu": "250m",
"memory": "512Mi"
}
} | Resources for the compactor. |
| loki | bool | true | Enable the index-gateway. Index-gateway is required: it serves index lookups so queriers do not each download the whole index from object storage. |
| loki | object |
{
"enabled": false
} | Run the index-gateway ephemerally (no PVC, node-local emptyDir). The local index is a read-through cache of object storage, not authoritative — a fresh pod just re-downloads what it queries. This keeps the read path off EBS zonal pinning; the only cost is a little cold-start warmup and a burst of index reads after a reschedule. |
| loki | object |
{
"enabled": true,
"minAvailable": 1
} | PDB for index-gateway. |
| loki | object |
{
"requests": {
"cpu": "200m",
"memory": "512Mi"
}
} | Resources for the index-gateway. |
| loki | bool | true | 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. |
| loki | bool | true | 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. |
| loki | object |
{
"enabled": true,
"minAvailable": 1
} | PDB for the ruler. |
| loki | object |
{
"requests": {
"cpu": "100m",
"memory": "256Mi"
}
} | Resources for the ruler. |
| loki | h5 | {"allocatedMemory":2048, "priorityClassName":"monitoring-scalable"} | Chunk cache (memcached). Default allocation is sized for very large installs; we shrink it to match our volumes. The results cache keeps its upstream default. `priorityClassName` is repeated on both caches because the memcached StatefulSet template reads its component key only — `loki.global` does not reach it. |
| loki | h5 | {"priorityClassName":"monitoring-scalable"} | Query results cache (memcached). |
| loki | bool | true | Enable a ServiceMonitor for the loki microservices. |
| loki | h5 | {"enabled":true, "kind":"Deployment", "lokiurl":"loki-query-frontend:3100", "priorityClassName":"monitoring-scalable", "push":false} | End-to-end write→read canary for meta-monitoring. On by default upstream; surfaced here because self-monitoring the log store is a first-class requirement for us. |
| loki | string | "monitoring-scalable" | Scheduling priority. See the Priority classes section. Set explicitly because the canary reaches neither of the two keys that cover the rest of Loki: it renders from its own template rather than `_pod.tpl`, so `loki.global.priorityClassName` does not reach it, and `loki.defaults` does not either. Left unset it runs at priority 0 — *below* ordinary workloads — so the first node under pressure evicts the end-to-end write→read check, which is exactly the signal you want during the incident that caused the pressure. |
Thanos#
Bundled Thanos backend for long-term metrics.
Upstream reference:
- https://github.com/thanos-community/helm-charts/tree/master/charts/thanos
- https://github.com/thanos-community/helm-charts/blob/master/charts/thanos/values.yaml
| Key | Type | Default | Description |
|---|---|---|---|
| thanos | string | "thanos" | Standard Helm full-name override. We use a static name for deterministic relations. |
| thanos | string | nil | Namespace override. |
| thanos | string | "monitoring-scalable" | Scheduling priority for every Thanos pod. See the Priority classes section. |
| thanos | object |
{
"enabled": true,
"maxUnavailable": 1
} | PodDisruptionBudgets for every Thanos component. Upstream ships these off. One switch turns them on for all components,
and the per-component
This matches the Loki convention (ingester |
| thanos | bool | true | 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 |
| thanos | object |
{
"autoscaling": {
"enabled": true,
"maxReplicas": 5,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 80
},
"enabled": true,
"resources": {
"requests": {
"cpu": "500m",
"memory": "1Gi"
}
},
"topologySpreadConstraints": [
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "ScheduleAnyway"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
]
} | Thanos Query configuration. Query provides a PromQL query endpoint. |
| thanos | object |
{
"enabled": true,
"maxReplicas": 5,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 80
} | Horizontal autoscaling for Query. Query is a stateless PromQL fan-out, so it is the natural place to autoscale: no local state, no ring membership, no PVC. |
| thanos | list |
[
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "ScheduleAnyway"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
] | Topology spread for Query: soft on both axes.
Stateless and autoscaled, so an unbalanced placement costs query capacity
rather than correctness — soft is sufficient, and it keeps an HPA scale-up
from stalling on a hard rule when the newest zone has no room yet.
|
| thanos | object |
{
"requests": {
"cpu": "500m",
"memory": "1Gi"
}
} | Resource requests for Thanos Query. Medium sizing; see the `thanos-small` / `thanos-large` profiles for the other two columns. A CPU request is load-bearing here beyond scheduling: `targetCPUUtilization` is a percentage *of the request*, so without one the HPA above has no denominator and never scales. No memory limit — a fan-out query's peak scales with the series it touches, and an OOM-kill mid-query is a worse failure than a slow one. |
| thanos | bool | true | Enable Thanos receiver. Receive provides a Prometheus remote_write-compatible endpoint. |
| thanos | string | "standalone" | Whether to split receive distributors from ingesters. mode=split is not very stable in the helm chart |
| thanos | int | 3 | Number of receive replicas. |
| thanos | object |
{
"retention": "6h"
} | How long Receive keeps blocks on local disk before they age out.
**6h, overriding the subchart's 24h**, because local retention is what sets
Receive's disk requirement — and on an `emptyDir` that disk is the node's, a
far scarcer and much smaller pool than a provisioned volume was. This is a recent-query cache, not a durability window: blocks ship to object storage every 2h and the Store Gateway serves everything older, so all that shortens here is how far back a query is answered from local TSDB rather than from the bucket. 6h covers the common dashboard ranges and leaves 4h of margin after a block closes, uploads, and the Store Gateway syncs it. Raising it back is a real cost rather than just a number. At the medium envelope (1.5M series) 24h is ~7.3Gi per pod, and a GKE node with a 47Gi boot disk offers only ~18.8Gi of allocatable ephemeral storage — one pod would claim 40% of the node’s budget, and Loki’s ingesters draw on the same pool. |
| thanos | list | [ "--receive.replication-factor=3" ] | Extra CLI arguments for Receive. **This is a list, and Helm overwrites lists rather than merging them.** Anything that sets `receive.extraArgs` replaces this entry wholesale, so the replication factor must be restated or it silently falls back to Thanos's default of 1 — write quorum 1, no losses tolerated. The shipped profiles restate it; so must yours. See Production Best Practices > Metrics (Thanos) for the quorum table. |
| thanos | object |
{
"enabled": false
} | VerticalPodAutoscaler for Receive.
Off, overriding the subchart's `true`. Three reasons, in order:
If you want its recommendations without its actions, set
|
| thanos | object |
{
"limits": {
"ephemeral-storage": "6Gi"
},
"requests": {
"cpu": "500m",
"ephemeral-storage": "4Gi",
"memory": "4Gi"
}
} | Resource requests for Receive. Medium sizing.
Memory is the cell to get right: budget ~3 KB per active series held, and
note that at `replicaCount == replicationFactor` **every pod holds every
series** — sharding begins only above RF. No memory limit, deliberately, and for a sharper reason than elsewhere in this chart: an OOM-kill drops the un-uploaded block window, and unlike Loki’s ingesters that window has nothing protecting it but the replication factor. Alert on usage instead.
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 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 |
| thanos | list |
[
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "receive",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"minDomains": 2,
"nodeTaintsPolicy": "Honor",
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "DoNotSchedule"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "receive",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
] | Topology spread for Receive: hard across zones, soft across hosts.
This is the constraint that makes `--receive.replication-factor=3` mean
what the quorum table says. Without it the scheduler is free to place all
three replicas in one zone, and RF 3 buys nothing against the failure it
exists for — losing a zone takes every copy and, since write quorum is 2,
takes writes with it. Hard ( This became viable when Receive stopped using a PVC (see
The selector names both The release name is deliberately absent: the subchart renders this through
Note this cannot move to |
| thanos | object |
{
"enabled": false
} | Local TSDB storage for Receive: **ephemeral, not a PVC.**
This is the same call the chart makes for Loki's ingesters, for the same
reason, and it is worth spelling out because Receive looks stateful. Durability comes from A PVC makes an AZ failure worse, not merely less flexible: an EBS volume cannot be attached from another zone, so a pod whose zone is gone stays Pending until the zone returns rather than rescheduling into a healthy one. On the write path that turns a recoverable event into an outage that waits on the cloud provider — with RF 3 write quorum is 2, so two pods stuck Pending block writes outright. The trade accepted in exchange: Thanos Receive has no peer hand-off, so unlike Loki it will not backfill an emptied pod from its neighbours. That window stays at two copies instead of three until the next block ships. |
| thanos | object |
{
"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"
}
]
} | Thanos Store Gateway configuration. Store Gateway provides historical block querying. Autoscaling is available upstream but left off deliberately. Store Gateway is a PVC-backed StatefulSet that syncs the bucket’s block index on startup, so scale-up is slow to become useful (it serves nothing until the index is warm) and CPU-triggered scaling reacts long after the load that triggered it. Scale-down also leaves orphaned PVCs behind, since StatefulSet volumes are not reclaimed. Size it deliberately instead. |
| thanos | object |
{
"enabled": true,
"size": "10Gi"
} | Index-header cache for Store Gateway.
One of two Thanos components that keep a PVC — the Compactor is the other,
and Receive alone is `emptyDir`-backed. The reason here is startup cost
rather than durability or size: the on-disk binary index-headers are a
read-through cache of the bucket and lose nothing when discarded, but
rebuilding them means re-downloading from object storage, and the component
serves no historical query until that finishes. On a large bucket that is
minutes of degraded long-range dashboards on every restart. Kept on at every size deliberately, rather than flipping to ephemeral
for small installs. A volume that appears and disappears with the profile
is a surprise during an incident, and the AZ-pinning argument that drives
Receive to The |
| thanos | object |
{
"requests": {
"cpu": "500m",
"memory": "3Gi"
}
} | Resource requests for Store Gateway. Medium sizing. There is a floor here that is easy to trip over. Thanos defaults `--chunk-pool-size=2GB` and `--index-cache-size=250MB`, and the subchart passes neither, so a stock Store Gateway wants ~2.5Gi before it serves a single query. A request below that is an OOM, not a small install — the `thanos-small` profile shrinks the pools through `extraArgs` instead. |
| thanos | list |
[
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "storegateway",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "ScheduleAnyway"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "storegateway",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
] | Topology spread for Store Gateway: soft on both axes.
**Soft** (`ScheduleAnyway`) where Receive is hard, and the difference is
the point rather than an inconsistency. Two things make it the right trade
here:
|
| thanos | list | [ "--store.limits.request-series=5000000", "--store.limits.request-samples=200000000" ] | Extra CLI arguments for Store Gateway.
Read-path protection. Thanos has no per-tenant write limit to set; the
realistic failure is one query fanning out across the bucket and taking
this component down, which takes historical reads with it. These two caps
make that query fail instead. The subchart ships this empty, so unlike |
| thanos | object | enabled with default retention policies | Thanos Compactor configuration. Compactor provides block compaction and downsampling. |
| thanos | object |
{
"resolution1h": "365d",
"resolution5m": "90d",
"resolutionRaw": "30d"
} | Retention policies for Thanos Compactor downsampled data
The medium row of the retention table in Production Best Practices. Raw retention has a floor set by the downsampling thresholds: Thanos produces 5m downsamples only from blocks spanning 40h or more, and 1h downsamples only from blocks spanning 10d or more. Below those the tier is never created at all and long-range queries silently fall back to reading raw blocks — slower and more expensive, which is the opposite of the intent. 30d clears both comfortably. |
| thanos | object |
{
"enabled": false
} | VerticalPodAutoscaler for the Compactor. Off for the same reasons as `receive.vpa`, minus the block-loss argument — the Compactor's local volume is an idempotent working copy, so an eviction costs a restarted compaction rather than data. It still overwrites the requests below, and still behaves differently depending on whether the VPA CRD happens to be installed. |
| thanos | object |
{
"requests": {
"cpu": 1,
"memory": "2Gi"
}
} | Resource requests for the Compactor. Medium sizing.
The Compactor is a **singleton by necessity** — concurrent compactors
against one block set corrupt data — so it is the one component here with
no horizontal escape hatch. Vertical is the only lever, which makes
compaction falling behind something to alert on rather than discover. Downsampling is the memory-heavy phase, and No |
| thanos | object |
{
"enabled": true,
"size": "50Gi"
} | Working directory for the Compactor: **a PersistentVolume**, unlike Receive.
Scratch space for the block group under compaction. Nothing here is
authoritative — the bucket is — and a Compactor killed mid-compaction simply
redoes the work, so there is no *durability* argument for a volume. The
argument is size. Compaction works on whole block groups, not on the recent window, so the
requirement scales with days of data rather than hours. At the medium
envelope a 2d group is roughly six 8h blocks at ~2.4Gi each, and the
Compactor needs the sources and the output at once — on the order of 30Gi.
A GKE node with a 47Gi boot disk offers about 18.8Gi of allocatable
ephemeral storage, so that never fits: the pod stays Pending on
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
|
| thanos | object |
{
"autoscaling": {
"enabled": true,
"maxReplicas": 5,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 80
},
"enabled": false,
"resources": {
"requests": {
"cpu": "250m",
"memory": "512Mi"
}
},
"topologySpreadConstraints": [
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query-frontend",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "ScheduleAnyway"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query-frontend",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
]
} | Thanos Query Frontend configuration. Query Frontend provides query parallelization and result caching. Only required for production. Note that enabling this is not sufficient on its own: point
|
| thanos | object |
{
"enabled": true,
"maxReplicas": 5,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 80
} | Horizontal autoscaling for Query Frontend. Stateless like Query, so the same reasoning applies. Inert until `queryFrontend.enabled` is true. |
| thanos | list |
[
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query-frontend",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "ScheduleAnyway"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query-frontend",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
] | Topology spread for Query Frontend: soft on both axes, same reasoning as Query. Inert until `enabled` is true. |
| thanos | object |
{
"requests": {
"cpu": "250m",
"memory": "512Mi"
}
} | Resource requests for Query Frontend. Inert until it is enabled, which `thanos-large` does. Sized for splitting and result caching rather than query execution — the work happens in Query behind it. |
| thanos | object |
{
"enabled": false
} | Thanos Ruler configuration. Ruler provides alerting and recording rules evaluation. |
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
| Key | Type | Default | Description |
|---|---|---|---|
| grafana-operator | string | "monitoring-scalable" | Scheduling priority. See the Priority classes section. Scalable tier: while the operator is down, dashboard and datasource reconciliation stops, but Grafana keeps serving what it already has. |
| grafana-operator | string | "grafana-operator" | Standard Helm full-name override. We use a static name for deterministic relations. |
| grafana-operator | string | nil | Namespace override. |
| grafana-operator | object |
{
"immutable": true
} | CRD behavior. The Grafana Operator CRDs are owned by the `materialize-monitoring-crds` chart, which vendors a deflated copy of them. The operator chart offers no way to skip its own CRDs outright — `immutable` only chooses where they come from — so keep this `true`: that keeps them out of this chart's release manifest and leaves them install-only, which `helm install --skip-crds` drops entirely. Setting it `false` makes this chart template and upgrade the CRDs itself, fighting the CRDs chart for ownership. |
Grafana Instance#
Bundled Grafana for dashboard rendering.
Upstream reference:
The defaults here are the safe shape, not the production shape, and the two differ in three places you have to close yourself:
- 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 everyresyncPeriod); anything a human created does not. Apply thegrafana-postgresprofile, orgrafana-pvcif you have no database. - Reachability. The Service is
ClusterIP, so the only access path iskubectl port-forward. Setingressorservice.type— and readconnections.grafana.allowPublicAccessbefore you do. - Authentication. The only account is the generated admin. Configure an
identity provider under
grafana.inibefore exposing it to anyone but yourself.
Everything under grafana.ini is passed through to Grafana’s own config file
verbatim, so any section Grafana understands can be set here — see the
[auth.generic_oauth] example on that key.
See Production Best Practices > Grafana for the full checklist.
| Key | Type | Default | Description |
|---|---|---|---|
| grafana | string | "grafana" | Standard Helm full-name override. We use a static name for deterministic relations. `connections.grafana.mode: bundled` derives the URL it hands grafana-operator from this, so a release-name-derived name would leave the operator dialing a host that does not resolve. |
| grafana | string | nil | Namespace override. |
| grafana | string | "monitoring-scalable" | Scheduling priority. See the Priority classes section. |
| grafana | object |
{
"pullPolicy": "IfNotPresent",
"registry": "docker.io",
"repository": "grafana/grafana",
"tag": "13.2.2"
} | 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
Both ship no shell and no package manager, which is the point — and which
also means Bitnami also publishes a hardened Grafana, but its images are built for
Bitnami’s own charts — Bitnami entrypoints, |
| grafana | int | 1 | 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. |
| grafana | object |
{
"enabled": false,
"maxReplicas": 5,
"minReplicas": 1,
"targetCPU": "60",
"targetMemory": ""
} | Horizontal autoscaling for the Grafana Deployment.
Off by default because it is meaningless on the default SQLite backend:
every replica would carry its own database. With `grafana.ini.database`
pointed at PostgreSQL, Grafana is stateless and scales horizontally, so
enable it then — `minReplicas` becomes the effective replica count and
`replicas` stops being rendered at all.
|
| grafana | object |
{
"maxUnavailable": 1
} | PodDisruptionBudget for Grafana. `maxUnavailable` rather than `minAvailable`, matching the convention the Loki and Thanos blocks use: it scales with the replica count, and on a single replica `minAvailable: 1` would permit no voluntary eviction at all and hang node drains. |
| grafana | object |
{
"limits": {
"memory": "1Gi"
},
"requests": {
"cpu": "100m",
"memory": "256Mi"
}
} | Resource requests and limits for the Grafana container. Requests are what the scheduler and the HPA both read — without a CPU request, `autoscaling.targetCPU` has no denominator and the HPA never scales. No CPU limit: query rendering is bursty and throttling it makes the UI feel broken. Raise the memory limit if you run many large dashboards or heavy plugins. |
| grafana | object |
{
"accessModes": [
"ReadWriteOnce"
],
"enabled": false,
"size": "10Gi"
} | Persistent volume for Grafana's SQLite database and plugin directory.
Off by default. Turning it on survives restarts but caps you at one replica
(SQLite tolerates one writer) and, on a `ReadWriteOnce` volume, requires
`deploymentStrategy.type: Recreate` — a rolling update otherwise deadlocks
with the new pod waiting for a volume the old pod has not released. Both are
enforced at render time. The `grafana-pvc` profile is the assembled version. Prefer PostgreSQL ( |
| grafana | object |
{
"type": "RollingUpdate"
} | Deployment update strategy. Must be `Recreate` alongside a `ReadWriteOnce` PersistentVolume; see `persistence`. |
| grafana | object |
{
"annotations": {},
"enabled": true,
"loadBalancerSourceRanges": [],
"port": 80,
"targetPort": 3000,
"type": "ClusterIP"
} | Service exposing Grafana.
`ClusterIP` is reachable only from inside the cluster, which is the safe
default and the reason a fresh install needs `kubectl port-forward`. Prefer
`ingress` over a `LoadBalancer` Service: Grafana is ordinary HTTP that wants
host-based routing and a certificate, which is what Ingress is for. A |
| grafana | object |
{
"annotations": {},
"enabled": false,
"hosts": null,
"ingressClassName": "",
"labels": {},
"path": "/",
"pathType": "Prefix",
"tls": null
} | Ingress for Grafana.
The preferred way to make Grafana reachable. Terminate TLS here: Grafana
authenticates with a session cookie and, without TLS, that cookie and the
admin password cross the network in the clear. A render-time check warns
when an Ingress carries no `tls` block. Set |
| grafana | object |
{
"analytics": {
"check_for_updates": false,
"reporting_enabled": false
},
"date_formats": {
"default_timezone": "UTC"
}
} | 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 OIDC single sign-on, as an example — this is the shape, not a default: Every provider, the role-mapping rules, and the break-glass path are in Dashboards > Grafana > Authentication. |
| grafana | object |
{
"allowExternal": true,
"egress": {
"enabled": false
},
"enabled": true,
"explicitIpBlocks": [],
"explicitNamespacesSelector": {},
"ingress": true
} | NetworkPolicy for Grafana. The subchart’s policy is rigid: it emits exactly one ingress rule, on
Ingress: What it closes is the rest of the pod: You do not have to turn this off to get HA alerting. The chart renders the
missing rule itself, as Egress is left unrestricted ( |
| grafana | bool | true | 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. |
| grafana | bool | true | Accept from any source. See the note above before turning this off. |
| grafana | object |
{} | Namespaces allowed in when `allowExternal` is false. The ingress controller's namespace goes here. |
| grafana | list | [] | CIDRs allowed in when `allowExternal` is false. Load-balancer client and health-check ranges go here. |
| grafana | bool | false | Leave off. See the note above: a partial egress allowlist for Grafana fails as empty panels rather than as an error. |
| grafana | list | [] | 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
|
| grafana | object |
{
"enabled": false
} | Grafana Image Renderer, for server-side PNG rendering of panels. Deliberately off, and it should stay off in production: the renderer is a headless Chromium that fetches arbitrary URLs on Grafana's behalf, which makes it both a large attack surface and a server-side request forgery pivot into the cluster network. A render-time check warns when it is enabled. |
| grafana | object |
{
"enabled": false
} | Helm test hooks shipped by the subchart. Off: it pulls a `bats` image this chart does not otherwise use or pin, and the e2e suite covers what it asserts. |
| grafana | bool | true | 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`. |
| grafana | object |
{
"enabled": true
} | Scrape Grafana's own metrics. |
Alertmanager#
Bundled Alertmanager for routing alerts emitted by the rule packages.
| Key | Type | Default | Description |
|---|---|---|---|
| alertmanager | string | "monitoring-scalable" | Scheduling priority. See the Priority classes section. |
| alertmanager | string | "4Gi" | 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. |
Kube State Metrics#
kube-state-metrics for Kubernetes resource-state metrics consumed by Materialize-adjacent dashboards.
Upstream reference:
| Key | Type | Default | Description |
|---|---|---|---|
| kube-state-metrics | string | "monitoring-scalable" | Scheduling priority. See the Priority classes section. |
| kube-state-metrics | object |
{
"egress": [
{
"ports": [
{
"port": 53,
"protocol": "UDP"
},
{
"port": 53,
"protocol": "TCP"
}
],
"to": [
{
"namespaceSelector": {}
}
]
},
{
"ports": [
{
"port": 443,
"protocol": "TCP"
},
{
"port": 6443,
"protocol": "TCP"
}
]
}
],
"enabled": true,
"ingress": [
{
"from": [
{
"podSelector": {
"matchLabels": {
"app.kubernetes.io/name": "alloy-gateway"
}
}
}
],
"ports": [
{
"port": 8080,
"protocol": "TCP"
},
{
"port": 8081,
"protocol": "TCP"
}
]
}
]
} | NetworkPolicy for kube-state-metrics. Ingress is narrowed to the Alloy gateway, which is what scrapes it
(
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
|
| kube-state-metrics | object |
{
"honorLabels": true
} | Keep kube-state-metrics' own labels when they collide with the scrape's. Load-bearing, not a preference. Every The result is not missing data, which is why it went unnoticed: every
series arrives, Both endpoints, so turning Asserted by the e2e suite: |
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.
| Key | Type | Default | Description |
|---|---|---|---|
| node-exporter | string | "node-exporter" | Standard Helm full-name override. We use a static name for deterministic relations. |
| node-exporter | string | nil | Namespace override. |
| node-exporter | string | "monitoring-critical" | Scheduling priority. See the Priority classes section. Critical: a node with no node-exporter is a node nothing else reports on, and the gap does not backfill. |
| node-exporter | object |
{
"distroless": true,
"registry": "quay.io",
"repository": "prometheus/node-exporter",
"tag": "v1.12.1"
} | Container image. Split into registry / repository / tag so Renovate's `helm-values` manager can bump the exporter independently of the subchart version — the two move on different cadences and a chart release is not a prerequisite for a node_exporter CVE fix. `distroless: true` appends `-distroless` to the tag, giving an image with no shell and no package manager. node_exporter reads the host's `/proc`, `/sys` and `/` — a shell in that container is a materially better foothold than a shell in most others, and nothing in our configuration needs one. |
| node-exporter | object |
{
"limits": {
"memory": "64Mi"
},
"requests": {
"cpu": "10m",
"memory": "64Mi"
}
} | Resource envelope, matching what Materialize runs in production today. Deliberately no CPU limit. This is a DaemonSet that does nothing between scrapes and then bursts for the length of one collection; a CPU limit turns that burst into CFS throttling, which shows up as scrape timeouts on exactly the loaded nodes where you most need the sample. The 10m request is what it averages, not what a scrape costs. Memory is request == limit, which is the opposite call for the opposite
reason: node_exporter’s working set is flat and bounded (~20-30 MiB for this
collector set), so a limit equal to the request costs nothing and makes the
per-node footprint a number the cluster autoscaler can actually plan with.
This lands the pod in Burstable QoS. Guaranteed would require the CPU limit
we just argued against; being evicted a little earlier under node pressure
is the better trade, and |
| node-exporter | object |
{
"rollingUpdate": {
"maxUnavailable": "10%"
}
} | Rolling update budget for the DaemonSet. Upstream's `maxUnavailable: 1` walks a large fleet one node at a time, which makes an image bump take hours. Metrics are gap-tolerant and the pod restarts in seconds, so a percentage keeps rollout time flat as the fleet grows. |
| node-exporter | object |
{
"enabled": false
} | kube-rbac-proxy sidecar, which would put the exporter behind TokenReview/SubjectAccessReview over HTTPS. Off deliberately. It is a second container on every node, and DaemonSet overhead is the constraint we are managing here — the sidecar’s own request is comparable to node_exporter’s, so it roughly doubles the per-node cost of node metrics to protect an endpoint that exposes no secrets. What it would buy, for when that trade changes: authenticated scrapes, and
(via Note that with |
| node-exporter | object |
{
"enabled": true,
"ingress": [
{
"from": [
{
"podSelector": {
"matchLabels": {
"app.kubernetes.io/name": "alloy-gateway"
}
}
}
],
"ports": [
{
"port": 9100,
"protocol": "TCP"
}
]
}
]
} | NetworkPolicy for the exporter. Ingress is narrowed to the Alloy gateway, which is what actually scrapes it
( Read the hostNetwork caveat before relying on this. The pods run with
The default |
| node-exporter | bool | true | 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. |
| node-exporter | list | [ "--collector.disable-defaults", "--collector.cpu", "--collector.cpufreq", "--collector.loadavg", "--collector.schedstat", "--collector.stat", "--collector.pressure", "--collector.meminfo", "--collector.vmstat", "--collector.swap", "--collector.vmstat.fields=^(oom_kill|pgpg|pswp|pg.*fault|pgscan|pgsteal|workingset).*$", "--collector.diskstats", "--collector.filesystem", "--collector.filesystem.mount-points-exclude=^/(dev|proc|run/credentials/.+|sys|var/lib/docker/.+|var/lib/containers/storage/.+|var/lib/kubelet/(pods|plugins)/.+|run/containerd/.+)($|/)", "--collector.nvme", "--collector.xfs", "--collector.netdev", "--collector.netdev.device-exclude=^(lo|lxc.*|veth.*|cali.*|azv.*|docker.*|br-.*|nodelocal.*|kube-ipvs.*|dummy.*)$", "--collector.netclass", "--collector.netclass.ignored-devices=^(lo|lxc.*|veth.*|cali.*|azv.*|docker.*|br-.*|nodelocal.*|kube-ipvs.*|dummy.*)$", "--collector.netstat", "--collector.sockstat", "--collector.softnet", "--collector.udp_queues", "--collector.conntrack", "--collector.arp", "--collector.os", "--collector.uname", "--collector.time", "--collector.timex", "--collector.filefd", "--collector.entropy", "--collector.hwmon", "--collector.selinux", "--collector.kernel_hung" ] | Extra arguments to node_exporter, which is where the collector allowlist lives.
Every name here must exist on Linux. An unknown Chosen for a network- and memory-hungry Rust database that swaps on purpose:
Left off on purpose, with the reason, since each is one line to re-add:
|
Metrics Server#
metrics-server for pod and node resource usage; only needed when the cluster does not already ship one.
Upstream reference:
| Key | Type | Default | Description |
|---|---|---|---|
| metrics-server | int | 1 | Number of replicas for metrics-server. |