Securing materialize-monitoring#
This page reads across the whole stack: what its trust boundaries are, what it is allowed to do in your cluster, where credentials live, and what is not built yet. Per-component configuration lives in Production Best Practices, which is organized by backend; this page is organized by the question you are actually asking.
Everything here describes the chart’s defaults.
The Terraform modules are one [consumer] implementation of them and close a few of the open items on their own; those are marked where they apply.
What you are securing#
The stack’s value and its risk are the same property: it aggregates everything. Every log line from every pod, every metric series from every workload and node, and the credentials needed to reach the object storage holding both, all land in one namespace behind one Grafana.
That shapes the threat model more than any individual component does:
- Read access to Grafana is read access to the cluster’s telemetry, across every namespace, through datasources that are already authenticated to the backends. There is no per-user scoping below the datasource.
- Logs and metrics are not sanitized. An application that logs a token logs it into Loki, where it is searchable and retained for as long as the tenant’s retention says.
- The collection tier is privileged by nature. It reads pods, nodes, and the API server cluster-wide, because that is the job.
None of that is unusual for an observability stack. It is worth stating because the useful mitigations follow from it — restrict who reaches Grafana, keep secrets out of telemetry, and know what the collectors are allowed to read — rather than from hardening any single pod.
Trust boundaries#
Three boundaries, in decreasing order of how much attention they usually deserve.
| Boundary | What crosses it | What controls it |
|---|---|---|
| Outside → the stack | Humans reaching Grafana; anything you deliberately expose | Grafana authentication, ingress/LB allowlists, the render-time exposure check |
| The cluster → the stack | The Alloy gateway’s ingest ports, every backend’s query and write API | NetworkPolicy, and nothing else — see below |
| The stack → outside | Object storage, notification providers, external metric destinations, an identity provider | Egress NetworkPolicy, workload identity, your egress firewall |
Outside → the stack#
Grafana is the only component in the stack meant for a human, and the only one this chart will help you expose. The render enforces the allowlist convention rather than trusting it:
| Shape | What the render does |
|---|---|
LoadBalancer with no loadBalancerSourceRanges | Error, unless connections.grafana.allowPublicAccess is set — which suppresses the error, not the exposure, and still warns |
NodePort | Error unless allowPublicAccess is set; a NodePort has no allowlist mechanism of its own |
Ingress with no hosts, or the upstream placeholder host | Error — it would carry no rules and route nothing |
Ingress with no tls block | Warning. Grafana authenticates with a session cookie, so without TLS that cookie and the admin password cross the network in the clear |
Exposed with no grafana.ini.server.root_url | Warning. Share links, alert notification links, and OAuth redirect URIs are all built from it, and all three break silently when it does not match the URL users reach |
| Exposed with no authentication provider enabled | Warning — the admin password is the only thing in front of every log and metric in the cluster |
Exposed with auth.anonymous enabled | Error unless allowPublicAccess is set. Anyone who can reach it reads every dashboard and every datasource behind it without signing in |
-
[operator]Configure an identity provider before Grafana is reachable by anyone. A fresh install has one account — the admin, with a generated password — and that is fine behindkubectl port-forwardand not fine otherwise. See Grafana > Authentication. -
[operator]Nothing else in the stack should be exposed. Loki, Thanos, Alertmanager and the Alloy gateway have no authentication of their own (see below), so an ingress in front of any of them is an unauthenticated one. -
[operator]Reach a backend directly throughkubectl port-forwardwhen you need to, rather than by giving it an ingress. Troubleshooting uses that shape throughout.
The cluster → the stack#
Inside the cluster, NetworkPolicy is the only access control the stack has. No component authenticates its callers:
| Endpoint | What an arbitrary pod could do without a policy |
|---|---|
Alloy gateway ingest (3100, 4317, 4318, 9090) | Write logs and metrics into your backends |
Loki distributor / query frontend (3100) | Read and write any tenant’s logs |
Thanos Receive (10908) / Query (9090) | Write and read any metric series |
Alertmanager (9093) | Read alerts, create silences |
Every /metrics endpoint | Read the stack’s own telemetry |
Loki runs with auth_enabled: true, which is worth reading correctly: it makes the X-Scope-OrgID header required, so reads and writes are scoped to a tenant rather than pooled into an implicit one.
It does not authenticate anything — the header is a string any client can send.
Tenancy here is a data-partitioning boundary, not a security one, and the hard isolation boundary remains the install.
The Materialize metrics endpoint#
One endpoint the stack scrapes rather than serves belongs here too, because it looks authenticated and is not.
environmentd serves its SQL metrics routes — /metrics/mz_compute, /metrics/mz_frontier, /metrics/mz_storage, /metrics/mz_usage — on a listener configured with no authenticator.
That holds in every authentication mode: on a deployment using password, OIDC, or SASL auth, Materialize creates a dedicated metrics-only listener with authentication off, specifically so scraping keeps working without a scrape credential.
So the Basic-auth credential the scrapers send is not a credential. The username selects the role the underlying queries run as, which scopes which objects those queries can see; the password is never read. Two things keep this narrower than it first sounds — the listener still constrains which role names it will accept, and the routes serve fixed query sets rather than arbitrary SQL — but access to that port is governed by the network and nothing else.
-
[operator]Treat theenvironmentdmetrics port as unauthenticated. It is Materialize’s internal HTTP port and should not be reachable from outside the cluster. Scoping which pods reach it is a NetworkPolicy question, the same as every row in the table above.
This is upstream behavior in the Materialize product, not something this chart configures. See Scraping for the Secret it implies.
Every workload now ships a NetworkPolicy, on by default.
Production Best Practices > Network policies has the per-component table, the reasoning behind the ingress/egress asymmetry, and the three limits worth knowing — chiefly that a NetworkPolicy does nothing unless your CNI enforces it, and that kindnet does not.
-
[operator]Confirm your CNI enforces NetworkPolicy. Cilium and Calico do. If yours does not, everything in the table above is reachable from any pod in the cluster and the policies are documentation. -
[operator]Narrow the ingest ports if your cluster has no workloads that legitimately push telemetry. They are open to the whole cluster by default precisely because usually it does.
The stack → outside#
-
[consumer](Terraform: automatic on AWS and GCP) Use workload identity for object storage — IRSA, GKE Workload Identity, or Azure Workload Identity — rather than static keys. See Logs & Events > Storing and Metrics > Storing. -
[operator]Narrow the0.0.0.0/0egress rules where your infrastructure gives you something tighter — a VPC endpoint’s CIDR, or Cilium’stoFQDNs. The chart cannot derive the API server’s or the object store’s address, so it ships the broad form and says so. -
[operator]Remember the credential endpoint, not just the bucket. Workload identity fetches a token from STS (or the GCP/Azure equivalent) on443, and a policy that covers the bucket but not the token endpoint hangs the component at startup rather than failing it. This is the single most common NetworkPolicy mistake in this stack; Loki’s checklist covers the symptom in detail.
Cluster permissions the stack holds#
Two grants are broader than people expect, and both are load-bearing rather than incidental.
| Subject | Scope | Notable permissions |
|---|---|---|
alloy-agent, alloy-gateway | cluster-wide | get/list/watch on secrets and configmaps; pods, pods/log, nodes, nodes/metrics, events; the Prometheus Operator CRDs |
kube-state-metrics | cluster-wide | list/watch on secrets and every other built-in resource kind |
grafana-operator | cluster-wide | full CRUD on grafana.integreatly.org/*, plus deployments, services, secrets, configmaps, ingresses |
metrics-server | cluster-wide | nodes/metrics, and get/list/watch on pods, nodes, namespaces |
loki | cluster-wide | get/watch/list on configmaps and secrets |
Both Alloy roles can read every Secret in the cluster.
This comes from the upstream Alloy chart and it is required by prometheus.operator.servicemonitors and podmonitors: a ServiceMonitor may reference a Secret for bearer-token or TLS scrape credentials, and Alloy resolves those references itself, so it cannot know in advance which Secrets it will need.
kube-state-metrics can list every Secret, for kube_secret_* metrics.
It reads metadata and never the values — but the RBAC grant does not distinguish those, so the ServiceAccount is as powerful as the grant.
-
[operator]Treat these ServiceAccounts as high-value. Anyone who can create a pod with thealloy-gatewayServiceAccount, or exec into its pods, can read every Secret in the cluster. That is a stronger reason to restrictexecin the monitoring namespace than anything about the telemetry itself. -
[operator]Do not simply drop the Secret grant — a default install already uses it. The shippedmzmon-materialize-environmentd-sqlPodMonitor authenticates withbasicAuthagainst thematerialize-sql-monitorSecret, and Alloy resolves that reference itself. What makes the grant cluster-wide rather than namespaced isnamespaceSelector: any: trueon the monitors plus the operator convention that each resolves its credentials from its own namespace. If you know which namespaces host monitors, a Role in each of those is a real reduction; getting it wrong fails visibly, with an RBAC error in the gateway’s logs and a target that stops being scraped. -
[operator]grafana-operatorwatches cluster-wide by default, so two releases in different namespaces both reconcile everyGrafanain the cluster. ScopeWATCH_NAMESPACE, or narrowconnections.grafana.labelsper release.
Credentials and secrets#
The chart consumes secrets by name and mints almost none. A default install renders three: the Grafana admin credentials, the Thanos objstore config, and the Materialize SQL-monitor credentials. Everything else — an object-storage key, a Grafana database password, an OIDC client secret, an external destination’s token — is yours to provision, with External Secrets Operator, Vault Agent, SOPS, or your cloud’s CSI driver.
Two rendering traps are worth knowing, because both put a plaintext credential somewhere durable rather than failing:
grafana.inirenders into a ConfigMap. A secret written there is plaintext in the release manifest, inhelm get values, and in whatever Git repo holds your values. Use$__file{/path}against a mounted Secret, or$__env{VAR}. The subchart’sassertNoLeakedSecretscheck fails the render on a known-sensitive key set to a literal — leave it on. See Grafana > Authentication.Loki’s config defaults to a ConfigMap too. With static S3 credentials the rendered config carries
secret_access_keyverbatim, soloki.loki.configStorageType: Secretis load-bearing whenever you are not using workload identity. The Terraform module sets it automatically on that path; a hand-written values file has to. Thanos needs no equivalent — its objstore document already renders into a Secret.[consumer]Provision every Secret in the namespace the pod runs in, which undersplit-namespaceis not the release namespace.[operator]Rotate the Grafana admin password after an identity provider is configured, or disable the account. It is a generated password in a Secret, and it bypasses SSO.[operator]Prefer file-mounted credential material over environment variables for anything that renews. An env-var PEM is read once at process start, so renewal does not take effect until every pod restarts.
Workload hardening#
Most of the stack runs non-root with a read-only root filesystem and all capabilities dropped. Two workloads cannot, and both are deliberate:
| Workload | Deviation | Why |
|---|---|---|
alloy-agent | runs as root; three hostPath mounts (/var/log, /run/log/journal, /etc/machine-id) | It reads container logs and the systemd journal off the node. No capabilities are added — everything it reads is reachable by uid 0 under ordinary file permissions |
node-exporter | hostNetwork: true; hostPath mounts of /proc, /sys and / | The network collectors read namespaced files under /proc/net; in a pod network namespace they would report the pod’s traffic rather than the node’s |
node-exporter is otherwise the most locked-down workload in the stack — distroless, non-root, read-only root filesystem, automountServiceAccountToken: false — because a shell on a container that reads the host’s /proc, /sys and / is a materially better foothold than most.
Two consequences follow from those deviations, and the second is the one that surprises people:
hostNetworkputs node-exporter outside pod NetworkPolicy on most CNIs, so port9100is guarded by the node firewall and nothing Kubernetes enforces. See Exposure.- The release namespace cannot run under Pod Security Admission
baselineorrestrictedas shipped. Baseline forbidshostPathvolumes and host namespaces, and the two DaemonSets need both, so the namespace has to be labelledprivileged. Nothing else in the stack needs it.
-
[operator]Give the DaemonSets their own namespace if you want PSA aboveprivilegedfor the rest.split-namespaceis the mechanism; the backends, Grafana and the gateway are allbaseline-clean today. Note that support for that layout is best-effort, and that it changes the workload-identity subject and the NetworkPolicy selectors along with it. -
[operator]seccompProfileis not set on every workload — Alloy, Thanos, Alertmanager and grafana-operator leave it unset, so they inherit the container runtime’s default rather than declaringRuntimeDefault. Set it through each subchart’spodSecurityContextif you are targetingrestricted. -
[operator]Restrictpods/execandpods/portforwardin the monitoring namespace. Given the ServiceAccount permissions above, exec into the gateway is a cluster-wide Secret read.
Supply chain#
Every image is pinned by registry, repository and tag — in this chart’s values.yaml where the cadence matters enough to own, in a subchart’s defaults otherwise.
Repointing at a mirror or a hardened rebuild is a values change: four overlays under profiles/registry/ do it for the whole stack at once, including the pull-secret wiring.
See Images and registries for the vendors and the UID hazard that makes a careless swap crash-loop.
-
[operator]Pin Grafana plugin versions (name@version), or bake them into an image.grafana.pluginsdownloads fromgrafana.comat every pod start — a startup dependency on a third party, and a way for a plugin to change underneath a pinned Grafana. A validator warns on an unpinned entry. -
[operator]The Grafana Image Renderer stays off. It is a headless Chromium that fetches URLs on Grafana’s behalf: a large attack surface and a server-side request forgery pivot into the cluster network. A validator warns when it is enabled.
The telemetry itself#
Data that reaches the stack is stored as it arrives.
-
[operator]Keep secrets out of logs at the source. The pipeline ships no redaction stage today (DEP-220), so a token an application logs is a token in Loki, searchable for the tenant’s full retention. -
[operator]Set retention deliberately. It is the only bound on how long anything that did leak stays queryable. Retention and compaction are covered in Logs & Events > Storing and Metrics > Storing. -
[consumer]Enable server-side encryption and access logging on the buckets. The Terraform modules enable versioning; encryption policy is yours, and bucket versioning means a deleted object is not necessarily gone. -
[operator]Remember that isolation within a Loki tenant is label-based, not enforced. Per-environment separation viaenvironment_idis a query convention; the hard boundary is a separate install. See Tenancy & auth.
Certificates#
certificates.enabled renders cert-manager Certificate resources for the stack.
It is off by default and cert-manager is never a hard dependency — with it off, nothing in this section renders at all.
It is gated on a value rather than on an API-server capability probe, deliberately.
A probe would make the same chart render differently under helm template, the Terraform render check, and an ArgoCD diff than it does under a live install, which is exactly the class of bug those checks exist to catch.
A missing CRD with the flag on is an apply-time failure with a resource name in it, which is a better error than silently rendering nothing.
Two issuers, because they cannot be one#
certificates.internal | certificates.external | |
|---|---|---|
| Names | $svc, $svc.$ns, $svc.$ns.svc, $svc.$ns.svc.$clusterDomain, localhost | the public DNS name the load balancer answers on |
| Typical issuer | a self-signed root, or your private CA | ACME, or a private CA that signs your public names |
| Verified by | the stack’s own components | a browser |
A public ACME issuer cannot sign loki-distributor.monitoring.svc, and a self-signed root means nothing to a browser.
Collapsing these into one key would make one of the two unusable.
The external certificate is only needed behind an L4 load balancer, which passes TCP through and leaves TLS to terminate at the pod — so the material has to exist in the cluster.
An L7 load balancer terminating with a cloud-managed certificate (ACM, Google Certificate Manager, Azure Key Vault) attaches it by ARN or resource ID and the private key never enters the cluster; for that shape leave external unset and pass the annotation through grafana.service.annotations.
The render warns if you set external.dnsNames without an issuer, since that combination looks configured and issues nothing.
Where the root comes from#
Either you supply one, or the chart makes one:
# Consume your own PKI. The production path.
certificates:
enabled: true
internal:
issuerRef:
name: my-ca-issuer
kind: ClusterIssuer# Bootstrap a self-signed root. Renders a selfSigned issuer, a CA Certificate
# signed by it, and a CA issuer every component certificate then references.
certificates:
enabled: true
internal:
selfSigned:
enabled: trueSetting both is an error — component certificates can reference only one issuer, so one of the two things you asked for would silently not happen.
-
[operator]Prefer an issuer scoped to this stack over the cluster’s general-purpose one. None of the receiving components implement per-client authorization, so the whole authorization decision is “is this signed by the CA we trust”. Reusing aClusterIssuerthat signs for every workload in the cluster reduces mTLS to “has any certificate” — real, and much narrower than it sounds. See What a certificate means for why the trust domain is the security property. -
[operator]Usekind: ClusterIssuerif you run split-namespace. A namespacedIssuersigns only forCertificateresources in its own namespace, so components elsewhere sitPendingforever. The render refuses that combination rather than letting you find out. -
[operator]A bootstrappedClusterIssuer’s CA Secret lands in cert-manager’s namespace, not yours. cert-manager reads acaissuer’s Secret from its cluster resource namespace (cert-managerby default), so the chart renders the CACertificatethere. Override withcertificates.internal.selfSigned.caSecretNamespaceif your cert-manager uses a different one; get it wrong and the issuer sitsFalsewithsecret not foundwhile the Secret is one namespace over.
The SAN ladder, and why clusterDomain matters#
Every internal certificate carries four rungs per Service — $svc, $svc.$ns, $svc.$ns.svc, and $svc.$ns.svc.$clusterDomain — plus localhost and 127.0.0.1.
All four, because the chart’s own URLs disagree about which form to use: every in-cluster destination it writes stops at $svc.$ns.svc, while the Terraform test substrate writes …svc.cluster.local.
A certificate carrying only the fully-qualified name therefore fails verification against endpoints the chart itself ships, and the error reads as a broken certificate rather than as a mismatch in name form.
cluster.local is a default, not a fact.
Set global.clusterDomain if yours differs — it propagates into Loki and Thanos, which build their own internal addresses from it, so one value covers all three.
-
[operator]Setmetrics-server.tls.clusterDomaintoo if you change the global. metrics-server reads its own key, and the render warns when the two disagree. -
[chart]A render check asserts that every in-cluster destination URL the chart writes matches a SAN on the corresponding certificate. A wrongserviceslist is valid YAML and installs clean, so this is the cheapest guard against the failure the ladder exists to prevent.
Turning a backend onto TLS#
profiles/mtls.values.yaml moves two hops off plaintext — gateway → Loki and gateway → Thanos Receive — and is the supported way to do it:
helm upgrade --install mzmon charts/materialize-monitoring -n monitoring \
-f charts/materialize-monitoring/profiles/aws-example.values.yaml \
-f charts/materialize-monitoring/profiles/mtls.values.yaml \
--set certificates.enabled=true \
--set certificates.internal.selfSigned.enabled=trueIt is a profile rather than a switch because the change is not one setting. Turning on Loki’s listener is one key; keeping the deployment working is six, spread across three subcharts and two of this chart’s own trees — the writer, the reader, the kubelet probes, the metrics scrape, and the canary all dial the port that just moved. Every one of them fails quietly, and none of the symptoms names TLS:
| Left behind | What you see |
|---|---|
| The gateway’s destination | Writes fail with a protocol error that reads like Loki is broken |
loki.defaults.readinessProbe scheme | Every Loki pod fails readiness at once — presents as a crashloop |
loki.monitoring.serviceMonitor scheme | Loki’s own metrics vanish, and up goes absent rather than 0, so an alert on up == 0 does not fire either |
| The Grafana datasource URL | Every log panel renders empty, with no error on the dashboard |
| The canary’s flags | The end-to-end check reports the log store as broken when it is not |
The render refuses each of those rather than letting you find out, which is most of what the chart contributes here.
Thanos Receive is narrower by construction: its TLS flags scope to the remote-write listener, so probes, metrics and the ServiceMonitor are untouched and Thanos Query stays plaintext.
Through Terraform#
The Terraform module composes the same profiles from one input, because a consumer of the module has no copy of the chart directory to point -f at:
certificates_enabled = true
internal_tls = "authenticate" # off | encrypt | present | authenticateThe stages map to the profiles in order — encrypt is mtls.values.yaml, present adds mtls-phase2, authenticate adds mtls-phase3 — so the table in the next section describes both paths.
internal_tls needs certificates_enabled, and the module refuses the combination without it rather than installing a stack that mounts Secrets nothing created.
In materialize-terraform-self-managed both are on by default, since every example there installs cert-manager.
The phases, and where each hop can actually end up#
Three profiles, composed in order. The two hops do not reach the same place, and that is a property of Kubernetes rather than of the backends — all of this was measured on a live cluster, not read off documentation.
| Phase | Profile | Gateway ingress | Loki | Thanos Receive |
|---|---|---|---|---|
| 1 | mtls.values.yaml | TLS, no client CA | TLS, NoClientCert | TLS, no client CA |
| 2 | + mtls-phase2.values.yaml | client CA set; clients present | VerifyClientCertIfGiven, client presents | client presents, server still ignores it |
| 3 | + mtls-phase3.values.yaml | RequireAndVerifyClientCert — authenticated | unreachable | client CA set — authenticated |
The gateway’s own ingress reaches phase 3 because its listeners are not the ports the kubelet probes — readiness is on 12345. That is the difference between it and Loki.
Loki’s HTTP port cannot require client certificates, ever. The kubelet’s readiness and liveness probes dial the same port 3100 that the gateway does, and a Kubernetes httpGet probe has no field for a client certificate. Setting RequireAndVerifyClientCert fails every probe with remote error: tls: certificate required, and every Loki pod goes unready and then restarts. The render refuses it. Phase 2 is the ceiling for that hop: a certificate from the wrong CA is refused, an anonymous client is still served. Real authentication there needs an authenticating proxy in front of Loki, or a listener the kubelet does not touch.
Thanos Receive does reach phase 3, because its probes are on the HTTP port while the TLS flags scope to the separate remote-write listener. Verified: a client presenting no certificate is refused at the TLS handshake; one presenting a certificate from the trusted CA is served.
min_version has three vocabularies in one binary, and two of them fail differently. Client blocks (tls_config) take TLS13; the dskit-flavoured listeners (loki.source.api, prometheus.receive_http) take VersionTLS13 and reject anything else at load, crashlooping the pod; otelcol.receiver.otlp takes OpenTelemetry’s 1.3 and rejects the others silently — the component goes unhealthy, its port never binds, and the process stays up reporting Ready. alloy validate catches none of the three. Values use one vocabulary and the chart translates per listener; that silent case is why kubectl get pods is not enough to confirm this feature is working.
Two more measured constraints the profiles encode, both of which crashloop the stack if you get them wrong:
Loki’s
client_ca_fileandclient_auth_typemust arrive together. dskit refuses a client CA with no policy — Loki exits at startup withclient CA's have been configured without a Client Auth Policy, buried in a Go stack trace, on every microservice at once. That is why phase 1 ships neither.Both probes need the scheme, not just readiness. Liveness hits a different path on the same port; left plaintext it returns 400 and the kubelet restarts the container after readiness has gone green, which reads as an unrelated flap.
[operator]Phase 1 is encryption, not authentication, and phase 2 only rejects the wrong CA. Phase 3 is where a client presenting nothing is refused — on Thanos Receive’s remote-write listener and all four gateway ingress ports. Loki’s HTTP port stops at phase 2 and cannot go further, because the kubelet probes it.[operator]Roll the server and its clients in either order at phase 1 and 2, never at phase 3. Kubernetes does not order them, so a server that starts requiring certificates before its clients present them stops ingesting until they catch up. Phase 2 exists to make phase 3 order-independent; the render refuses phase 3 applied without it.[operator]Grafana’s datasource TLS does not renew like the rest. It reads fromsecureJsonData, which is provisioned config rather than a file mount, so a new CA means re-provisioning the datasource.
Renewal is the failure that matters#
Certificate material is mounted from a Secret, not injected through environment variables.
Env vars are captured once at process start and cert-manager renews by rewriting the Secret in place, so an env-carried PEM works for exactly one certificate lifetime and then fails on every hop simultaneously — months after the change that caused it.
Prefer the tls.*File carriers on every destination over the inline ca/cert/key, which remain supported for bring-your-own-PKI.
The mount is unconditional and marked optional: true, so the same values work before, during and after issuance: a Secret that does not exist yet mounts empty rather than blocking the pod.
-
[operator]Do not setrenewBeforenearduration. It looks like a way to exercise renewal and it is a way to break cert-manager: at 92% of duration (a 1h certificate with 55m of headroom) renewal fires every few minutes, and on a small cluster with six certificates the controller livelocked in an optimistic-locking re-queue loop, stopped renewing, and then reportedReady=True: "Certificate is up to date and has not expired"on certificates that had expired 45 minutes earlier — every TLS hop failing withcertificate has expiredwhile the Certificate resource looked healthy. KeeprenewBeforeto a third ofdurationor less, and force renewal explicitly (delete the Secret, orcmctl renew) if you want to test it. -
[operator]A mounted file is not a reloaded file. The kubelet refreshes Secret contents atomically, but the process still has to notice, and reload support differs per component. That is why no hop turns on by default, and why enabling one is a decision to make per component rather than per stack.
What is not there yet#
Stated plainly, because the values surface implies more than the deployment has — minVersion: TLS13 sitting next to enabled: false reads like a switch rather than a project.
| Gap | Status |
|---|---|
| Certificate issuance | ✅ Shipped, off by default. certificates.enabled renders cert-manager Certificate resources with the full SAN ladder — see Certificates |
| In-cluster TLS, gateway → Thanos Receive | ✅ Shipped and authenticated at phase 3, off by default. A client with no certificate is refused at the handshake |
| In-cluster TLS, gateway → Loki | 🔨 Encrypted at phase 2, and that is its ceiling — the kubelet probes the same port and cannot present a certificate |
| In-cluster TLS, every gateway ingress port | ✅ Shipped and authenticated at phase 3 — 3100, 4317, 4318 and 9090. All four listeners render from Helm and take TLS from values; a client presenting no certificate is refused at the handshake on each |
| In-cluster TLS, agent → gateway | ✅ Shipped and authenticated at phase 3. The listener renders from Helm and the agent’s destination presents a certificate; moving prometheus.receive_http out of the pre-rendered pipeline was the last blocker |
| Mutual TLS between components | ✅ At phase 3, five listeners require and verify a client certificate: Thanos Receive’s remote-write port and the gateway’s 3100, 4317, 4318 and 9090. Loki’s HTTP port is the exception and stays at verify-if-given. Authentication, not authorization — none of these can express “this identity may write and that one may not”, so the size of the trust domain is the security property |
| Authenticated scrapes of node-exporter | Available and deliberately parked. kubeRBACProxy would authenticate via TokenReview/SubjectAccessReview over HTTPS, at the cost of a second container on every node to protect an endpoint that exposes no secrets |
| A trust bundle for a private CA | ❌ Not shipped (DEP-236). Needed for an S3-compatible store behind a private CA, and for images that ship no CA bundle at all |
| Intra-Loki and intra-Thanos TLS | ❌ Not shipped. Distributor→ingester gRPC, the memberlist ring, query→store — all real hops inside a single subchart’s trust boundary |
| Redaction in the pipeline | ❌ Not shipped (DEP-220) |
Issuance and use are separate switches on purpose, and a default install turns on neither. A hop only leaves plaintext once that component’s renewal behaviour has been proven, because a component that does not reload a renewed certificate works for exactly one certificate lifetime and then fails with no deploy nearby to blame — which is why tls::survives_renewal forces a reissue and asserts delivery across it rather than trusting a freshly-installed stack.
Where the phases land is therefore your choice, not the chart’s. A stack sitting at phase 1 or 2 is encrypted and not authenticated, and phase 2 is the state most likely to be mistaken for mTLS: every values file carries a certFile, the servers name a client CA, and a client presenting nothing is still served. Only phase 3 refuses it.
The design for the first three is written up in the TLS and authentication design doc (internal), including the two-phase rollout that gets there without an outage.
See also#
- Production Best Practices — the per-component checklists these items read across.
- Network policies — the full per-component policy table.
- Grafana > Authentication — identity providers, role mapping, and the break-glass path.
- Grafana > Reaching Grafana — exposure options and the allowlist convention.
- Logs & Events > Storing — object storage, workload identity, and retention for logs.
- Metrics > Storing — the same for metrics.
- Helm values reference — every key named on this page, with the reasoning next to it.