Installing via Helm#
If you are not using the terraform module to install materialize-monitoring, you can use the provided Helm charts to install the monitoring stack in your Kubernetes cluster.
For a short-lived install to troubleshoot a cluster, see Ad-hoc install instead.
The Terraform modules are the recommended path: they create the buckets and workload-identity bindings, derive every backend key from one
object_storageobject, and roll Alloy when its config changes.The charts are the full-fidelity surface — everything Terraform does is a layer over them — so reach for Helm when you need a setting the modules do not model, or when Terraform is not how you deploy.
What you take on by installing directly is the
[consumer]half of the shared responsibility model: cloud resources, secrets, version pinning, and the Alloy rollout below.
Dependency: Installing CRDs#
materialize-monitoring relies on several Custom Resource Definitions (CRDs) to function properly.
A second materialize-monitoring-crds Helm chart is provided to install these CRDs separately from the main materialize-monitoring chart, which is recommended to manage the lifecycle of these CRDs separately from the main chart.
Install it first, then install materialize-monitoring with --skip-crds:
helm install mzmon-crds oci://ghcr.io/materializeinc/helm-charts/materialize-monitoring-crds --namespace monitoringThe bundled Grafana Operator ships its own copy of the Grafana CRDs and offers no way to opt out of them, so without --skip-crds a fresh install of the main chart would create them behind the CRDs chart’s back.
See Dependencies for the full list of CRDs.
Dependency: Setting Up Storage#
You will likely need to set up storage for your metrics and logs before you can start using materialize-monitoring.
The specific steps for setting up storage will depend on your environment and the storage solution you choose.
If you are using both external metric storage and external log storage, you will not need an object storage bucket.
Two buckets (or containers) are the norm, one per backend — Loki and Thanos want different lifecycle rules, and per-bucket IAM keeps each backend out of the other’s data.
Cloud Managed Kubernetes Service (AWS EKS, Google Cloud GKE, Azure AKS, etc.)#
Grant access through workload identity rather than static keys. The binding is per-cloud and documented step by step, including the trust-policy subjects that must match the chart’s rendered ServiceAccount names:
- Logs & Events > Storing — IRSA, GKE Workload Identity, and Entra Workload ID, in tabs
- Metrics > Storing — the same three for Thanos
Then name the backend in your values. This is the step that most often goes wrong: the chart’s defaults are S3-shaped, so any other backend has to be named in three load-bearing places and none of them fails softly — the client is chosen by name and validated against a config that was never populated, so the component crash-loops. See Selecting the backend.
The example profiles do all of it correctly and are the shortest path:
| Profile | Backend |
|---|---|
profiles/aws-example.values.yaml | S3 + IRSA |
profiles/gcp-example.values.yaml | GCS + GKE Workload Identity |
profiles/azure-example.values.yaml | Blob + Entra Workload ID |
profiles/aws-amp-fanout.values.yaml | S3 for logs; metrics to the bundled Thanos and Amazon Managed Prometheus at once |
Read the Azure profile’s header before copying it. Entra Workload ID needs a pod label as well as the ServiceAccount annotation, and the two subcharts take that label under different keys —
loki.loki.podLabelsfor Loki,thanos.global.commonLabelsfor Thanos, which has nopodLabelsof its own.
On-Premises Kubernetes Cluster with Access to Cloud Object Storage (S3, GCS, Azure Blob Storage, etc.)#
Without a cloud identity provider there is no workload identity to bind, so credentials become static keys supplied as a Secret — the documented escape hatch rather than the recommended path. Both backends read them by reference:
- Loki takes them under
loki.loki.storage.object_store.<backend>; prefer supplying the values throughloki.<component>.extraEnvFrom(asecretRef) over inlining them, since inline values render into a ConfigMap in plaintext. - Thanos takes them inside
thanos.global.objstore.config, which becomes a Secret whencreateSecret: true.
Any S3-compatible endpoint works, including Ceph RGW, MinIO, and rustfs. The chart’s own tier-2 E2E runs against rustfs.
profiles/s3-compatible.values.yaml sets up both backends for one of these stores.
Its header has the commands that create the two Secrets it reads: the Thanos objstore document, and a pair of environment variables for Loki.
Write the endpoint as HOST:PORT with no scheme, because the objstore client rejects one.
Set insecure: true for a store that serves plain HTTP.
The chart’s validators grade credential handling and will tell you when a cloud backend has neither an identity annotation nor inline credentials — at which point the component falls back to ambient node credentials, which usually means it silently works in one cluster and fails in another. Read the render output rather than assuming silence.
Customizing your Helm Installation#
The materialize-monitoring Helm chart is designed to be highly customizable, so you can easily integrate with your existing observability infrastructure.
Typically, you would want to create a values.yaml file that has your
specific configurations.
You can start fresh, or start from the profiles in the chart’s profiles/ directory.
Be aware that when merging examples together that you do not have multiple of the same key on the same level since they do not automatically merge. YAML is whitespace sensitive.
You must specify -f YOUR_VALUES.yaml in your helm install/helm upgrade command to apply these customizations.
These are automatically overlaid on top of the default values of the
chart, so you only need to specify the values that are different from the default.
This documentation may refer to values in dotted notation (e.g.,
component.subcomponent.key=value) which corresponds roughly to this YAML structure:component: subcomponent: key: value
Get the profiles#
The profiles ship inside the chart package, so you don’t need to download them one at a time. Pull the chart and unpack it:
helm pull oci://ghcr.io/materializeinc/helm-charts/materialize-monitoring --version VERSION --untarReplace VERSION with the chart version you install.
Every profile is then at materialize-monitoring/profiles/, and you can pass each one with -f.
Pull the same version you install, because a profile can name values that exist only in that version.
Choosing which components run, via tags#
Which subcharts install is driven by tags, and the semantics are OR: a component runs if any tag covering it is true. There is no need to disable a group before enabling one member.
| Tag | Covers |
|---|---|
tags.default | everything below except metrics-server. true by default |
tags.bundled-backends | Loki, Thanos, Alertmanager |
tags.managed-grafana | Grafana and grafana-operator |
tags.pipeline | both Alloy roles (agent + gateway) |
tags.cluster-metrics | kube-state-metrics, node-exporter, and metrics-server. metrics-server installs only through this tag or tags.metrics-server, because most clusters already run one |
Per-chart overrides — tags.loki, tags.thanos, tags.alloy-agent, tags.alloy-gateway, tags.grafana-standalone, tags.grafana-operator, tags.alertmanager, tags.kube-state-metrics, tags.metrics-server — are OR’d on top.
So a Loki-only install is tags.default: false plus tags.loki: true, and adding Thanos to a default install needs nothing at all.
Disabling a Component#
Each subchart also has an enabled circuit breaker that takes precedence over every tag. Setting loki.enabled: false turns Loki off even with tags.default: true, which is the reliable way to subtract one component from an otherwise default install.
Sizing#
The chart’s defaults target a medium deployment, so the sizing profiles are deltas in either direction rather than a full configuration:
Each backend has its own pair, because they size off different axes — Loki off log throughput, Thanos off active series and object count. Pick one from each row you deploy; they compose.
| Profile | Use |
|---|---|
profiles/loki-small.values.yaml | dev, or a constrained node pool |
profiles/loki-large.values.yaml | high-volume logging |
profiles/thanos-small.values.yaml | dev, or a constrained node pool |
profiles/thanos-large.values.yaml | high-cardinality metrics; also enables the Thanos query-frontend and repoints the datasource at it |
| (none) | medium — the chart defaults, for either backend |
profiles/loki-test.values.yaml | CI only: SingleBinary Loki on local filesystem, no object storage |
profiles/kind.values.yaml | CI only: shrinks every workload to fit one kind node. Sizing only — see below |
See Production Best Practices for the envelope each tier assumes: logging throughput for Loki, active series and collections for Thanos, including how to pick a tier from cluster inventory before you have any metrics to measure.
The kind profile is sizing only, and composes last#
The chart defaults request roughly 33Gi of memory and 9 CPU in total, which no standard CI runner will schedule — pods sit Pending and it reads like a chart bug rather than a capacity one.
profiles/kind.values.yaml cuts that to under 4Gi and about 1.6 CPU.
It sets container requests and limits, volume sizes, and cache allocations, and nothing else: no feature toggles, no tags, and deliberately no replica counts.
Compose it last so its sizes win:
helm install mzmon . -f profiles/loki-test.values.yaml -f profiles/kind-tier1.values.yaml -f profiles/kind.values.yamlWhy no replica counts.
loki-testdisables Loki’s distributed components by settingreplicas: 0on them, so a replica count in a sizing overlay would switch them back on — and Loki then refuses to render at all, withmore than zero replicas configured for both the monolithic and distributed targets. Separately, Loki’s ingesters and Thanos Receive both sit at the replication-factor floor of 3, and the ring behaviour they provide is much of what an E2E run exists to prove. So this profile shrinks each pod and leaves the number of them to whoever owns the topology: three tiny pods, not one big one.Two sizing traps it has to work around, both of which fail as an OOM rather than as a small install if you only lower the request: Thanos Store Gateway holds a 2GB chunk pool and a 250MB index cache by default, and memcached derives its pod request from
allocatedMemory. Both are shrunk explicitly.
Other shape overlays#
| Profile | Use |
|---|---|
profiles/existing-grafana.values.yaml | point at a Grafana you already run instead of installing one |
profiles/grafana-postgres.values.yaml | Grafana state in Postgres rather than SQLite — the production shape |
profiles/grafana-pvc.values.yaml | Grafana state on a PersistentVolume — durable, but still one replica. For a Helm-only install with no database |
profiles/grafana-ingress.values.yaml | make Grafana reachable over an Ingress, internal by default and TLS-terminated. Pair it with one of the two above |
profiles/scheduling.values.yaml | node selector, tolerations, and priority-class names, fanned out to every subchart. Edit the four anchors at the top; see below |
profiles/storage-class.values.yaml | one StorageClass, aimed at the four workloads that claim a volume. Edit the single anchor at the top |
profiles/no-storage-class.values.yaml | for clusters with no StorageClass. Puts the same four workloads, and Grafana, on emptyDir and requests node disk for them instead. Grafana then keeps UI changes only until it restarts. Compose it last. On an existing install, delete the four StatefulSets first; see StatefulSet fields that cannot be patched |
profiles/no-zone-spread.values.yaml | for clusters with no zone labels, or one zone. Without it Thanos Receive and Loki’s ingesters stay Pending; see Production Best Practices |
profiles/s3-compatible.values.yaml | Ceph RGW, MinIO, or another S3-compatible store with static keys, for both backends. The keys come from two Secrets you create; the header has the commands |
profiles/adhoc.values.yaml | the whole stack in one file: one replica of everything, no volumes, no NetworkPolicies. For troubleshooting, not production; see Ad-hoc install |
profiles/split-namespace.values.yaml | one namespace per subchart. Changes every workload-identity subject; see Namespace layout |
profiles/noop.values.yaml | a release that creates no resources. Install it first, then turn components on over it one tag at a time |
profiles/otel-metrics-fanout.values.yaml | additional metric destinations (GCM, Datadog) with per-destination importance tiers |
profiles/otlp-metrics-honeycomb.values.yaml | a generic OTLP metrics backend |
Hardened images and private registries#
profiles/registry/ repoints the stack at a hardened-image vendor and wires up the pull secret that vendor’s registry needs.
| Profile | Use |
|---|---|
profiles/registry/pull-secret.values.yaml | name a dockerconfigjson Secret on every workload. Compose it first; it sets no registries, so it works for a mirror as well as a vendor |
profiles/registry/mirror.values.yaml | a private mirror of the upstream images — same binaries, different host |
profiles/registry/chainguard.values.yaml | Chainguard Images — cgr.dev/<ORG>/<image> |
profiles/registry/docker-hardened-images.values.yaml | Docker Hardened Images — docker.io/<ORG>/dhi-<image> |
Compose the pull secret first, then one of the others:
helm install mzmon . -f profiles/registry/pull-secret.values.yaml -f profiles/registry/mirror.values.yamlMirroring#
A mirror changes where each image is pulled from and nothing about the image itself — same binary, same digest, same tag — so it is the least disruptive option here and the one most often mandated rather than chosen. It is also the only one that can carry the Alloy image, since that is a Materialize build rather than something a hardened-image vendor publishes.
Check first whether you need chart values at all. Mirroring happens at two layers, and only one is a chart concern:
- At the node — containerd’s
registry.mirrors/hosts.toml, or anImageDigestMirrorSeton OpenShift — redirects pulls transparently while manifests keep naming the upstream registry. If you have this, change nothing. Rewriting references in values bypasses the indirection you set up and leaves the same mapping maintained in two places. - In the manifest — what
mirror.values.yamldoes. Needed when you cannot reach node configuration, which covers most managed control planes, or when policy requires the mirror to be visible in the manifest rather than inferred from cluster state.
The profile assumes one project per upstream registry, the Harbor proxy-cache and Artifactory remote-repository shape, and this stack pulls from four: docker.io, quay.io, registry.k8s.io, and ghcr.io.
Four anchors at the top carry those; if your mirror is flat and path-preserving instead, set all four to the same value.
Four images spell their registry inside image.repository rather than taking a separate registry key, so anchors cannot reach them and they are written out in full — they are marked HAND-EDIT at the line, and missing one is the likely way to get this half-applied.
The pull-secret overlay is a separate file because the subcharts do not agree on which global to read.
Alloy reads global.image.pullSecrets; Thanos, Grafana, kube-state-metrics and node-exporter read global.imagePullSecrets; and Loki, grafana-operator, Alertmanager and metrics-server read neither and need their own key.
Setting only global.imagePullSecrets leaves Loki — the largest pod count in the release — pulling anonymously, which surfaces as ImagePullBackOff on the ingesters long after helm install reports success.
global.imageRegistryis not the one-line version of the mirror profile. It misses Alloy (which readsglobal.image.registry) and Alertmanager and metrics-server (which carry the host insideimage.repositoryand have noregistrykey) — each of which then keeps pulling from the internet rather than erroring. In the Loki chart it also outranks every per-componentimage.registry, so setting it alongside a profile silently overrides everything that profile chose.
Each vendor profile’s header lists what it deliberately leaves on an upstream registry. Common to both: the Alloy image is a Materialize build carrying the custom components the pipelines are written against, so it is a rebuild rather than a retag — which is why a mirror can carry it and a hardened rebuild cannot.
The three Jobs this chart renders itself — the pre-delete cleanup hook and the two Alloy pre-install validators — read both globals directly, so the pull-secret overlay covers them with nothing extra to set.
Confirm the cleanup hook’s image is pullable before you rely on it: it runs at pre-delete, so an image it cannot pull hangs helm uninstall on the hook rather than failing an install you can retry.
Hardened images run as their own non-root UID, which is generally not the UID the upstream chart writes into
securityContext— Grafana’s chart assumes 472, Loki’s assumes 10001. A mismatch renders and schedules cleanly and then crash-loops on a volume the container cannot write. Check the UID before pointing one of these at a cluster that holds data.These images also ship no package manager, so
grafana.plugins— which installs at container start — silently gets you a Grafana without those plugins. Bake them into a derived image instead.
Why there is no Bitnami profile#
Bitnami Secure Images publishes hardened builds of nearly everything in this stack, so its absence here is deliberate rather than an oversight.
The two profiles above are retags: Chainguard and DHI rebuild the upstream image and keep its entrypoint, arguments, and filesystem layout, so a values change is the whole change.
Bitnami’s images are built for Bitnami’s own charts — Bitnami entrypoint scripts, /opt/bitnami paths, UID 1001 — while the subcharts here are the upstream Grafana, Thanos, and prometheus-community ones, which pass their own arguments and mount config at upstream paths.
Reconciling that is per-component work no values file can express, which is the part a profile could not have finished for you.
Three concrete things sit on top of that, if you are weighing the port anyway:
- Bitnami publishes no
loki-canary, and the canary is on by default — so an otherwise-complete swap is broken until you also setloki.lokiCanary.enabled: false. There is likewise nok8s-sidecar,prometheus-config-reloader, oraccess-log-exporter, though all three are disabled by default here. - Bitnami tags carry a distro and revision suffix (
3.7.6-debian-12-r0) rather than plain semver. Theprometheus-node-exporterchart feeds its tag tosemverCompare, so that suffix fails the render outright withinvalid semantic versionuntil you also setnode-exporter.versionto the upstream version the tag was built from. - The catalog is subscription-gated. The free
docker.io/bitnamisecurenamespace carries a small sampler that includes none of these applications, so there is no anonymous tag to pin against.
Disabling a Component#
If you want further control of the managed components, you can selectively disable components in the materialize-monitoring Helm chart by setting the enabled field for that component to false in your YOUR_VALUES.yaml file or via --set in your helm install/helm upgrade command.
Scheduling: where each subchart wants it#
The subcharts disagree about where scheduling settings go, which is the whole reason this is a profile rather than three --set flags.
thanos.global covers everything Thanos runs; loki.defaults covers everything Loki runs except three components that render from their own templates; Alloy puts it under controller; the rest take it at the top level.
A selector written to the wrong one of those renders perfectly and does nothing.
Edit the four anchors at the top of the file and nothing else — every fan-out site aliases them. The two class names match the chart’s defaults, so they are a no-op until you change them; aliasing both the created classes and all eleven references off one anchor is what makes renaming them a two-line edit that cannot half-apply.
A nodeSelector and a toleration are not two spellings of the same idea, and per-node collectors need them treated differently.
A nodeSelector narrows where a pod may run. On a DaemonSet whose job is to observe every node, that is a silent blind spot rather than a placement preference — an
alloy-agentconstrained to a workload pool simply stops collecting logs from everywhere else, and no dashboard shows a hole where the nodes should be. Tolerations widen placement, which is exactly what a DaemonSet wants so it can reach tainted, spot, and system pools.So
alloy-agentreceives tolerations and never a selector.node-exporterreceives neither: its chart default already tolerates everyNoScheduletaint, and a toleration list would replace that rather than extend it, because Helm merges maps but overwrites lists.
metrics-server takes a selector and tolerations but deliberately no priority class — it stays on the upstream system-cluster-critical, because it backs the metrics API that HPAs and the Materialize Console read, which makes it cluster plumbing rather than monitoring.
Terraform users need neither file. The module’s node_selector, tolerations, and storage_class variables carry the same maps and take the values directly. The two copies are kept honest by make terraform-render, which plans each example, renders the chart against the values it composed, and asserts that every pod template carries the selector and every volumeClaimTemplate carries the class — so a subchart that renames a key fails in this repository rather than in an apply.
Initial Installation#
CRDs first, then the chart with --skip-crds. Both releases go in the same namespace; the CRDs are cluster-scoped, so that namespace only holds the release metadata.
helm install mzmon-crds oci://ghcr.io/materializeinc/helm-charts/materialize-monitoring-crds \
--namespace monitoring --create-namespace \
--version X.Y.Z --waithelm install mzmon oci://ghcr.io/materializeinc/helm-charts/materialize-monitoring \
--namespace monitoring --skip-crds \
--version X.Y.Z \
-f my-values.yaml \
--timeout 15mThe dashboards ship in charts of their own, one per Grafana folder, and the main chart doesn’t install them. Without these releases, Grafana comes up with its data sources and folders but no dashboards:
helm install mzmon-dashboards oci://ghcr.io/materializeinc/helm-charts/materialize-monitoring-dashboards \
--namespace monitoring \
--version X.Y.Z
helm install mzmon-dashboards-infra oci://ghcr.io/materializeinc/helm-charts/materialize-monitoring-dashboards-infra \
--namespace monitoring \
--version X.Y.Z
helm install mzmon-dashboards-meta oci://ghcr.io/materializeinc/helm-charts/materialize-monitoring-dashboards-meta \
--namespace monitoring \
--version X.Y.ZThe first carries the Materialize environment dashboards, the second the platform underneath it, and the third the monitoring stack itself.
Install the ones you want.
Their defaults match a materialize-monitoring release named mzmon.
For a different release name, see Importing dashboards via Grafana Operator.
Pin --version on each.
The CRDs chart, the main chart, and the dashboards charts have separate lifecycles, so they don’t share a version.
The dashboards charts share one version among themselves.
For the pairs that are tested together, see Compatibility.
A few flags earn their place:
--timeout 15m. Helm’s default is 5 minutes, and a first install brings up Loki, Thanos, Grafana, Alertmanager, and both Alloy roles together. If it still times out, do not raise it again — a timeout almost always means a pod is broken rather than slow. See Troubleshooting.--waiton the CRDs release only. The main chart runs pre-install validation Jobs; if you add--waitthere, add--wait-for-jobstoo, or their verdict is never observed and a bad config rolls anyway.- Not
--atomicon a first install. A rollback destroys the evidence of which component failed, and this stack has enough moving parts that the diagnostic is usually worth more than the cleanup.
Verify it came up#
The chart’s validators print to NOTES.txt, so read the install output rather than assuming silence means success — warnings there are the earliest signal that a backend key or a quorum setting is wrong.
kubectl --namespace monitoring get pods
kubectl --namespace monitoring get grafana,grafanadatasource,grafanamanifestGrafana is ClusterIP, so reach it with a port-forward:
kubectl --namespace monitoring port-forward svc/grafana 3000:80The admin credentials are in the grafana Secret (keys admin-user and admin-password).
The chart generates that password and reuses it across
helm upgrade, because the Grafana chart looks the existing Secret up before generating.That lookup returns nothing during
helm templateand--dry-run, so any render-only pipeline — most GitOps setups — regenerates the password on every sync. Supply your own Secret and setgrafana.grafana.admin.existingSecretthere. The Terraform module does exactly this, for exactly this reason.
After changing values#
Restart Alloy after any pipeline or filter change. This is the one place the chart cannot own its own rollout, and it is the reverse of what a chart normally guarantees: a helm upgrade that reports success can leave both Alloy roles serving the previous configuration indefinitely.
kubectl --namespace monitoring rollout restart deployment/alloy-gateway daemonset/alloy-agentThe mechanism, and why a reload cannot substitute for a restart, is in Production Best Practices. The short version: the pipeline’s -env ConfigMaps are consumed with envFrom, and environment variables are fixed at container start, so neither Alloy’s /-/reload nor a config-reloader sidecar can pick up a filter change.
Going to production#
Production Best Practices is the deployment checklist, ordered by what goes wrong if you skip an item and tagged with who owns it: the chart, you as the chart consumer, or you as the operator.
On the Helm path every [consumer] item is yours, including the buckets and workload-identity bindings that the Terraform modules would otherwise create.
Upgrading and uninstalling#
- Upgrading — ingester rollouts are ordered and readiness-gated, so budget roughly a minute per ingester and raise your client’s timeout accordingly.
- Uninstalling — a
pre-deletehook now clears the Grafana custom resources while grafana-operator is still running, so an ordinaryhelm uninstallno longer deadlocks on finalizers with no remover. Read this before tearing down a release installed before that hook existed, or one you plan to remove with--no-hooks.
Reference#
- Chart values — every value, generated from
values.yaml - CRDs — what the CRDs chart installs
- Compatibility — chart, CRDs chart, and Terraform module versions