Production checklist#

This checklist is for you if you install or operate the materialize-monitoring stack in production. It covers the collection tier (Alloy), node metrics (node-exporter), and the bundled Loki, Thanos, Grafana, and Alertmanager.

The checklist is ordered by what goes wrong if you skip an item, most severe first:

Exposure
Someone outside the trust boundary can read your data or change your configuration.
Silent failure
Part of the stack stops working without an error, so nothing tells you.
Data loss
An ordinary restart, node loss, or upgrade loses telemetry or state for good.
Outage
Part of the stack fails to install, upgrade, or stay up. These failures are loud.
Capacity and cost
The stack runs short of resources, or costs more than it needs to.
Hardening and extras
Nothing breaks. Each item adds defense in depth, or extra signal where your environment calls for it.

Each item is tagged with its owner under the shared responsibility model, and names the component it touches. The component links to the page that explains the setting. Open items are yours to act on. What the chart already ships is checked, and collapsed at the end of each tier.

For the trust boundaries, the cluster permissions the stack holds, and where credentials live, see Securing.

Shared responsibility model#

Four parties share responsibility for a production deployment. Checklist items are tagged with the primary owner:

TagPartyOwns
[upstream]Upstream service (Loki, Alloy, Grafana)component behavior: the ring, WAL, compaction, query engine, defaults
[chart]materialize-monitoring charttopology and wiring, opinionated defaults, config rendering, validation, shipped dashboards/alerts, the profiles
[consumer]Chart consumer (Terraform / Pulumi / ArgoCD / FluxCD)cloud resources (bucket, IAM/IRSA, StorageClass, DNS), secret provisioning, version pinning, selecting the profile
[operator]Human operator (end-user)size selection, retention budget, tenant policy, incident response, day-2 procedures, capacity watch

The following table shows how the parties split each area:

AreaUpstreamChartConsumerOperator
Component logic (ring, WAL, compaction, query)ownsconfigures——
Topology, defaults, validation, dashboards/alerts—ownsselects profile—
Cloud resources: bucket, IAM/IRSA, StorageClass—consumes by nameownsapproves
Secrets provisioning—consumes by nameprovidesrotates
Rolling Alloy after a config change—cannotownsverifies
Size, retention budget, tenant policy—offers profilessets valuesdecides
Incident response, upgrades, DR, capacity—tooling + alertsapplies changesowns

If you install with Terraform#

The Terraform modules are a [consumer] implementation, so several items are already done when you use them. Those are marked (Terraform: automatic); read them to understand what’s happening, not as work to do.

The modules satisfy the following:

Object storageOne bucket per backend on AWS and GCP, with versioning on; on Azure, one storage account with a container per backend
Workload identityOne identity per backend: IRSA on AWS, Workload Identity on GCP, a user-assigned managed identity with a federated credential on Azure. No static keys
Version pinningThe module and the chart are one release, so the module ref names the chart version
Retention postureNo bucket expiry by default, leaving deletion to each compactor
Grafana admin secretGenerated and supplied by name
Alloy rolloutsA values hash on both pod templates, so a config change rolls Alloy and an unchanged apply does not

The following stay yours on any install path:

  • A usable StorageClass, for the four PVC-backed workloads: Alertmanager, the Loki ruler, and the Thanos Store Gateway and Compactor. The modules don’t create one. For what “usable” means on GCP, see the StorageClass item under Outage.
  • Sizing and retention budgets, node-pool capacity, and the profile choice.
  • In-cluster TLS between components. Certificates and the mTLS profiles ship off by default on both paths; for which hops each phase covers, see Securing > What is not there yet.
  • Everything tagged [operator].

On Azure, the module turns on no blob versioning, so the disaster-recovery item under Data loss stays yours there in full.

Exposure#

Skip one of these and someone outside the trust boundary can read your data or change your configuration.

  • [operator] Network policies: Confirm your CNI enforces NetworkPolicy. Cilium and Calico do. kindnet, the default in kind and therefore in the E2E tiers, ignores NetworkPolicy entirely, so a green E2E run isn’t evidence that any of this is enforced.
  • [operator] node-exporter: Confirm port 9100 isn’t reachable from outside the cluster. The pods are host-networked, which puts them outside pod NetworkPolicy on most CNIs, so this is a security-group or firewall question, not a Kubernetes one.
  • [consumer] Object storage: (Terraform: automatic) Grant Loki and Thanos bucket access by workload identity (IRSA, GKE Workload Identity, or Azure Workload Identity) rather than static keys, and supply any storage secret by reference. Static keys are a documented escape hatch only. For Thanos, see Metrics > Storing.
  • [operator] Loki: Know that isolation within the Loki tenant is label-based. The hard isolation boundary is the install. That’s fine for trusted internal consumers; per-team or customer-facing access needs Grafana label-based access control or tenant-per-environment writes.
  • [operator] Grafana: Configure an identity provider under grafana.ini before exposing Grafana to anyone but yourself. Any auth.* section Grafana supports works. A render-time check warns when an exposed Grafana has none.
  • [consumer] Grafana: Provide the OAuth client secret as a Secret, referenced with $__file{} or $__env{}. Never put it in grafana.ini as a literal, because that renders into a ConfigMap.
  • [operator] Grafana: Map an IdP group claim onto Grafana roles with role_attribute_path, so group membership does the provisioning and nobody is added by hand. After it’s right, set role_attribute_strict: true, so a broken claim fails the login instead of quietly falling back to Viewer.
  • [operator] Grafana: Know that Grafana’s permissions aren’t a data boundary. Every data source is queryable by anyone who can reach it, so a Viewer in Grafana still reads every metric in Thanos and every log in the tenant.
  • [consumer] Grafana: Choose the protocol layer deliberately. A LoadBalancer Service gives you L4 on every cloud and terminates no TLS. An Ingress gives you L7, terminates TLS, and needs a controller you might not have. L7 is what a public Grafana wants, because a WAF and edge authentication are the two things L4 can’t do, but L4 has no request-timeout ceiling, which matters for the long Thanos and Loki queries a panel makes. The grafana-ingress profile is the L7 shape; the Terraform wrappers use the Service.
  • [consumer] Grafana: Terminate TLS in front of Grafana. It authenticates with a session cookie, so without TLS that cookie and the admin password cross the network in the clear. Use either an Ingress tls block or termination at the load balancer against a cloud-held certificate (ACM on AWS, a managed certificate on GKE). A render-time check warns on an Ingress with no tls; it can’t see the second case, so confirm rather than dismiss it. A bare LoadBalancer Service with no controller in front is the one shape with no TLS anywhere.
  • [operator] Grafana: Supply the certificate or the issuer. The consumer wires it; where the trust comes from, such as a cert-manager ClusterIssuer, an ACM ARN, or an uploaded certificate, is a policy decision.
  • [consumer] Grafana: Set security.cookie_secure: true after TLS is in place. Before that, the session cookie is never sent and nobody can log in.
  • [operator] Grafana: Rotate the generated admin password, or supply your own with grafana.admin.existingSecret. Grafana reads it once at startup, so a rotation needs a restart.
  • [operator] Grafana: Never inline the database password into grafana.ini, because it renders into a ConfigMap. Use $__file{} against a mounted Secret, or $__env{} against envValueFrom. The subchart’s assertNoLeakedSecrets check fails the render if you forget; leave it on.
  • [operator] Alertmanager: Don’t expose Alertmanager without authentication in front of it. It has none of its own, and its API creates silences. Grafana’s Alertmanager data source gives you the alert list and the silence editor behind Grafana’s authentication.
  • [consumer] Alertmanager: Publish to Amazon SNS with workload identity, not keys: an IRSA role or EKS Pod Identity association for the alertmanager ServiceAccount, allowing sns:Publish on the topic (plus kms:GenerateDataKey* and kms:Decrypt for an encrypted topic). SNS is the only integration that can use one; SES and Azure Communication Services email authenticate over SMTP with a credential in the Secret.
What the chart already does
  • [chart] Network policies: On by default for every component, with a render-time warning on each one that’s switched off.
  • [chart] Network policies: Validators reject the shapes that render a policy which can’t work: an Alloy flavor the subchart doesn’t implement (it renders nothing, silently), an Alloy or kube-state-metrics policy declaring Egress with no rules, a Grafana policy with neither direction enabled, and a chart-rendered policy that opens no ports at all.
  • [chart] Alloy: NetworkPolicy on both roles, ingress only. The agent accepts 12345 from the gateway alone. The gateway accepts its ingest ports (3100, 4317, 4318, 9090) from any pod in the cluster, because its clients are your workloads, while 12345, which carries clustering gossip and the debugging UI, is restricted to the gateway’s own pods.
  • [chart] node-exporter: NetworkPolicy with ingress restricted to the Alloy gateway on 9100 and egress denied outright. kube-rbac-proxy is off, because it would double the per-node cost to protect an endpoint that exposes no secrets.
  • [chart] Loki: One logical tenant per install, with auth_enabled: true and a single named X-Scope-OrgID, so a future split is config rather than a data migration.
  • [chart] Loki: Grafana’s Loki data source points at the Loki Gateway, two replicas with a PDB, which routes only Grafana’s reads to the query frontend and Grafana’s rule list to the ruler. Rule edits, pushes, and admin endpoints are refused.
  • [chart] Thanos: The objstore config is rendered into a Secret (global.objstore.createSecret) and consumed by every component. A render-time check errors when the identity annotation names a different cloud than the objstore backend, and warns when a cloud backend has neither an annotation nor inline credentials.
  • [chart] Thanos: NetworkPolicies through thanos.global.networkPolicies, one per enabled component. Each allows that component’s own service ports from anywhere and leaves egress open, so it closes non-service ports rather than restricting callers.
  • [chart] Grafana: Internal by default. The Service is ClusterIP and no Ingress is rendered; grafana.ingress and grafana.service are surfaced for when you need them.
  • [chart] Grafana: Public requires an allowlist, enforced. A LoadBalancer Service with no loadBalancerSourceRanges, or a NodePort, which has no allowlist mechanism at all, is a render-time error. connections.grafana.allowPublicAccess: true downgrades it to a warning; it’s an acknowledgement, not a silencer.
  • [chart] Grafana: A render-time check errors on auth.anonymous enabled while Grafana is exposed, unless connections.grafana.allowPublicAccess says the exposure is deliberate.
  • [chart] Grafana: A render-time check warns on a database connection with ssl_mode unset or disable.
  • [chart] Grafana: NetworkPolicy on by default, with ingress on 3000 from any source and every other port on the pod closed. allowExternal stays on because every way a human reaches Grafana is unselectable from here: an ingress controller in another namespace, a load balancer’s client ranges, or kubectl port-forward arriving from the API server. Narrow it with explicitNamespacesSelector and explicitIpBlocks after you know your path in. Egress is unrestricted, because a partial allowlist fails as a dashboard that renders empty with no error.
  • [chart] Grafana: Image Renderer disabled, and a validator warns when it’s turned on. It’s a headless Chromium that fetches URLs on Grafana’s behalf, which makes it a large attack surface and a server-side request forgery pivot into the cluster network.
  • [chart] Alertmanager: A render-time check errors on an inline credential in a receiver or in alerting.global. alerting.assertNoInlineCredentials turns it off; leave it on.
  • [chart] Alertmanager: NetworkPolicy on by default, with 9093 and 9094 (TCP and UDP) open to the release namespace and egress open to notification providers on 443, 587, and 465.

Silent failure#

Skip one of these and part of the stack stops working without an error, so nothing tells you.

  • [consumer] Alloy: (Terraform: automatic) Restart both Alloy roles after any pipeline or filter change. A config change doesn’t roll the pods, and a reload doesn’t pick up the filter half of the config. This is the one place the chart can’t own its own rollout.
  • [operator] Alloy: Change pipeline behavior (stages, label families, endpoints) through the YAML sources, never by hand-editing rendered .alloy in a running deployment. Edits there are untracked and lost on the next render.
  • [operator] Alloy: Alert on the pipeline’s own shedding (drop_counter_reason), so a rate limit that’s dropping lines is visible rather than silent data loss. The shipped alloy-log-drops alert covers only entries loki.write fails to deliver.
  • [operator] Alloy and node-exporter: Verify coverage after any node-pool change. The agent’s pod count and count(up{job="node-exporter"}) should both equal count(kube_node_info). A persistent gap is a pool the DaemonSets aren’t reaching.
  • [operator] Alloy and node-exporter: If you use NoExecute taints for routine pool separation, add a toleration or lose those nodes. Neither DaemonSet tolerates them by default. Add the agent’s through the Terraform module’s tolerations, which appends rather than replaces.
  • [operator] Alloy and node-exporter: Record every pool you exclude. Excluding either DaemonSet from a pool, or narrowing its tolerations, is a deliberate blind spot, not a saving. Do it only where the per-node budget can’t absorb the pod, about 64Mi for node-exporter.
  • [consumer] Namespaces: Under the split-namespace layout, add namespaceSelector entries to the network policies. A podSelector without a namespaceSelector doesn’t cross a namespace boundary, so the pod-label rules match nothing and the traffic they allow is dropped, with no error. Add them through networkPolicies.<component>.ingress.extra and egress.extra, and the subcharts’ own ingress lists, such as node-exporter.networkPolicy.ingress; or turn the affected policies off. The render warns when it sees the combination.
  • [consumer] Cluster name: Give every cluster its own name: cluster_name on Terraform, clusterName on Helm. Every log line carries it as cluster, and the default alert group_by includes it. The default, default, distinguishes nothing once two clusters share a log store, a channel, or an incident tool. Two clusters sending one condition to one PagerDuty service then share an incident and resolve each other’s.
  • [operator] node-exporter: If your cluster already runs a node-exporter, turn this one off with node-exporter.enabled: false, or install_node_exporter = false on Terraform. Running both double-counts silently in any sum() or rate().
  • [operator] node-exporter: Alert on collectors failing, not only on the exporter being up. node_scrape_collector_success == 0 means that collector returned nothing while the scrape as a whole succeeded, so up stays green and the metrics never appear. Some failures are expected and worth allowlisting rather than chasing: hwmon is usually empty on cloud VMs, nvme needs NVMe devices, and kernel_hung needs Linux 6.13+ for /proc/sys/kernel/hung_task_detect_count.
  • [operator] node-exporter: schedstat has two kernel gates and fails quietly on the second. /proc/schedstat needs CONFIG_SCHEDSTATS, and since Linux 4.6 the counters are also off at runtime unless kernel.sched_schedstats=1. A missing file shows up in node_scrape_collector_success; the sysctl being off doesn’t, because the file reads as zeros.
  • [operator] Loki: Wire tier-0 alerts to paging: ingester unhealthy or flush failures, compactor not running, discarded samples (loki_discarded_samples_total), object-store errors, and disk usage. Loki down is its own incident.
  • [operator] Thanos: Alert on Receive write failures and quorum errors. With a replication factor of 1 or 2, these are the first sign of a lost pod, and the dashboards don’t show it.
  • [operator] Thanos: Keep the Store Gateway enabled. It’s how queries reach historical blocks; without it, reads are limited to what Receive holds locally.
  • [operator] Grafana: Set grafana.ini.server.root_url to the URL users actually reach. Share links, alert notification links, and OAuth redirect URIs are all built from it, and all three break silently when it disagrees with the host. A render-time check covers it.
  • [operator] Grafana: A hardened Grafana can’t install plugins at start, so grafana.plugins silently gets you a Grafana without them. Bake them into a derived image instead.
  • [operator] Grafana: Alert on Grafana being down. It isn’t a data-loss incident, but it’s the interface every other alert is investigated through.
  • [operator] Alertmanager: Configure at least one receiver. Until one exists, every alert reaches mzmon-null, and the render warns.
  • [operator] Alertmanager: Choose alerting.preset deliberately. important is the default because it errs in the least damaging direction. A deployment for which Materialize is critical infrastructure wants critical-infrastructure, which routes critical to page. A preset of your own is one more key under alerting.presets.
  • [operator] Alertmanager: Test a receiver end to end after configuring it, with amtool alert add from inside a pod. The render proves the configuration is well-formed, not that a vendor accepts the notification.
  • [consumer] Alertmanager: Provision the alertmanager-receivers Secret in the namespace the Alertmanager pods run in, with External Secrets Operator, Vault Agent, SOPS, or a CSI driver. The chart consumes it by name and never creates it. It’s mounted as optional, so a missing Secret shows up as failed notifications, not as a pod that won’t start. Rotating a credential in it needs no restart: Alertmanager reads a *_file credential on each send, and the kubelet refreshes the mount within about a minute.
  • [operator] Alertmanager: Leave alertmanager.baseURL empty unless Alertmanager itself is exposed, and then set it to that address. Never point it at Grafana, which doesn’t serve Alertmanager’s UI paths, so every link in a notification breaks. The render warns when the two share a host.
  • [operator] Alertmanager: Under split-namespace, the profile opens Alertmanager to the ruler and Grafana namespaces. A receiver listening on a port other than 443, 587, or 465 needs that port added to networkPolicies.alertmanager.egress.external.ports.
  • [operator] Alertmanager: Add meta-alerts on the notifier. The shipped rule set has none on alertmanager_cluster_members, alertmanager_config_last_reload_successful, or alertmanager_notifications_failed_total, so a notifier that can’t deliver is visible on a dashboard and pages nobody.
  • [operator] Alertmanager: Route a deadman’s switch to something outside the cluster. Stopped evaluation looks like nothing being wrong, and only an external heartbeat monitor can tell the two apart.
What the chart already does
  • [chart] Scheduling: Two PriorityClasses, referenced by name from every subchart that accepts a priorityClassName. The per-node collectors and the gateway run at monitoring-critical, because losing one is a blind spot nothing covers. The backends, Grafana, grafana-operator, and Alertmanager run at monitoring-scalable, because a surviving replica or a retry absorbs the loss.
  • [chart] Loki: priorityClassName on every Loki pod. loki.global.priorityClassName covers everything that renders through the chart’s _pod.tpl; three components read only their own key and are set separately: the two memcached StatefulSets and the canary. Left unset, the canary would run at priority 0, so the first node under pressure would evict the end-to-end write→read check during the incident that caused the pressure.
  • [chart] Alloy and node-exporter: Both DaemonSets tolerate every NoSchedule taint, so they reach tainted, spot, and system pools without configuration.
  • [chart] Alloy: ServiceMonitor or PodMonitor (or GCP PodMonitoring) for both roles, scraping Alloy’s own component metrics: received and sent bytes, dropped lines, and write failures.
  • [chart] Alerting: The rule set ships alloy-log-drops (entries loki.write drops), loki-push-err-high (Loki push errors, including 429 rate limits), and logging-collection-down, all on by default.
  • [chart] node-exporter: ServiceMonitor enabled, and prometheus.io/scrape: "false" on the Service so annotation-based discovery can’t scrape it a second time under another job label.
  • [chart] Loki: ServiceMonitor for the Loki components, with the plaintext exporters scraped separately so they survive the mTLS profiles; and loki-canary enabled for end-to-end write→read verification.
  • [chart] Thanos: Query, Receive, Store Gateway, and Compactor enabled by default, with ServiceMonitors for every component (thanos.global.serviceMonitor).
  • [chart] Grafana: ServiceMonitor for Grafana’s own metrics.
  • [chart] Grafana: Grafana-managed unified alerting is not HA out of the box, because each replica evaluates every rule and notifies separately. The grafana-postgres profile enables gossip (headlessService: true plus unified_alerting.ha_peers). Validators warn on several replicas with no gossip, and on ha_peers pointing at a headless Service that was never created. The Prometheus rules this chart ships are unaffected either way.
  • [chart] Grafana: Gossip needs pod-to-pod 9094 on TCP and UDP, which the Grafana subchart’s own NetworkPolicy closes. The chart renders the missing rule as networkPolicies.grafanaGossip when unified_alerting.ha_peers is set and grafana.networkPolicy is on. Turning the supplement off on its own duplicates notifications rather than failing, and a validator warns on that combination.
  • [chart] Alertmanager: Two replicas by default, gossiping over 9094, spread hard across zones and soft across hosts. A validator warns on one replica, and on several replicas with no spread or anti-affinity.
  • [chart] Alertmanager: Both rulers notify every replica, through DNS discovery of the headless Service rather than the load-balanced one.
  • [chart] Alertmanager: ServiceMonitor for Alertmanager’s own metrics, scraping each replica once through the ClusterIP Service, and an Alertmanager data source in the bundled Grafana for the alert list and silences.
  • [chart] Alertmanager: A render-time check errors when the selected preset names a class no receiver serves, on a route naming an undefined receiver or time interval, on a receiver key that isn’t an Alertmanager integration, and on a *_file path under no mounted volume.
  • [chart] Alertmanager: amtool check-config runs in CI over representative configurations, in the Alertmanager image the chart pins.
  • [chart] Alertmanager: Routing changes reload in place: the config-reloader sidecar POSTs /-/reload when the rendered Secret changes, and a configuration Alertmanager rejects leaves the previous one running. A validator warns when the sidecar is turned off.
  • [chart] Alertmanager: Every alert carries cluster, stamped by both rulers from clusterName. The render fails on an empty name, or one Loki’s configuration can’t hold unquoted.
  • [chart] Alertmanager: A validator warns when a narrowed policy closes the mesh port on either transport, which duplicates every notification rather than failing.

Data loss#

Skip one of these and an ordinary restart, node loss, or upgrade loses telemetry or state for good.

  • [operator] Thanos: Keep the Receive replication factor odd, and restate receive.extraArgs in full whenever you set it. The chart sets the factor to 3 through that list, and Helm overwrites lists, so an override that adds any flag drops it to Thanos’s default of 1. At 1, the un-uploaded window exists in one copy on one node. A render-time check warns at factor 1, warns harder at 2, and errors when the factor exceeds replicaCount.
  • [operator] Thanos: Restate compactor.extraArgs in full too. It carries the vertical compaction and deduplication flags, and dropping the first halts the Compactor at the first overlapping block.
  • [operator] Thanos: Know that deduplication is irreversible on an existing bucket. Downsampled blocks written before it was turned on can’t be vertically compacted, and keep their per-replica copies until retention expires them.
  • [operator] Alloy: Confirm the loki.write durability settings (WAL, retry, and backoff) survive gateway restarts to your RPO.
  • [operator] Alloy: Persist the agent’s file positions and journal cursor on a hostPath, so a restart resumes where it left off instead of re-tailing (duplicate lines) or skipping (gaps).
  • [consumer] Loki: (Terraform: automatic) Align the object-store lifecycle policy with Loki’s retention, or make it longer, so the compactor owns deletion. The modules leave bucket expiry off by default, which is the safe end of that alignment.
  • [operator] Loki: Treat schema periods as append-only. A future format change goes in a new period with a from date ahead of now, never in an edit to a past period.
  • [consumer] [operator] Loki: Plan disaster recovery as object versioning, cross-region replication, and, for audit, Object Lock (WORM). Restoring means repointing at the bucket; there’s no native snapshot. The Terraform modules enable versioning by default on AWS and GCP, but not on Azure. Replication and Object Lock remain yours on every cloud.
  • [operator] Grafana: Move Grafana’s state off emptyDir before anyone depends on it. Use the grafana-postgres profile for production, or grafana-pvc for a single small instance with no database available.
  • [operator] Grafana: Back the database up. It holds every service-account token, alert rule, and annotation. Nothing in this chart replicates it, and a PVC snapshot is the only copy on the grafana-pvc path.
  • [operator] Grafana: Switching an existing install from SQLite to PostgreSQL doesn’t carry state over, because Grafana has no migration between them. Export what matters through the HTTP API first.
  • [operator] Grafana: Treat the running Grafana as a cache, not a source. Dashboards edited in the UI are overwritten at the next resync; change them in packages/dashboards/ instead.
What the chart already does
  • [chart] Alloy: The gateway runs at least 2 replicas, the HPA’s floor. It holds in-memory buffers, so a single replica is a delivery gap during restarts and node churn.
  • [chart] Loki: Microservice (distributed) mode, with replication_factor: 3, at least 3 ingesters, a singleton compactor, and a memberlist ring (no Consul or etcd).
  • [chart] Loki: Ingesters are ephemeral, on node-local emptyDir with no PVC. Durability is the replication factor, so a killed or rescheduled ingester’s unflushed data is recovered from its peers.
  • [chart] Loki: flush_on_shutdown: true as a best effort, with a modest terminationGracePeriodSeconds of about 60s; replication covers a truncated flush.
  • [chart] Loki and Thanos: No memory limit on Loki’s ingesters or on Thanos Receive, so an OOM-kill can’t drop buffered logs or the un-uploaded block window. Requests are set for scheduling.
  • [chart] Loki: schema_config uses TSDB, schema v13, and a 24h index period.
  • [chart] Thanos: receive.mode: standalone with replicaCount: 3, an auto-generated ketama hashring, and --receive.replication-factor=3 in receive.extraArgs.
  • [chart] Thanos: Every profile restates receive.extraArgs in full, so the replication factor survives. A unit test pins this at all three sizes.
  • [chart] Thanos: VerticalPodAutoscaler disabled on Receive and the Compactor. The subchart defaults it on for exactly those two, in updateMode: Auto, so where the VPA CRD is present it would rewrite the profile’s requests and evict pods holding up to 6h of blocks on emptyDir. A unit test asserts this with the CRD declared present, so it fails on the value rather than passing on the gate.
  • [chart] Grafana: A render-time check errors on more than one replica, including an HPA ceiling above one, without grafana.ini.database pointed at a shared database. It warns when an exposed Grafana keeps its state on an emptyDir. The grafana-postgres and grafana-pvc profiles ship the assembled shapes.
  • [chart] Grafana: grafana-operator manages dashboards and data sources as code, re-pushing anything it owns on drift.
  • [chart] Alertmanager: A 4Gi volume per replica, for silences and the notification log. Gossip covers losing one replica; the volume covers losing both at once.

Outage#

Skip one of these and part of the stack fails to install, upgrade, or stay up. These failures are loud, but they can read like chart bugs.

  • [consumer] StorageClass: (Terraform: storage_class) A usable StorageClass must exist for the PVC-backed workloads: Alertmanager, the Loki ruler (for its remote-write WAL), and the Thanos Store Gateway and Compactor. “Usable” isn’t the same as “present”. On GCP’s C4 and N4 machine families, which accept only Hyperdisk, every StorageClass GKE creates by default is Persistent Disk, and none of them attaches. Use volumeBindingMode: WaitForFirstConsumer, the default for managed-cloud CSI classes; under Immediate, a PVC’s zone is chosen before scheduling, and the hard zone rules leave pods Pending. On GKE, the legacy standard class is Immediate.
  • [operator] Zones: Tell the chart how many zones you have. The default assumes two or more, and below that it fails closed: Loki’s ingesters and Thanos Receive stay Pending, and on a cluster with no zone labels so does Alertmanager. On Terraform, set min_zones; on Helm, apply the no-zone-spread profile.
  • [operator] Zones: Aim for three or more zones, and raise minDomains to the count your node pools can launch in. The chart’s floor is 2, so with three zones a two-zone placement still satisfies it. Setting it above your real count leaves pods Pending forever. With two zones, an AZ loss can break Loki and Thanos write quorum (2 of 3) until the ring recovers.
  • [operator] Thanos: Check Receive’s ephemeral-storage request against your nodes’ allocatable ephemeral storage, not their disk size, before you pick a profile or raise Receive’s local retention. A request no node shape fits never schedules, and the cluster autoscaler doesn’t rescue it.
  • [consumer] Images: Create the pull Secret in every namespace the stack renders into. Helm doesn’t create it, and a Secret is namespaced, so a split-namespace install needs one copy per namespace, not one for the release.
  • [operator] Images: Verify the vendor’s tag strings before install. Hardened-image vendors track upstream versions without always spelling them identically: a leading v is commonly dropped, and a DHI mirror carries only the tags you chose to mirror. The profiles inherit the chart’s tags rather than restating them, so add an explicit tag for any image whose string differs.
  • [operator] Images: Alloy is a rebuild, not a retag. The chart runs a Materialize build of Alloy carrying the custom components the pipelines are written against, and a stock vendor Alloy fails config validation at startup. A mirror carries it fine; the mirror profile moves it along with everything else, digest intact.
  • [operator] Images: Confirm the pre-delete cleanup hook can pull its image. An image it can’t pull hangs helm uninstall with the release half-removed, and helm uninstall --no-hooks is the way out.
  • [operator] Scheduling: PriorityClasses are cluster-scoped, so two releases of this chart in one cluster fight over them. Set priorityClasses.create: false on all but one, or rename them per release with priorityClasses.critical.name and priorityClasses.scalable.name.
  • [operator] Scheduling: If you rename or disable the PriorityClasses, update the subcharts’ priorityClassName values to match. A priorityClassName naming a class that doesn’t exist doesn’t degrade: the API server rejects the pod, and the only evidence is an admission error on a ReplicaSet nobody is watching. A render-time check warns when it can tell.
  • [operator] Scheduling: If your platform already defines a priority scheme, point the subchart values at your own classes instead. metrics-server stays on the upstream system-cluster-critical deliberately: it backs the metrics API that HPAs and the Materialize Console read, so it’s cluster plumbing rather than monitoring.
  • [operator] Network policies: 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 doesn’t would fail every pod in the namespace at once, which is the symptom to recognize rather than a per-component bug.
  • [consumer] Version pinning: (Terraform: automatic) Pin the chart version, and with it every subchart and image, so upgrades are deliberate.
  • [consumer] Loki: (Terraform: automatic) Provision the object-storage bucket (S3-compatible, GCS, or Azure Blob): a single bucket, with prefixes /loki/chunks and /loki/ruler.
  • [consumer] Loki: (Terraform: automatic) On any backend other than S3, name it in all three load-bearing places: storage.object_store.type, the newest schemaConfig period, and compactor.delete_request_store. A stale one crash-loops the component that reads it.
  • [operator] Loki: Cover the credential endpoint as well as the bucket in Loki’s egress, and declare it explicitly rather than relying on another workload’s broad 443 rule. Missing STS egress crash-loops the compactor with misleading memberlist timeouts.
  • [operator] Loki: Budget the ingester roll and raise the deploy timeout. The serial roll takes about 1 minute per ingester and overruns Helm’s default 5-minute --wait. Set helm upgrade --timeout, Flux spec.timeout, or Pulumi customTimeouts; ArgoCD is async and tolerant. For more information, see Upgrading.
  • [consumer] Grafana: Provision the database and an owning user. Grafana runs its own schema migrations at startup, so a read/write-only grant fails the migration.
  • [consumer] Grafana: Provide the database password as a Secret in the namespace the Grafana pod runs in. Under split-namespace, that’s grafana, not the release namespace. The chart consumes it by name and doesn’t mint it.
  • [operator] Grafana: Create the DNS record. Neither the Ingress nor the Service publishes the hostname, and the chart can’t, because it has no view of your zone. Unless something like external-dns already reconciles records in the cluster, this is a manual step that’s easy to forget until the certificate fails to issue.
  • [operator] Grafana: Keep the local admin as break-glass. disable_login_form: true still leaves /login?disableAutoLogin reachable; know that before an IdP outage takes your incident tooling with it.
  • [operator] Grafana: Don’t update a bundled data source plugin from Grafana’s plugin catalog. On the read-only root filesystem it leaves the data source unloaded until the pod restarts.
  • [operator] Grafana: Where Grafana can’t reach grafana.com, set grafana.ini.plugins.preinstall_disabled: true, which removes the Drilldown apps’ download at every start.
  • [operator] Alertmanager: Treat persistence.enabled and persistence.size as immutable. An upgrade that changes either fails until the StatefulSet is deleted with --cascade=orphan.
  • [operator] Alertmanager: Know the recovery for a replica whose zone is lost: delete its PVC, then its pod, so it reschedules into a healthy zone and resyncs from its peer.
What the chart already does
  • [chart] Alloy: Pipelines are authored as code (packages/alloy-pipelines/*.yaml) and rendered to .alloy. The rendered output is committed, CI asserts it matches a fresh render, and alloy validate runs on every rendered pipeline.
  • [chart] Alloy: A pre-install and pre-upgrade alloy validate hook, so a bad config fails the release rather than a running pod.
  • [chart] Images: profiles/registry/ ships the mirror overlay, the two hardened-vendor overlays, and the pull-secret overlay, with unit tests asserting the pull secret reaches each of the four subcharts that read no global.
  • [chart] Images: The pre-delete cleanup hook and both pre-install validator Jobs carry imagePullSecrets, merged from both global spellings and deduplicated by name.
  • [chart] Scheduling: The scheduling profile carries the whole fan-out (node selector, tolerations, and both class names), with every site aliased off four anchors. A rename is a two-line edit rather than eleven priorityClassName keys across nine subcharts that can half-apply.
  • [chart] Network policies: metrics-server accepts 10250 from any source, unlike everything else. The aggregation layer dials it from the API server, which on a managed control plane isn’t selectable, so closing that port would break kubectl top and every HPA in the cluster. The policy still closes every other port on the pod.
  • [chart] Zones: Hard zone spread, soft host spread on Loki’s ingesters, Thanos Receive, and Alertmanager. Receive’s constraint sets minDomains: 2 and nodeTaintsPolicy: Honor; Loki’s drops the chart’s default hard per-host anti-affinity. The Store Gateway, Query, and Query Frontend get soft spread on both axes, and the Compactor, a singleton, gets none.
  • [chart] Disruption budgets: PodDisruptionBudgets with maxUnavailable: 1 on Loki’s ingesters, every Thanos component, Grafana, and Alertmanager. maxUnavailable scales with the replica count, and on a singleton minAvailable: 1 would permit no eviction and hang node drains. Validators warn on minAvailable for a singleton, on several replicas with no budget, and on a Loki budget or rollout above 1, and error when the Receive budget exceeds what write quorum tolerates.
  • [chart] Loki: The index-gateway and compactor are ephemeral too, so they reschedule freely across zones; the compactor singleton is never PVC-pinned to one zone.
  • [chart] Loki: The StatefulSet rolls ingesters one at a time (maxUnavailable: 1).
  • [chart] Loki: Egress to object storage and the credential endpoint on 443 through networkPolicy.externalStorage (by default ports: [443], cidrs: ["0.0.0.0/0"]).
  • [chart] node-exporter: updateStrategy.rollingUpdate.maxUnavailable: 10%, so an image bump doesn’t walk a large fleet one node at a time.
  • [chart] Thanos: Vertical compaction on, so the overlapping blocks Receive writes across restarts don’t halt the Compactor.
  • [chart] Thanos: Render-time validators cover objstore placeholders, backend and identity mismatch, component topology, replication-factor quorum, and writers or data sources aimed at a Thanos that isn’t deployed.
  • [chart] Grafana: A render-time check errors on a ReadWriteOnce volume paired with a rolling update, and on a volume asked to back several replicas.
  • [chart] Grafana: Probes on /api/health (liveness and readiness) come from the subchart and are left at their defaults.
  • [chart] Grafana: Bundled data source plugins stay at the image’s versions (grafana.ini.plugins.preinstall_auto_update: false), and a validator errors when the auto-update is turned back on.
  • [chart] Grafana: A validator errors when grafana.extraEmptyDirMounts, grafana.extraVolumeMounts, or grafana.extraVolumes adds a second /tmp mount, which the API server would reject.
  • [chart] Alertmanager: Readiness on /-/ready, liveness on /-/healthy, so a rollout replacing one replica at a time loses no silences.
  • [chart] Alertmanager: extraArgs.web.route-prefix: / keeps every endpoint at the root, so a baseURL with a path doesn’t move the API the rulers post to. Resource names are pinned by alertmanager.fullnameOverride: alertmanager, so the rulers address alertmanager-headless whatever the release is called. The render warns or fails when either drifts.

Capacity and cost#

Skip one of these and the stack runs short of resources, or costs more than it needs to.

  • [operator] Loki: Pick the Loki size from the throughput table, and record the measured sustained rate and 5-minute burst it’s based on.
  • [operator] Thanos: Pick the Thanos profile from inventory, and record the inventory it’s based on. thanos-small and thanos-large are deltas from the chart defaults, which target medium.
  • [operator] Thanos: Re-measure head series against the envelope after install, and after any change in collection count.
  • [operator] Thanos: To cut metrics cost, tighten minMetricImportance, not the profile. Downshift only after head series have fallen.
  • [operator] Loki: Set the per-tenant ingestion_rate_mb, ingestion_burst_size_mb, and max_global_streams_per_user from the table. These apply per environment.
  • [operator] Loki and Thanos: Set retention to your storage budget: Loki’s global retention_period, and Thanos’s per-resolution retention.
  • [operator] Loki: Configure tiered (per-stream) retention: keep ERROR and audit streams long, and expire high-volume INFO fast.
  • [operator] Thanos: Keep raw retention above the downsampling thresholds (40h for the 5m tier, 10d for the 1h tier). Below them the tier is never produced, and long-range queries silently fall back to raw blocks.
  • [operator] Loki: Add ingesters (N > RF) when per-ingester memory or stream count climbs, or to spread the regression burst. One tenant concentrates cardinality, so watch per-ingester memory against the total fleet stream count.
  • [operator] Loki: Size memcached per the table.
  • [operator] Loki: Scale queriers and frontends, not ingesters, when dashboards feel slow.
  • [operator] Loki: If you bring nodes up tainted until DaemonSets are healthy, model the taint as a Karpenter startupTaint so it doesn’t over-provision. The ingester spread already sets nodeTaintsPolicy: Honor, so tainted nodes stay out of the skew math.
  • [operator] Alloy: Size gateway memory vertically, from the series each pod holds, rather than adding memory to the HPA.
  • [operator] Alloy: Understand the backpressure semantics before tuning the rate limits: drop = false queues, drop = true sheds.
  • [operator] node-exporter: Don’t add a CPU limit to node-exporter. It turns each scrape’s burst into CFS throttling, which surfaces as scrape timeouts on the loaded nodes whose samples matter most.
  • [operator] Thanos: Keep replicaCount at or above the replication factor. At equality every pod holds every series, which is good availability but no horizontal capacity.
  • [operator] Thanos: Enable Query Frontend for production read paths (splitting and result caching), and repoint connections.datasources.thanos.url at it, or the cache is deployed and bypassed. A render-time check warns on that mismatch. The thanos-large profile does both; at S and M, Query is the data source target.
  • [operator] Thanos: Keep Query’s replicaCount equal to autoscaling.minReplicas. The subchart templates a static replicas alongside the HPA, so every upgrade or GitOps reconcile writes it back; matching the floor makes that reset a no-op. A validator warns when the two disagree.
  • [operator] Thanos: Keep resource requests on anything you autoscale. Without a request the HPA has no CPU target to measure against, so it never scales.
What the chart already does
  • [chart] Alloy: The gateway promotes only a small, stable label set and routes everything else identifying to structured metadata.
  • [chart] Alloy: Per-level rate limits, a per-node pod-log rate cap in the agent (AGENT_POD_LOG_RATE_LIMIT / AGENT_POD_LOG_BURST), stale lines dropped (older_than), and oversized lines cut to fit Loki (stage.truncate).
  • [chart] Alloy: Horizontal autoscaling on CPU only (2–8 replicas, 50%), with slow scale-down: only after an hour of low CPU, and one pod per ten minutes.
  • [chart] node-exporter: A collector allowlist rather than upstream defaults, so a node_exporter release can’t add collectors behind you. Per-pod veths and kubelet per-pod mounts are excluded from the network and filesystem collectors.
  • [chart] node-exporter: Swap covered on both axes (swap, meminfo, vmstat, and pressure), with the vmstat filter widened to admit pgscan_* and pgsteal_*.
  • [chart] node-exporter: 64Mi memory request and limit, 10m CPU request, and no CPU limit.
  • [chart] Loki: reject_old_samples: true and reject_old_samples_max_age set.
  • [chart] Loki: Compactor retention_enabled: true, with delete_request_store and retention_delete_delay configured.
  • [chart] Loki: Results cache (query-frontend), chunks cache, and index and stats cache on the bundled memcached, with query-result caching enabled (cache_results, max_cache_freshness_per_query).
  • [chart] Loki: Query-frontend at 2 or more replicas for queue fairness, with query splitting and parallelism configured.
  • [chart] Thanos: Resource requests on every enabled component, per the profile tables.
  • [chart] Thanos: Compactor retention at raw 30d, 5m 90d, 1h 365d, and replicas deduplicated in the bucket (--deduplication.replica-label=receive_replica), so the bucket holds one copy of each series.
  • [chart] Thanos: Receive TSDB local retention 6h (overriding the subchart’s 24h) with WAL compression, so the emptyDir budget fits a modest node.
  • [chart] Thanos: Horizontal autoscaling on Query (2–5 replicas, 80% CPU), and on Query Frontend when it’s enabled. Store Gateway autoscaling is deliberately off: it’s a PVC-backed StatefulSet that syncs the bucket index on startup, so scale-up serves nothing until it’s warm, and scale-down orphans PVCs.
  • [chart] Grafana: Resource requests and limits (100m / 256Mi requests, 1Gi memory limit), and no CPU limit, because query rendering is bursty.
  • [chart] Grafana: HPA surfaced and off by default, because it’s meaningless on SQLite. The grafana-postgres profile turns it on (2–5 replicas, 60% CPU), and minReplicas becomes the effective floor. A validator warns when autoscaling.targetCPU is set with no CPU request.
  • [chart] Grafana: grafana-operator requests 10m / 64Mi, with a 512Mi memory limit. The subchart sets none, and a BestEffort pod is evicted under node pressure ahead of every pod within its requests, whatever its priority class.
  • [chart] Alertmanager: Resource requests and a memory limit on both containers (10m / 64Mi, 256Mi limit for Alertmanager), and no CPU limit. A quiet install idles near 15Mi; the limit sits well above the request because it’s the one setting that can take both replicas down together during an alert storm.

Hardening and extras#

Skip one of these and nothing breaks. Each adds defense in depth, or extra signal where your environment calls for it.

  • [operator] Network policies: Narrow the 0.0.0.0/0 egress rules where your infrastructure lets you. kube-state-metrics and grafana-operator reach the API server on 443 and 6443 to everywhere, because a chart can’t derive the control plane’s address, and Loki reaches object storage the same way. A VPC endpoint’s CIDR, or Cilium’s toFQDNs, is the tighter form; for Loki, see Object-storage egress.
  • [operator] TLS: Turn on in-cluster TLS where your environment requires it. Certificates and the mTLS profiles ship off by default.
  • [operator] Alertmanager: TLS on the API port comes with the mTLS profiles: alerting.server.tls moves 9093 to TLS, and the profiles move the probes, the reloader, the scrape, both rulers, and the Grafana data source with it. VerifyClientCertIfGiven at phase 2 is as far as it goes, because the kubelet’s probes and the reloader can’t present a certificate.
  • [operator] node-exporter: TLS client authentication on /metrics is available and parked (kubeRBACProxy.tls.tlsClientAuth plus tlsSecret). Turn it on if your environment requires authenticated scrapes, accepting a second container on every node.
  • [operator] Images: Mirror at the node if you can. A containerd hosts.toml or an ImageDigestMirrorSet redirects pulls without touching any chart value, which keeps one mapping in one place. Reach for mirror.values.yaml when you can’t configure nodes, or when policy wants the mirror named in the manifest.
  • [operator] Grafana: Pin plugin versions (name@version) or bake them in, so a plugin can’t change underneath a pinned Grafana. A validator warns on an unpinned entry.
  • [operator] Thanos: If you re-enable VPA deliberately, use updateMode: "Off" for recommendations only, and know that its numbers disagree with the profile by design.
  • [operator] node-exporter: On AWS, consider enabling ethtool with --collector.ethtool.device-include=^(eth|ens|enp). It’s where the ENA allowance counters live (bw_in_allowance_exceeded, bw_out_allowance_exceeded, pps_allowance_exceeded, conntrack_allowance_exceeded): instance-level network throttling that’s invisible in every other metric. It’s off by default because the stat set is driver-specific, so validate it on your AMI before relying on it.
  • [operator] node-exporter: On bare metal, edac (ECC errors) and rapl are worth adding; hypervisors don’t expose either to guests, which is why they’re off.
  • [operator] node-exporter: Turn slabinfo on during an investigation, not as a default. It costs about 1.5k series per node and needs a root init container to chmod /proc/slabinfo, and meminfo already reports Slab, SReclaimable, and SUnreclaim.
What the chart already does
  • [chart] Images: Every image is pinned by tag. The images whose cadence shouldn’t be tied to a chart release (Grafana, Alloy, and node-exporter) are pinned in this chart’s own values.yaml, so Renovate bumps them on their own cadence.
  • [chart] Images: The Alloy image is pinned by digest as well as tag, so the identity of the running pipeline binary is fixed rather than merely named.
  • [chart] Images: Hardened base images are a values change: the overlays under profiles/registry/ repoint every image, Grafana included.
  • [chart] Alloy: Distroless Alloy image: FIPS boringcrypto, multi-arch, non-root, and published to GHCR.
  • [chart] node-exporter: Distroless image (no shell, no package manager), non-root, read-only root filesystem, and automountServiceAccountToken: false. The container reads the host’s /proc, /sys, and /, which makes a shell there a materially better foothold than in most containers.
  • [chart] Loki: runAsNonRoot, read-only root filesystem, and all capabilities dropped on every Loki component, memcached included.
  • [chart] Grafana: Read-only root filesystem, with an emptyDir at /tmp for the Unix sockets Grafana’s backend plugins listen on.
  • [chart] Grafana: analytics.reporting_enabled and check_for_updates are off, and the subchart’s testFramework hook is off.
  • [chart] Alertmanager: Read-only root filesystem, all capabilities dropped, no privilege escalation, seccompProfile: RuntimeDefault, and non-root UID 65534, on both the Alertmanager and the config-reloader containers. A validator warns when the root filesystem is made writable.
  • [chart] Alertmanager: No ServiceAccount token mounted, because Alertmanager reads nothing from the Kubernetes API.
  • [chart] Alertmanager: cluster.label on every gossip message, so a peer from another Alertmanager cluster that inherits a recycled pod IP is rejected rather than merged.