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:
| Tag | Party | Owns |
|---|---|---|
[upstream] | Upstream service (Loki, Alloy, Grafana) | component behavior: the ring, WAL, compaction, query engine, defaults |
[chart] | materialize-monitoring chart | topology 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:
| Area | Upstream | Chart | Consumer | Operator |
|---|---|---|---|---|
| Component logic (ring, WAL, compaction, query) | owns | configures | — | — |
| Topology, defaults, validation, dashboards/alerts | — | owns | selects profile | — |
| Cloud resources: bucket, IAM/IRSA, StorageClass | — | consumes by name | owns | approves |
| Secrets provisioning | — | consumes by name | provides | rotates |
| Rolling Alloy after a config change | — | cannot | owns | verifies |
| Size, retention budget, tenant policy | — | offers profiles | sets values | decides |
| Incident response, upgrades, DR, capacity | — | tooling + alerts | applies changes | owns |
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 storage | One bucket per backend on AWS and GCP, with versioning on; on Azure, one storage account with a container per backend |
| Workload identity | One 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 pinning | The module and the chart are one release, so the module ref names the chart version |
| Retention posture | No bucket expiry by default, leaving deletion to each compactor |
| Grafana admin secret | Generated and supplied by name |
| Alloy rollouts | A 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 inkindand 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 undergrafana.inibefore exposing Grafana to anyone but yourself. Anyauth.*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 ingrafana.inias a literal, because that renders into a ConfigMap. -
[operator]Grafana: Map an IdP group claim onto Grafana roles withrole_attribute_path, so group membership does the provisioning and nobody is added by hand. After it’s right, setrole_attribute_strict: true, so a broken claim fails the login instead of quietly falling back toViewer. -
[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. ALoadBalancerService 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. Thegrafana-ingressprofile 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 Ingresstlsblock 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 notls; it can’t see the second case, so confirm rather than dismiss it. A bareLoadBalancerService 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-managerClusterIssuer, an ACM ARN, or an uploaded certificate, is a policy decision. -
[consumer]Grafana: Setsecurity.cookie_secure: trueafter 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 withgrafana.admin.existingSecret. Grafana reads it once at startup, so a rotation needs a restart. -
[operator]Grafana: Never inline the database password intografana.ini, because it renders into a ConfigMap. Use$__file{}against a mounted Secret, or$__env{}againstenvValueFrom. The subchart’sassertNoLeakedSecretscheck 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 thealertmanagerServiceAccount, allowingsns:Publishon the topic (pluskms:GenerateDataKey*andkms:Decryptfor 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 Alloyflavorthe subchart doesn’t implement (it renders nothing, silently), an Alloy orkube-state-metricspolicy declaringEgresswith 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 accepts12345from 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, while12345, 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-proxyis off, because it would double the per-node cost to protect an endpoint that exposes no secrets. -
[chart]Loki: One logical tenant per install, withauth_enabled: trueand a single namedX-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 throughthanos.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 isClusterIPand no Ingress is rendered;grafana.ingressandgrafana.serviceare surfaced for when you need them. -
[chart]Grafana: Public requires an allowlist, enforced. ALoadBalancerService with noloadBalancerSourceRanges, or aNodePort, which has no allowlist mechanism at all, is a render-time error.connections.grafana.allowPublicAccess: truedowngrades it to a warning; it’s an acknowledgement, not a silencer. -
[chart]Grafana: A render-time check errors onauth.anonymousenabled while Grafana is exposed, unlessconnections.grafana.allowPublicAccesssays the exposure is deliberate. -
[chart]Grafana: A render-time check warns on a database connection withssl_modeunset ordisable. -
[chart]Grafana: NetworkPolicy on by default, with ingress on3000from any source and every other port on the pod closed.allowExternalstays on because every way a human reaches Grafana is unselectable from here: an ingress controller in another namespace, a load balancer’s client ranges, orkubectl port-forwardarriving from the API server. Narrow it withexplicitNamespacesSelectorandexplicitIpBlocksafter 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 inalerting.global.alerting.assertNoInlineCredentialsturns it off; leave it on. -
[chart]Alertmanager: NetworkPolicy on by default, with9093and9094(TCP and UDP) open to the release namespace and egress open to notification providers on443,587, and465.
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.alloyin 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 shippedalloy-log-dropsalert covers only entriesloki.writefails to deliver. -
[operator]Alloy and node-exporter: Verify coverage after any node-pool change. The agent’s pod count andcount(up{job="node-exporter"})should both equalcount(kube_node_info). A persistent gap is a pool the DaemonSets aren’t reaching. -
[operator]Alloy and node-exporter: If you useNoExecutetaints 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’stolerations, 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, addnamespaceSelectorentries to the network policies. ApodSelectorwithout anamespaceSelectordoesn’t cross a namespace boundary, so the pod-label rules match nothing and the traffic they allow is dropped, with no error. Add them throughnetworkPolicies.<component>.ingress.extraandegress.extra, and the subcharts’ own ingress lists, such asnode-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_nameon Terraform,clusterNameon Helm. Every log line carries it ascluster, and the default alertgroup_byincludes 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 withnode-exporter.enabled: false, orinstall_node_exporter = falseon Terraform. Running both double-counts silently in anysum()orrate(). -
[operator]node-exporter: Alert on collectors failing, not only on the exporter being up.node_scrape_collector_success == 0means that collector returned nothing while the scrape as a whole succeeded, soupstays green and the metrics never appear. Some failures are expected and worth allowlisting rather than chasing:hwmonis usually empty on cloud VMs,nvmeneeds NVMe devices, andkernel_hungneeds Linux 6.13+ for/proc/sys/kernel/hung_task_detect_count. -
[operator]node-exporter:schedstathas two kernel gates and fails quietly on the second./proc/schedstatneedsCONFIG_SCHEDSTATS, and since Linux 4.6 the counters are also off at runtime unlesskernel.sched_schedstats=1. A missing file shows up innode_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: Setgrafana.ini.server.root_urlto 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, sografana.pluginssilently 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 reachesmzmon-null, and the render warns. -
[operator]Alertmanager: Choosealerting.presetdeliberately.importantis the default because it errs in the least damaging direction. A deployment for which Materialize is critical infrastructure wantscritical-infrastructure, which routescriticaltopage. A preset of your own is one more key underalerting.presets. -
[operator]Alertmanager: Test a receiver end to end after configuring it, withamtool alert addfrom inside a pod. The render proves the configuration is well-formed, not that a vendor accepts the notification. -
[consumer]Alertmanager: Provision thealertmanager-receiversSecret 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*_filecredential on each send, and the kubelet refreshes the mount within about a minute. -
[operator]Alertmanager: Leavealertmanager.baseURLempty 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: Undersplit-namespace, the profile opens Alertmanager to the ruler and Grafana namespaces. A receiver listening on a port other than443,587, or465needs that port added tonetworkPolicies.alertmanager.egress.external.ports. -
[operator]Alertmanager: Add meta-alerts on the notifier. The shipped rule set has none onalertmanager_cluster_members,alertmanager_config_last_reload_successful, oralertmanager_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 apriorityClassName. The per-node collectors and the gateway run atmonitoring-critical, because losing one is a blind spot nothing covers. The backends, Grafana, grafana-operator, and Alertmanager run atmonitoring-scalable, because a surviving replica or a retry absorbs the loss. -
[chart]Loki:priorityClassNameon every Loki pod.loki.global.priorityClassNamecovers 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 everyNoScheduletaint, so they reach tainted, spot, and system pools without configuration. -
[chart]Alloy: ServiceMonitor or PodMonitor (or GCPPodMonitoring) for both roles, scraping Alloy’s own component metrics: received and sent bytes, dropped lines, and write failures. -
[chart]Alerting: The rule set shipsalloy-log-drops(entriesloki.writedrops),loki-push-err-high(Loki push errors, including 429 rate limits), andlogging-collection-down, all on by default. -
[chart]node-exporter: ServiceMonitor enabled, andprometheus.io/scrape: "false"on the Service so annotation-based discovery can’t scrape it a second time under anotherjoblabel. -
[chart]Loki: ServiceMonitor for the Loki components, with the plaintext exporters scraped separately so they survive the mTLS profiles; andloki-canaryenabled 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. Thegrafana-postgresprofile enables gossip (headlessService: trueplusunified_alerting.ha_peers). Validators warn on several replicas with no gossip, and onha_peerspointing 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 asnetworkPolicies.grafanaGossipwhenunified_alerting.ha_peersis set andgrafana.networkPolicyis 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 over9094, 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*_filepath under no mounted volume. -
[chart]Alertmanager:amtool check-configruns in CI over representative configurations, in the Alertmanager image the chart pins. -
[chart]Alertmanager: Routing changes reload in place: the config-reloader sidecar POSTs/-/reloadwhen 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 carriescluster, stamped by both rulers fromclusterName. 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 restatereceive.extraArgsin 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 exceedsreplicaCount. -
[operator]Thanos: Restatecompactor.extraArgsin 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 theloki.writedurability 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 afromdate 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 offemptyDirbefore anyone depends on it. Use thegrafana-postgresprofile for production, orgrafana-pvcfor 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 thegrafana-pvcpath. -
[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 inpackages/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, withreplication_factor: 3, at least 3 ingesters, a singleton compactor, and a memberlist ring (no Consul or etcd). -
[chart]Loki: Ingesters are ephemeral, on node-localemptyDirwith 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: trueas a best effort, with a modestterminationGracePeriodSecondsof 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_configuses TSDB, schema v13, and a 24h index period. -
[chart]Thanos:receive.mode: standalonewithreplicaCount: 3, an auto-generated ketama hashring, and--receive.replication-factor=3inreceive.extraArgs. -
[chart]Thanos: Every profile restatesreceive.extraArgsin 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, inupdateMode: Auto, so where the VPA CRD is present it would rewrite the profile’s requests and evict pods holding up to 6h of blocks onemptyDir. 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, withoutgrafana.ini.databasepointed at a shared database. It warns when an exposed Grafana keeps its state on anemptyDir. Thegrafana-postgresandgrafana-pvcprofiles 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. UsevolumeBindingMode: WaitForFirstConsumer, the default for managed-cloud CSI classes; underImmediate, a PVC’s zone is chosen before scheduling, and the hard zone rules leave podsPending. On GKE, the legacystandardclass isImmediate. -
[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 stayPending, and on a cluster with no zone labels so does Alertmanager. On Terraform, setmin_zones; on Helm, apply theno-zone-spreadprofile. -
[operator]Zones: Aim for three or more zones, and raiseminDomainsto 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 podsPendingforever. With two zones, an AZ loss can break Loki and Thanos write quorum (2 of 3) until the ring recovers. -
[operator]Thanos: Check Receive’sephemeral-storagerequest 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 leadingvis 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 explicittagfor 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; themirrorprofile moves it along with everything else, digest intact. -
[operator]Images: Confirm thepre-deletecleanup hook can pull its image. An image it can’t pull hangshelm uninstallwith the release half-removed, andhelm uninstall --no-hooksis the way out. -
[operator]Scheduling: PriorityClasses are cluster-scoped, so two releases of this chart in one cluster fight over them. SetpriorityClasses.create: falseon all but one, or rename them per release withpriorityClasses.critical.nameandpriorityClasses.scalable.name. -
[operator]Scheduling: If you rename or disable the PriorityClasses, update the subcharts’priorityClassNamevalues to match. ApriorityClassNamenaming 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-serverstays on the upstreamsystem-cluster-criticaldeliberately: 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/chunksand/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 newestschemaConfigperiod, andcompactor.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 misleadingmemberlisttimeouts. -
[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. Sethelm upgrade --timeout, Fluxspec.timeout, or PulumicustomTimeouts; 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. Undersplit-namespace, that’sgrafana, 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: truestill leaves/login?disableAutoLoginreachable; 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, setgrafana.ini.plugins.preinstall_disabled: true, which removes the Drilldown apps’ download at every start. -
[operator]Alertmanager: Treatpersistence.enabledandpersistence.sizeas 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, andalloy validateruns on every rendered pipeline. -
[chart]Alloy: A pre-install and pre-upgradealloy validatehook, so a bad config fails the release rather than a running pod. -
[chart]Images:profiles/registry/ships themirroroverlay, the two hardened-vendor overlays, and thepull-secretoverlay, with unit tests asserting the pull secret reaches each of the four subcharts that read no global. -
[chart]Images: Thepre-deletecleanup hook and both pre-install validator Jobs carryimagePullSecrets, merged from both global spellings and deduplicated by name. -
[chart]Scheduling: Theschedulingprofile 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 elevenpriorityClassNamekeys across nine subcharts that can half-apply. -
[chart]Network policies:metrics-serveraccepts10250from 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 breakkubectl topand 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 setsminDomains: 2andnodeTaintsPolicy: 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 withmaxUnavailable: 1on Loki’s ingesters, every Thanos component, Grafana, and Alertmanager.maxUnavailablescales with the replica count, and on a singletonminAvailable: 1would permit no eviction and hang node drains. Validators warn onminAvailablefor 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 throughnetworkPolicy.externalStorage(by defaultports: [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 aReadWriteOncevolume 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 whengrafana.extraEmptyDirMounts,grafana.extraVolumeMounts, orgrafana.extraVolumesadds a second/tmpmount, 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 abaseURLwith a path doesn’t move the API the rulers post to. Resource names are pinned byalertmanager.fullnameOverride: alertmanager, so the rulers addressalertmanager-headlesswhatever 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-smallandthanos-largeare 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, tightenminMetricImportance, not the profile. Downshift only after head series have fallen. -
[operator]Loki: Set the per-tenantingestion_rate_mb,ingestion_burst_size_mb, andmax_global_streams_per_userfrom the table. These apply per environment. -
[operator]Loki and Thanos: Set retention to your storage budget: Loki’s globalretention_period, and Thanos’s per-resolution retention. -
[operator]Loki: Configure tiered (per-stream) retention: keepERRORand audit streams long, and expire high-volumeINFOfast. -
[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 KarpenterstartupTaintso it doesn’t over-provision. The ingester spread already setsnodeTaintsPolicy: 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 = falsequeues,drop = truesheds. -
[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: KeepreplicaCountat 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 repointconnections.datasources.thanos.urlat it, or the cache is deployed and bypassed. A render-time check warns on that mismatch. Thethanos-largeprofile does both; at S and M, Query is the data source target. -
[operator]Thanos: Keep Query’sreplicaCountequal toautoscaling.minReplicas. The subchart templates a staticreplicasalongside 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, andpressure), with thevmstatfilter widened to admitpgscan_*andpgsteal_*. -
[chart]node-exporter: 64Mi memory request and limit, 10m CPU request, and no CPU limit. -
[chart]Loki:reject_old_samples: trueandreject_old_samples_max_ageset. -
[chart]Loki: Compactorretention_enabled: true, withdelete_request_storeandretention_delete_delayconfigured. -
[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 theemptyDirbudget 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. Thegrafana-postgresprofile turns it on (2–5 replicas, 60% CPU), andminReplicasbecomes the effective floor. A validator warns whenautoscaling.targetCPUis 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 the0.0.0.0/0egress rules where your infrastructure lets you.kube-state-metricsandgrafana-operatorreach the API server on443and6443to 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’stoFQDNs, 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.tlsmoves 9093 to TLS, and the profiles move the probes, the reloader, the scrape, both rulers, and the Grafana data source with it.VerifyClientCertIfGivenat 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/metricsis available and parked (kubeRBACProxy.tls.tlsClientAuthplustlsSecret). 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 containerdhosts.tomlor anImageDigestMirrorSetredirects pulls without touching any chart value, which keeps one mapping in one place. Reach formirror.values.yamlwhen 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, useupdateMode: "Off"for recommendations only, and know that its numbers disagree with the profile by design. -
[operator]node-exporter: On AWS, consider enablingethtoolwith--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) andraplare worth adding; hypervisors don’t expose either to guests, which is why they’re off. -
[operator]node-exporter: Turnslabinfoon 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, andmeminfoalready reportsSlab,SReclaimable, andSUnreclaim.
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 ownvalues.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 underprofiles/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, andautomountServiceAccountToken: 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 anemptyDirat/tmpfor the Unix sockets Grafana’s backend plugins listen on. -
[chart]Grafana:analytics.reporting_enabledandcheck_for_updatesare off, and the subchart’stestFrameworkhook 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.labelon every gossip message, so a peer from another Alertmanager cluster that inherits a recycled pod IP is rejected rather than merged.