19
Chart Style Guide
Justin Klein edited this page 2026-10-15 17:51:52 +00:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Chart style guide

Binding for every current and future chart. template-chart/ is the reference implementation; where this page and the template disagree, the template is the bug.

Every documented exception is named here with its reason. An exception that is not written down is a deviation.

1. Naming

  • Resources are named {{ .Release.Name }}-{{ .Chart.Name }}-<kind>: -deployment, -sfs, -service, -secret, -configmap, -ingress, -pvc.
  • Template files are named <app>.<kind>.yaml. Stateful apps use a StatefulSet in <app>.sfs.yaml rather than a Deployment.

Documented exception — existing PVC names. PVCs of already-published charts keep their historical names even where they deviate. Renaming a PVC makes Kubernetes provision a new, empty volume, so a rename would orphan the existing data. New charts follow the scheme.

StatefulSet renames are possible — data survives because the charts use standalone PVC templates, not volumeClaimTemplates — but only in a major-bump PR with a one-time migration note: kubectl delete statefulset <old> --cascade=orphan, then delete the leftover pod, then upgrade.

2. Versioning

See Versioning for the full rules. Short form: version: <chart-semver>+up<appVersion>, suffix matches appVersion with dashes normalized to dots, and any push to main touching a chart directory publishes it.

3. Images

  • The main app image tag comes from {{ .Chart.AppVersion }} — never hardcoded.
  • Auxiliary images (sidecars, init containers, secondary workloads — the delegation nginx in matrix-synapse, guacd in patchmon, the busybox init/consume images in paperless-ngx) are pinned via values instead of being hardcoded in templates, and their versions are reviewed alongside every app-update round.

Documented exception — freesailarr. The chart bundles ten independently versioned upstreams, nine of them enabled by default, so none of them is "the" app version. No tag comes from appVersion; every image is pinned as <app>.image.repository/<app>.image.tag, which is also what puts them under the built-in helm-values manager. Its appVersion is the chart's own marker, raised by hand. Consequently it has no app-version block in renovate.json5 and is not in the change-app-version.yml choice list. mediathekarr deviates once more: its tags carry no version, so it is pinned by digest and is the one image with a customManagers block.

Documented exception — minecraft-server. The only chart whose main image tag can be overridden, through image.tagOverride (empty by default). appVersion is the tag and therefore fixes the JVM, and only the newest Java line is maintained out of the directory; the older lines stay published as frozen chart versions. Without the override, running an older JVM would mean installing a frozen chart and giving up every later fix — so the line is a decision at rollout time rather than a property of the chart. It is named tagOverride, not tag, because it decouples the running image from the chart version, and it is deliberately outside Renovate's reach. The chart checks the pairing itself: the Java line of the effective tag against minecraft.version, failing the render with both values named.

4. Ingress and Gateway API

  • ingress.domain and ingress.clusterIssuer are required without defaults — every installation sets both explicitly; they are the only values a default install needs. clusterIssuer is required only while ingress.enabled: in Gateway mode the chart renders no certificate, and a value that is demanded and does nothing is exactly the defect §12 exists to remove.
  • ingress.className defaults to nginx.
  • ingress.annotations takes optional extra annotations, merged with the cert-manager annotation the chart always sets.
  • The TLS secret is named tls-<release>-<chart>-ingress.

A set of objects publishing ONE backend under hostnames the chart does not own is the third admissible shape, and it pays for it with a per-entry secret name and a per-entry issuer (Gitea #122). Where a chart's app serves several tenants out of one deployment and each wants a hostname of its own — keycloak's realms, so far the only case — the hostnames come as a values list and the chart renders one object per entry, in both routing modes. Two rules above do not survive that, and both deviations are required rather than tolerated:

  • The TLS secret name is a per-entry value with no default. These hostnames already have certificates and several entries typically share one (a wildcard for a zone), so a derived tls-<release>-<chart>-… name per entry would ask cert-manager for a second certificate for a name that is already covered — one ACME order per hostname, for nothing. An entry without one aborts the render: a TLS block with no secret serves the controller's default certificate and fails in a browser only.
  • The cert-manager annotation becomes optional per entry, and omitting it is the normal case. cert-manager keys a Certificate by the secret it writes, so of several objects naming one secret exactly one may carry it — the same deliberate omission the qBittorrent object in freesailarr makes. The two go together: a chart that invents the secret name cannot also leave the issuing to somebody else, which is why the name is required rather than defaulted.

Annotations are per entry too (the set is not uniform across the hostnames — three of keycloak's four carry a proxy-buffer pair and one does not, which a chart-global block could not express), the per-entry parentRefs is the Gateway-mode counterpart of the per-entry class, and object names carry the list index by the rule below. The shape is not an excuse for a one-mode feature: it renders in both, or it is not admissible.

A shared domain with sub-paths is the second admissible shape. Where a chart exposes several apps they may each take a host, or share one host and be told apart by path (example.com/sonarr, example.com/radarr). Rules for the shared shape: the domain is a single value (ingress.host); each app's path is its own value defaulting to /<app>; the paths render in one Ingress object with the standard TLS secret; an app carrying a host of its own always wins and keeps its own object, so both shapes mix without extra values. An app needing a per-path proxy annotation (rewrite-target is per object, not per path) gets its own object on the same host, repeating the TLS secret name and omitting the cert-manager annotation, so exactly one certificate is requested. Apps that cannot serve a base path are rendered at / or refused with a fail — never rendered somewhere they provably cannot work. In Gateway mode that extra object is not needed: a URLRewrite filter is per rule, so the prefix-stripping app is simply one rule of the shared route — see below.

Documented exception — homeassistant keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its className default follows the standard. The exception extends to the Gateway mode: with no ingress.domain there is no httpRoute.hostnames either, ingress.hosts is read in both modes, and the chart renders one route per entry — see the Gateway rules below.

Documented exception — rss-bridge takes ingress.hosts as a required list of host plus optional tlsSecretName, so several hosts keep their own existing certificates.

Documented exception — freesailarr has no ingress.domain at all and offers both shapes — and therefore no httpRoute.hostnames either, the only chart without that key; its hostnames stay in ingress.host and <app>.ingress.host. See its page.

A list of hosts each carrying its own TLS secret and its own optional issuer splits into several Ingress objects, and the split is on the issuer. Added with Gitea #123 (stalwart.mtaSts.hosts), and it is a rule rather than a preference because cert-manager reads the two fields at different scopes: cert-manager.io/cluster-issuer is an annotation, so object-scoped and absent-means-nothing-issued-for-this-object, while secretName is tls-entry-scoped and yields one Certificate per entry. One object therefore cannot both request a certificate from issuer X and leave another host's secret alone — which is what an empty per-entry issuer has to mean, for the reason already in the shared-domain rule above (exactly one certificate per secret). So: group by issuer for the objects, by secret for the tls list (the rss-bridge.tlsGroups helper is the shape to reuse), and abort the render when one secret appears under two different issuers — ingress-shim names the Certificate after the secret and owns it from the Ingress, so that case is one Certificate name with two owners. Name such objects by index all the same, per the index rule above and its corrected reason — the grouping decision and the naming decision are independent, and an earlier draft of stalwart that named them by issuer was brought back into line. Note what the index then runs over: the groups, not the list, so a reorder can change the grouping and renumber objects that did not move. NOTES.txt must therefore print the index → issuer → hosts mapping, because the name no longer carries the issuer and nothing else in the cluster says which object holds which. Receipt: Lessons learned.

And a feature that spans both routing modes must render in both. A values block that produced objects only in Gateway mode (or only in Ingress mode) would make a mode switch silently drop a feature — the same class as the annotation losses of the sweep, but self-inflicted. Where one mode genuinely cannot carry part of it, that part is inert and documented, not required: stalwart's mtaSts.hosts[].tlsSecretName is required in Ingress mode and ignored in Gateway mode, where TLS belongs to the listener, so neither mode demands a value that does nothing (§12).

The Gateway API is the second routing mode

Added with the pilot on cyberchef (Gitea #114). A chart offers it through one values block, off by default, so adding it to an existing chart is additive — a minor bump:

httpRoute:
  enabled: false     # counterpart to ingress.enabled
  parentRefs: []     # the Gateway (name, namespace, sectionName) — a platform object
  hostnames: []      # empty = [ingress.domain]

Implementation-neutral, and that is the load-bearing decision. A chart renders only HTTPRoute with parentRefs: no Gateway, no gatewayClassName, no certificate, and no implementation-specific policy object (ClientSettingsPolicy, ClientTrafficPolicy and their equivalents). That subset looks identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio — which is precisely why the implementation decision can still fall later without the charts being touched again. A policy object would bind a chart to one implementation and to a CRD whose presence it cannot check. Class and certificate therefore move to the platform: they sit on the Gateway and its listeners, in cluster-config.

The rest of the rules, each against a failure that reports nothing:

Rule What it prevents
A value, never auto-detection .Capabilities.APIVersions is unreliable at template time, and an HTTPRoute without the Gateway-API CRDs fails at install time. A chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing.
Mutually exclusive with ingress.enabled, render abort naming both value paths two data paths onto one Service under one hostname is a conflict at DNS level already. A migration runs over a second hostname (httpRoute.hostnames set explicitly while the Ingress keeps ingress.domain), never in parallel.
parentRefs mandatory in Gateway mode, render abort on the empty list a route without a parent attaches to nothing: created, schema-valid, rejected by nobody, routing nothing — no event, no condition, the only symptom a hostname that does not answer.
One source of truth for the hostname — empty hostnames means [ingress.domain] two values that have to agree drift apart. ingress.domain is read in Gateway mode too: it names the release's public hostname and is not an Ingress switch. A chart without an ingress.domain omits hostnames entirely and keeps its existing host values — and its schema rejects the key, so the absence is enforced rather than merely intended. Two such charts: freesailarr (ingress.host / <app>.ingress.host) and homeassistant (ingress.hosts, the flexible list).
Several routing objects where the Ingress had several hosts, or several rules with different backends HTTPRoute.spec.hostnames applies to the whole object, so one route can carry several hostnames only when they want the same rules. A chart whose hosts want different paths or different backends renders one object per host: keycloak (public and admin hostname), matrix-synapse (homeserver at /, delegation nginx at /.well-known/matrix), homeassistant (one per ingress.hosts entry). Where the hostnames differ in kind rather than merely in value, the per-object parent gets its own value falling back to the chart-global one — httpRoute.admin.parentRefs, httpRoute.delegation.parentRefs. That fallback is the counterpart of ingress.<x>.className falling back to ingress.className, because "which data path" is a property of the Gateway in this API, not of the route.
Index-based object names where the count is data-driven a hostname is not a dependable object-name fragment (the 63-character label limit plus the release prefix; a sanitised hostname can collide). homeassistant names its routes …-httproute, …-httproute-1, … by list index; keycloak's realm objects follow it in both modes (…-realm-ingress-1, …-realm-httproute-1), and stalwart's MTA-STS Ingresses do on the Ingress side (…-mta-sts-ingress-1). Consequence written down rather than discovered: reordering the list renames objects, so they are recreated rather than edited. What makes that acceptable is that the Secret survives — not that no certificate is involved. See the note below.
A chart that still renders its Ingress unconditionally gets an ingress.enabled: true first without a second value path there is nothing for the mutual-exclusion abort to name, and the Ingress plus its unconditional required() on clusterIssuer would keep rendering in Gateway mode — a value demanded for nothing. paperless-ngx was the last such chart and got the switch with group 2; the default keeps the render identical.
NOTES.txt names the active mode and what it costs, as keycloak already does for its cases a mode nobody can see from the outside

The index rule's reason, corrected — the first version of it was true of the first chart and not of the rule. It used to read "acceptable only because a route holds no state and no certificate", and that justification died the moment the rule was extended past routes. keycloak's realm Ingresses (Gitea #122) and stalwart's MTA-STS Ingresses (Gitea #123) both own cert-manager Certificates, so the stated precondition excluded the very cases the rule was being applied to. Caught because the two rounds ran in parallel and named their objects differently for the same shape — one by index citing this rule, one by issuer citing this rule's precondition. The naming was unified on index and the reason repaired; the naming of the two merged charts was not disturbed.

What actually carries it, in three steps that are worth keeping apart because they have different standing:

  1. Measured in jk-cluster: cert-manager runs without --enable-certificate-owner-ref — the flag is in neither the cert-manager Deployment's args nor the ControllerConfiguration its --config points at (that file carries only kind: ControllerConfiguration and enableGatewayAPI: true). The controller's default therefore applies.
  2. Documented behaviour of that default, not measured here: the Certificate is owner-referenced from the Ingress and is garbage-collected with it, but cert-manager adds an owner reference to the Secret only when that flag is on. So a rename recreates the Certificate object while the Secret stays, the new Certificate finds a valid secret already present, and nothing is issued — no ACME request, no rate limit touched.
  3. Not applied: no rename was performed against a cluster on either round. Steps 1–2 are the mechanism, not a trial of it.

And the promise hangs on a cluster-level flag this repository does not control. With --enable-certificate-owner-ref switched on, the Secret would be collected with the Certificate and reordering a list would cost a fresh issuance per renamed object. Worth checking before anyone enables it; a chart cannot defend itself against it. Two further notes for whoever applies this rule next: the index may run over something derived from the list rather than over the list itself — stalwart indexes issuer groups, so a reorder can change the grouping and renumber objects that did not move — and the index question only arises where a mode renders one object per entry. stalwart has it on the Ingress side only, because its Gateway mode renders a single route carrying every hostname, TLS being none of the route's business. homeassistant remains the certificate-free original case.

What has no counterpart, and must be said rather than rebuilt. The Gateway API has no annotation channel for controller behaviour. rewrite-target has a real equivalent (the URLRewrite filter in a rule); proxy-body-size has none — an upload limit is implementation-specific policy. Every chart that sets or recommends one must print in its Gateway-mode NOTES.txt that the limit is not enforced and has to be set at the Gateway, because whoever overlooks that silently inherits the Gateway's default and meets it as a proxy error instead of an application error: calibre-web-automated, outline, paperless-ngx, zipline since group 2, bulwark and matrix-synapse since group 3 — each in the chart's own terms rather than in one copied sentence. The same holds for auth annotations in front of an app with no login of its own: in Gateway mode that escape hatch does not exist in the chart at all — cyberchef is the live example.

And the screen is "needs one", not "sets one". garage sets annotations: {} and still depends on an upload limit, because ingress-nginx truncates S3 uploads at 1 MB; freesailarr sets none and needs none itself, but ingress.annotations is the only lever its operator has for one. Both say so in their Gateway-mode notes. Read values comments and NOTES.txt, not only templates.

And the third screen is the LIVE object — group 3's lesson, and the one that cannot be done from inside the repository. A chart can read as correct while the running system fills a gap of the chart with an annotation the operator set, and Gateway mode drops ingress.annotations, so it drops the compensation with it. Measured with read-only kubectl before writing group 3's branches: four of its six charts carried such an annotation that the chart never sets — keycloak (proxy-buffer-size: 16k, proxy-buffers-number: 4, on both its Ingress objects), matrix-synapse (proxy-body-size: 50m plus the nginx.org/client-max-body-size twin plus a configuration-snippet), stalwart (the whole enable-cors block), bulwark (proxy-body-size: 25m). None appears in a git grep of this repository — the only hits are the documentation recommending two of them. So: before writing a chart's Gateway branch, read its live objects. See Lessons learned.

Read the annotation's directive before calling it load-bearing. matrix-synapse's live configuration-snippet looks like exactly what Synapse's x_forwarded depends on. It is not: more_set_headers is the headers-more module's output-header directive, the request-side one being more_set_input_headers, so those headers reach the client and never Synapse. The chart's notes therefore say not to re-create it at the Gateway. An annotation that looks frightening and does nothing costs as much review time as one that matters.

"No counterpart" is a claim to check against the pinned CRDs. For CORS it is false: HTTPRoute's filters carry a full cors field (GEP-1767) in the set this repo pins, and responseHeaderModifier sits beside it — both Support: Extended, neither Core. stalwart still renders neither, and the reasoning is the general rule: a chart renders a filter only when it has values to render it from and the support level makes the behaviour promisable. Extended is not promisable — a rejected route is rejected whole, and the observed failure is quieter still (Cilium accepts filters.type: CORS and forwards the preflight upstream anyway). And half a feature is worse than none: a responseHeaderModifier can add the Access-Control-Allow-* headers but cannot answer the preflight, which the gateway rather than the backend must do — and with Authorization in the allowed headers every request is preflighted, so the expressible half is exactly the useless one.

pathType: ImplementationSpecific has no counterpart either. Ingress has Exact/Prefix/ImplementationSpecific, the Gateway API Exact/PathPrefix/RegularExpression. The first two map; the third means "ask the controller" and nothing preserves it. The rule: render it as PathPrefix on / only (every controller agrees there, so a chart defaulting to it keeps working) and abort the render on any other path, because an ImplementationSpecific sub-path is a regex in ingress-nginx and PathPrefix is not one — mapping it silently would change matching while the render stayed green. RegularExpression is not an escape hatch: its support level is Implementation-specific. homeassistant is the chart this applies to, and it is a chart default there.

rewrite-target is one of two places where this mode is less machinery, not a translation of the old one. Per object versus per rule: the shared-host shape above needs a second Ingress object for a prefix-stripping app, which then repeats a TLS secret name, omits the cert-manager annotation conditionally, and matches an ImplementationSpecific regex path. In Gateway mode it is one rule with a filter — and the four crutches are gone. Measured on freesailarr.

The second such place, found in group 3: two routing objects may share a hostname. Under ingress-nginx two Ingress objects cannot both claim one host — the admission webhook rejects the second claim on the same host and path — which is why matrix-synapse has to carry its /.well-known/matrix delegation rule inside its own single Ingress object even though the Matrix server name is normally an apex domain belonging to a website. HTTPRoutes attached to the same listener for the same hostname are merged by the implementation, longest match winning, so that route can sit beside whatever owns the apex without either side knowing.

A value derived from ingress.enabled is the sweep's second screen, and it is invisible in the Ingress template. Gateway mode sets ingress.enabled: false by construction, so anything computed from it flips. Where that value stood for "is TLS terminated in front" or "is this app published at all", the flipped value is wrong and nothing fails. The fix is one helper reading ingress.enabled OR httpRoute.enabled — never a new value. Found three times, which is every chart that had the pattern: sure (RAILS_ASSUME_SSL/RAILS_FORCE_SSL), freesailarr (the shared-host app list, which also feeds the <APP>__SERVER__URLBASE variables) and ytdlp-web-player (the guard refusing to publish the app and its oauth2-proxy on one host).

And that last one is the case worth remembering, because the guard mattered more in the new mode. A host claimed twice fails loudly in Ingress mode — the admission webhook rejects the second Ingress. In Gateway mode the webhook never fires: it compares Ingress objects with Ingress objects, and an HTTPRoute is a different kind. Two valid objects then serve one hostname over two data paths that are not equivalent — the proxy's Ingress through authentication, the chart's route straight to the application — and which one a client reaches depends on DNS. So a render guard whose Ingress-mode job was to pre-empt a noisy webhook becomes, in Gateway mode, the only thing standing between a values file and an authentication bypass. When screening for this pattern, ask what the derived value protects, not only what it computes. See Lessons learned.

A subchart does not come along. Upstream charts are consumed as they come (§14), so a dependency that renders an Ingress keeps rendering one whatever the outer chart's mode is. ytdlp-web-player's oauth2-proxy has no Gateway-API support, so in the recommended configuration — proxy in the data path, owning the host — the publishing object is the subchart's Ingress in both modes, and switching the outer chart buys nothing. Say so where an operator will look for it: the template header, values.yaml, the schema and the relevant NOTES.txt branch.

And the CI side is part of the convention, not an afterthought. Either the chart's install-test fixture installs in Gateway mode, or the chart gets a render-only overlay at ci/httproute/<chart>.yaml. Without one of the two, ci/run-httproute-match-test.sh is green over nothing for that chart — which that test fails on. For a multi-app chart the overlay must also enter every shape the template has, or the branch nobody exercised is the one that breaks. See CI/CD.

Reference implementation: template-chart/templates/xxx.httproute.yaml. The sweep is finished except for one chart. The pilot (cyberchef); group 1 — one Ingress, no behaviour-bearing annotation — (bentopdf, garage, patchmon, rallly, searxng, sure, urlaubsverwaltung, voucher-vault); group 2 — the annotation cases — (calibre-web-automated, outline, paperless-ngx, zipline, freesailarr); group 3 — the special shapes — (homeassistant, keycloak, bulwark, matrix-synapse, stalwart, ytdlp-web-player). Remaining: rss-bridge only, frozen for changes — which is why #114 cannot be closed while #51 is open.

Three of #114's descriptions of the group-3 shapes were wrong, and the corrected list belongs here because this is what gets copied:

#114 said Measured
keycloak and bulwark have two Ingresses each keycloak does. bulwark has one.
bulwark is the second rewrite-target chart It has none and cannot have one: NEXT_PUBLIC_BASE_PATH is compiled into its bundle, so it cannot be served under a sub-path and its route is one rule at /. The grep that produced the claim hit the chart's own refutations of the idea. freesailarr is still the only rewrite-target chart.
matrix-synapse renders its Ingresses in a loop over several apps That is freesailarr. matrix-synapse renders one Ingress with two host rules, which becomes two HTTPRoute objects.
stalwart renders per node group Its Services and StatefulSets do; the routing object points at exactly one group (ingress.nodeGroup) and must.

The general lesson from that table, and it is the cheaper half of the live-object screen above: a shape read off an issue's summary table is a hypothesis. Open the template before building on it — three of four were wrong here, and two of the three had already been copied into this file and into a CI script's header comment, where they read as settled fact.

One chart still renders a Certificate in Gateway mode, and that is correct: stalwart's mail certificate covers STARTTLS on 25 and implicit TLS on 465/993, which pass through neither an ingress controller nor a Gateway. Only the HTTP hostname's certificate moves to the listener. A migration that "tidies up" the Certificate drops SMTP and IMAP to a self-signed one while every Kubernetes object stays green.

5. Security hardening

Defaults on every workload: automountServiceAccountToken: false, non-root user, readOnlyRootFilesystem: true, all capabilities dropped, allowPrivilegeEscalation: false. Pod and container contexts are values-configurable via securityContext.pod / securityContext.container. Writable paths (/tmp, nginx cache dirs, s6-overlay's /run) are emptyDirs so the root filesystem stays read-only.

Where an image cannot satisfy the standard, the deviation is minimal and MUST be justified with a comment in that chart's values.yaml. Home Assistant must start as root for s6-overlay; paperless needs a root init container to hand over /run; samba needs its capabilities; calibre-web-automated must keep readOnlyRootFilesystem: false.

6. Probes

Every workload has liveness, readiness and startup probes — the one documented exception is the paperless-ngx consume CronJob. Probes are values-configurable with calibrated defaults, and the startup probe gates liveness and readiness; its budget is documented in a values comment per chart.

7. Resources (auto-limits)

Requests ship as chart defaults in values.yaml and are user-overridable. Limits are computed by a per-chart _helpers.tpl helper:

  • an explicitly set limit always wins;
  • a missing memory or ephemeral-storage limit defaults to resources.limitFactor × the request (factor default 3);
  • a CPU limit is only rendered when set explicitly — otherwise CPU stays unlimited, because it is compressible and throttles instead of OOM-killing.

The helper is copied into every chart rather than shared through a library chart, which would be a second artifact to publish, version and pull for something that changes about once a year. The resource-quantity render test exists because a copy can carry an old bug into a diff nobody reads.

8. Secrets

Secrets are 1Password OnePasswordItem CRs: values expose secrets.<name>SecretRef holding an op:// item path, and the 1Password operator materializes the Kubernetes Secret in-cluster.

The Secret's keys are the field names of the 1Password item. A missing field does not fail the install — the release goes through and the pod hangs in CreateContainerConfigError naming the key it could not find. Every chart's NOTES.txt therefore lists the required fields.

9. Config changes must restart the workload

Every workload consuming a ConfigMap carries a checksum/config annotation on its pod template, one line per ConfigMap:

template:
  metadata:
    annotations:
      checksum/config: {{ include (print $.Template.BasePath "/<chart>.configmap.yaml") . | sha256sum }}

Where a workload consumes several ConfigMaps, use one annotation per file with a distinguishing suffix (checksum/config-<name>).

Why it is mandatory: without it a changed ConfigMap does not change the pod template, so nothing restarts and the running container keeps the old config — silently. subPath mounts, the usual form here, are never refreshed by the kubelet at all, and most apps read their config only at start. This produced a real outage: the router kept routing to Services that no longer existed while every bundle was green and the pod was Ready.

It is content-based, not time-based. The hash is taken over the rendered ConfigMap, so an unchanged config renders an identical hash and helm upgrade is a no-op. It never degenerates into a restart-on-every-upgrade.

Adding the annotation to an existing chart is a major bump: a config change now costs a restart, and some workloads take minutes to come back. Name that cost in the PR.

Secrets cannot use this mechanism

Helm never sees a OnePasswordItem's content — the operator fills the Secret in-cluster after rendering, so there is nothing to hash. A chart mounting such a Secret as a file documents the limitation in its values.yaml.

What can be covered is the secret's op:// path, via a separately named annotation over the rendered OnePasswordItem:

checksum/secret-ref: {{ include (print $.Template.BasePath "/<chart>.secret.yaml") . | sha256sum }}

Repointing a secrets.<name>SecretRef at a different vault item then takes effect instead of silently leaving the old credentials in the running pod. The separate name is deliberate: checksum/config promises "the config changed", checksum/secret-ref only "the config source changed". Reference implementations: minecraft-server, homeassistant.

operator.1password.io/auto-restart only works for Deployments

Verified against onepassword-operator 1.12.0: restartWorkloadsWithUpdatedSecrets enumerates exactly one workload type, appsv1.DeploymentList, and getPodTemplate accepts only *appsv1.Deployment. The RBAC role grants statefulsets and v1.11.0 generalized the restart code, but the PR that would add StatefulSets is still open — so the annotation on a StatefulSet is a silent no-op.

Setting it there is worse than not setting it: it advertises a guarantee that does not exist. Affected charts (matrix-synapse, samba, homeassistant) document the manual kubectl rollout restart statefulset <release>-<chart>-sfs instead. Re-check when the operator version is bumped.

If a Deployment-based chart ever does set it, mind the precedence: the annotation on the OnePasswordItem is copied onto the Kubernetes Secret and read first, so a false there overrides a true on the workload — the opposite of what the operator's usage guide suggests. Lookup order: Secret → workload → namespace → the operator-global AUTO_RESTART. The trigger is the 1Password item's version counter, not its content, so a re-save with identical values still restarts.

10. Environment variables

Every workload container offers an additive env value block, appended to the entries the chart always sets. Entries are rendered through tpl (so values may contain Helm templating) and support the value, secretKeyRef and configMapKeyRef forms.

| quote on the rendered value — and on the name and key of the ref forms — is part of the pattern, not cosmetics. Kubernetes types env[].value as a string, so a value of "true" or "0755" rendered bare becomes a YAML bool or integer and the API server rejects the whole manifest with cannot unmarshal bool into Go struct field EnvVar.value of type string — after a perfectly green render. A value beginning with * fails in the other direction and aborts the render, with an error naming the template while the operator's value appears nowhere in it; quoting it in the values file does not help, because Helm passes the string's content through. template-chart/templates/xxx.deployment.yaml carries the rule, and ci/run-yaml-scalar-render-test.sh holds it on every PR — see the yaml-scalar job and the lesson it was bought with.

And the count, because a convention with unnamed exceptions decays into a claim. 24 of 30 charts follow it; six do not — matrix-synapse, minecraft-router, minecraft-server, rallly, satisfactory, teamspeak-server — and they are green only because no fixture of theirs sets a hostile value. Bringing one over changes rendered output for values that currently work, so it is a major bump for that chart. template-chart's own comment claimed "every chart in this repo quotes here" while seven did not; that is what the test and this count replace.

Additive means additive: an entry repeating a name the chart already sets does not replace it, and what the pair does depends on when it arrives. At install both entries reach the pod and the later one — the user's — wins. On an existing release the same entry is lost: Kubernetes merges env by name, the pair collapses to the chart's own value, and helm upgrade still reports success.

Measured on urlaubsverwaltung (helm 3.19.0, Kubernetes 1.35): helm get manifest shows the variable twice while the live Deployment shows it once, carrying the chart's value. The failure mode is silence, so a chart whose NOTES.txt tells operators to override one of its own variables has to say that the override only takes at install time — and that a green upgrade proves nothing, the value belongs read back off the Deployment.

11. Scaling

  • Every Deployment/StatefulSet has a replicas value (default 1, path matching its section) that also accepts an explicit 0.
  • Every chart has a global scaleDown flag (default false). scaleDown: true takes precedence over all replicas values, renders every workload with replicas: 0 and suspends CronJobs. It stops the app without uninstalling it: PVCs, Secrets, Services and Ingress stay; setting it back brings the workloads up.
  • Implemented via a per-chart <chart>.replicas helper using an explicit nil check (kindIs "invalid"), because Helm's default would swallow an explicit 0.
  • A chart whose default is more than one replica ships a PodDisruptionBudget. policy/v1, named <release>-<chart>-pdb, minAvailable as a value (default 1), selector on the workload's app label. keycloak is the first and set the convention. It is a rule rather than a per-chart taste because of which failure a second replica is usually bought against: a node drain is the most common reason a pod moves — it is what a node upgrade does — and without a budget the drain is free to take every replica at once. The second replica then costs money and buys nothing.
  • And it is rendered only while the computed replica count is >= 2. minAvailable: 1 with exactly one replica forbids every voluntary eviction, so kubectl drain blocks forever on a pod nothing may move — and the message names the PDB, not the replica count, which sends the reader to the wrong file. The same applies to scaleDown: true, where there are no pods and the object would only describe a promise about nothing. So the condition reads the <chart>.replicas helper rather than the raw value, and NOTES.txt prints which of the two happened instead of leaving it to be inferred from kubectl get pdb.
  • A chart whose replicas are a property of a group ships one PDB per group, with that group's own count. stalwart is the first — one node group per cluster role, one StatefulSet each. The >= 2 condition then applies per group (with full: 2 and mx: 1 exactly one object exists), the name and the selector carry the group, and NOTES.txt lists the outcome per group rather than once for the release.
  • A budget without a distribution rule is half an answer. A PDB keeps a drain from taking every replica at once; it says nothing about where the replicas were put in the first place, and two pods on one node are not redundancy. So where replicas are meant to survive the loss of a node, the chart also offers a switch that renders the topologySpreadConstraints entry itself — maxSkew: 1 on kubernetes.io/hostname, whenUnsatisfiable: DoNotSchedule so the distribution is a promise rather than an effort, plus a generic pass-through (affinity, nodeSelector, tolerations, topologySpreadConstraints) for what the switch does not cover. stalwart's nodes.<group>.spreadAcrossNodes (default true, same computed >= 2 gate) is the reference.
  • The labelSelector is filled by the chart, never written by the operator. A spread constraint whose selector matches no pod is trivially satisfied — a condition over zero pods — so it distributes nothing with a green rollout, no event and no log line; and with DoNotSchedule the object then reads like a guarantee. A PDB whose selector matches nothing never blocks an eviction while looking like protection. One helper therefore defines the pod template's labels, the spread selector and the PDB selector (stalwart.groupLabels; the precedent for one-definition-because-a-mismatch-is-not-a-render-error is stalwart.env.dbPassword), and ci/run-selector-match-test.sh holds every rendered selector against the rendered pod labels on every PR — see the selector-match job. One definition only helps while nobody writes a second one.
  • Set nodeAffinityPolicy: Ignore explicitly, and argue it separately from the replica ceiling. The two node-inclusion policies have opposite defaults: nodeAffinityPolicy defaults to Honor, nodeTaintsPolicy to Ignore. Under Honor a narrowing nodeSelector shrinks the set of counted domains until both replicas fit into one — skew 0, co-location, no Pending, no event, and a correct selector, so no check can see it. Ignore turns the same case into skew 2 and a Pending pod. A render abort is the wrong fix: it would also forbid a nodeSelector spanning five nodes, which is legal. Two things belong in values.yaml with it, and separately: the field is beta and is pruned without a word below Kubernetes 1.26, which brings the gap straight back; and the replica ceiling — as many replicas per group as there are schedulable workers, because nodeTaintsPolicy ≙ Ignore keeps control-plane domains at 0 — comes from the other field and existed before this one was set. Write the ceiling as a rule, never as a number.
  • A user entry colliding with the chart's own on (topologyKey, whenUnsatisfiable) aborts the render, naming both values. whenUnsatisfiable defaults to DoNotSchedule, so an omitted one still collides. The alternatives are an API error pointing at a list index, and a silent merge over patchMergeKey: topologyKey that drops one labelSelector and goes green.
  • Do not add updateStrategy or podManagementPolicy for this. The Kubernetes defaults — RollingUpdate with partition: 0, and OrderedReady — already update one pod at a time and wait for Ready. A values key that restates a default is a key someone will later believe does something.

12. Values that must not lie

Every chart ships a values.schema.json with additionalProperties: false on every chart-owned object level — not just the top one. The gap this closes is a key that looks like configuration and does nothing: the pilot found three of them in one chart, one nested three levels deep and quietly leaving a game world unpinned.

Objects passed verbatim into the pod spec stay open (probes, securityContext.pod/.container, global, free-form annotation maps): Kubernetes owns their schema, and restricting them would only reject fields that do not exist yet. The schema adds to required(), it does not replace it.

1. A value read outside its obvious template needs its own guard

Before putting an Ingress behind ingress.enabled, check whether anything else reads ingress.domain — in templates/ and in values.yaml, because env entries are rendered through tpl and hide such reads. If something does, that read needs its own required(); the gate then frees clusterIssuer but not domain.

This happened in six charts, and matrix-synapse shows why it matters more than the count: its delegation nginx serves ingress.domain in the /.well-known/matrix/{server,client} endpoints through which federation finds the homeserver. Gating without the guard publishes {"m.server": ":443"} — rendered cleanly, pod green, federation broken. Check per chart and in both directions: in calibre-web-automated nothing outside the Ingress reads the domain, and there the gate frees both values.

Two addenda from the Gateway-API sweep, because the second routing mode added a second reader and a second gate:

  • ingress.domain has a new reader in every converted chart: the HTTPRoute, whenever httpRoute.hostnames is empty. A chart that documented the domain as Ingress-only therefore has to correct that sentence rather than leave it standing — garage and calibre-web-automated both did, and in both the claim now holds only while both modes are off.
  • A read that relied on another template's required() is not guarded. paperless-ngx built PAPERLESS_URL from ingress.domain without a required() of its own, which was safe only while the Ingress was unconditional. The moment the Ingress became switchable, httpRoute.hostnames set plus an empty domain would have rendered https:// and refused every login POST as a CSRF failure. Guard the read where it is, not where it happens to be checked today.

2. Every chart-owned block goes through the null-safe helper, never behind an if

An override written without children is YAML null and erases the block: the container renders securityContext: null (unhardened, healthy, silent), a probe disappears, or resources loses its requests and computed limits.

The defaultedDict helper defines the opposite semantics — an empty block means "chart defaults apply" — while an explicitly set value still wins, including false and 0 (hence the kindIs "invalid" check; default and mergeOverwrite both swallow zero values).

Do not wrap these blocks in {{- if }}: a guard does not render a visible null, it removes the probe entirely — the same bug with no evidence left.

The rule is about the shape, not about those three block kinds. It applies to every chart-owned block a template dereferences, plain app-config blocks included. satisfactory shipped 7.0.0 with game null-safe and its neighbours dev and server not, and half-safe is worse than unsafe: whoever sees game=null fall back assumes server=null does too. The single erased key is the quieter half — a nulled server.gamePort rendered port: and nodePort: empty into the Service and an empty containerPort into the StatefulSet, and nothing rejects that until the API server does.

An exception is allowed; an unmarked one is not. A key may be left un-defaulted on purpose — satisfactory.storage is, so that an erased size fails at apply time instead of being guessed by the chart — but then the schema, values.yaml and the chart's wiki page all name it as an exception and point at the issue where the trade is argued. That is what separates intent from oversight for the next reader. And do not settle such an exception inside an unrelated bugfix: overturning a documented decision there is a behaviour change smuggled into a patch, and it costs the patch the property that makes it reviewable — that its output is byte-identical for every valid input.

And helm template does not render NOTES.txt. A value read there stays broken after a fix that looks green in every render, until a real helm install — and for a chart excluded from the install-test, no stage renders the notes at all. helm install --dry-run=client is no way around it: it still wants a reachable API server (measured on helm v3.22.0). Cluster-free method: in a throwaway copy of the chart, wrap the verbatim NOTES.txt body in a {{- define }} and include it into a scratch ConfigMap, which helm template does render — same engine, same root context, only the sink differs.

3. Do not hang liveness on an external dependency where a dependency-free path exists

zipline deliberately probes / for liveness and /api/healthcheck only for readiness and startup, so a database outage cannot restart the pod. Where no such path exists — outline returns 500 on / when the database is down — all three probes may share the endpoint, but the reason belongs in a comment on the helper, or the next reader will "fix" the inconsistency.

The other half of the same rule: readiness must hang on the dependency, and a separate path is not proof that it does. urlaubsverwaltung probed /actuator/health/liveness and /actuator/health/readiness for two years and still stayed 1/1 Ready through a total database outage, because Spring Boot's readiness group contains readinessState alone — the split paths were right, the group content was empty of anything external. Whatever aggregates behind a probe path has to be read off the running endpoint, not off the path name; the chart now names the members explicitly (MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE=readinessState,db).

4. A fallback reproduces this chart's defaults, not the repo standard

Five charts deviate from the hardening baseline on purpose. A "cleaned up" standard default is not tidying, it is an outage: calibre-web-automated must keep readOnlyRootFilesystem: false, because in read-only mode the image disables its PUID/PGID remapping and the runtime uid silently moves from 1000 to 911 — making existing volume data inaccessible.

The same applies to non-standard values that look like mistakes: satisfactory keeps limitFactor: 2 (that is what holds the memory limit at the published 8Gi) and fsGroupChangePolicy: OnRootMismatch (which avoids a recursive chown of multi-GB game files on every start).

5. A probe default carries the probe's context, not just its timings

teamspeak-server addresses its TCP port by the name file-transfer, because the voice service is UDP and cannot be probed — a default without that name leaves the workload with no working probe at all. patchmon sends a Host header, because the server answers 403 to any request whose Host is neither loopback nor a CORS_ORIGIN host — a default without it means the pod never becomes Ready.

Port names, Host headers and exec commands belong in the fallback; where the context is dynamic, pass the root context in and rebuild it.

6. A value that reads as the negation of the variable it feeds must actually be negated — and not cannot do it

satisfactory shipped game.seasonalEvents straight into the image's DISABLESEASONALEVENTS: the default true switched the events off in every install for the chart's whole life, while the schema described the key as "true means events are enabled".

The fix is the loud one — rename the key so it says what it does (game.enableSeasonalEvents) rather than silently inverting the template under existing releases, because the old key then fails schema validation instead of quietly meaning the opposite.

Two mechanics come with it: Go's not treats every non-empty string as true, so not "false" is false and the string form these schemas deliberately accept flips the wrong way — compare explicitly (eq (lower (printf "%v" $v)) "true"). And a value that cannot be read as either boolean belongs in a fail, not in an implicit branch: enableSeasonalEvents: "yes" must abort the render. When such a rename lands, the new default is the one that reproduces what the chart already rendered; changing the meaning and the behaviour in the same release turns the loud fix back into a silent one.

Two mechanics that are easy to lose

  • null reaches the schema in two cases out of three, and which one you are in depends on the chart's default, not on what the user wrote. Measured on Helm 3.22.0 with a probe chart, because the wording here was wrong before and was being applied to exactly the case it got wrong:

    • a user null over a non-null chart default (--set x=null, or x: null in a values file) is treated as a deletion and the key is removed before validation — the schema never sees it, and only the helper can catch it;
    • a null chart default survives into validation, with or without a user null on top of it, because there is nothing to delete it against: the schema sees the key present with value null;
    • a key without any default (e.g. resources.limits) likewise survives, and a strict "type": "object" aborts the render.

    That is why the helper and the widened ["object", "null"] / ["string", "null"] types are both needed. Everything else stays strictly typed.

  • A value that has no valid default cannot be enforced by values.schema.json at all — required() is the whole enforcement. The key ships as null, so it is present at validation time, and JSON Schema's required tests presence and not null-ness: the array never fires, under any input. Both ways of making it fire break the rule that helm lint --strict passes on chart defaults, because lint validates those defaults — dropping the null default gives missing property '<key>', narrowing ["string", "null"] to "string" gives got null, want string, each a red pipeline on a chart nobody touched. So such a key is guarded with required() in the template and described as required in the schema, and carries no required array. See Lessons learned.

  • Validate every schema against the real values, not only against chart defaults. Defaults are valid by construction, so they prove nothing. Render each chart against its cluster bundle and against helm get values — the two differ more often than expected (four of ten bundles in one wave, always with the repo file being the worse one). This caught a schema that would have broken three live paperless releases, because all three write tikaGotenberg.enabled as the string "true" while the first draft demanded a boolean. The result per chart is a list of rejected keys, which is the cluster side's work order before the version is pulled up.

13. Everything else

  • icon: is mandatory in every Chart.yaml. Documented exception — freesailarr, the only chart without one: the rule elsewhere is "the icon is this chart's one upstream's logo", and a chart bundling ten independently versioned projects has no such upstream. That is the whole criterion — a single-upstream chart without an icon is a missing icon, not an exception. The omission costs an INFO line in helm lint --strict, which still exits 0.
  • helm lint --strict must pass without findings.
  • Every chart ships a templates/NOTES.txt, template-chart included. The bar: print what an operator would otherwise have to look up in the templates — the rendered access URL, which fields the 1Password item must carry, the options actually active in this release, and the chart's known traps, all conditional on the values really set. A NOTES.txt that only repeats the values is skimmed once and never read again. No real domain anywhere — the URL renders from ingress.domain, and where an example is unavoidable it is example.com. Writing or reworking one renders no Kubernetes object: a minor bump.
  • CI fixtures under ci/<chart>/, or a justified entry in ci/excluded-charts.txt. The install-test fails if both are missing.

14. Chart dependencies

The former "no dependencies" rule is gone, replaced by a bar rather than a ban. Three charts use one. ytdlp-web-player needed upstream's oauth2-proxy in the data path in front of an application that ships no authentication at all, and proxy and app are only useful wired to each other. stalwart takes one of our own published charts, valkey, because Stalwart's cluster coordinator rides on the in-memory store connection and a multi-node install does not work without one. paperless-ngx takes the same one, because PAPERLESS_REDIS is the Celery broker and the application cannot run without a Redis at all — before the subchart every installation needed a second Helm release whose address lived in a 1Password item and in neither chart.

The same subchart, opposite configuration — which is what makes the two the useful comparison. stalwart runs valkey as a cache (persistence.enabled: false + allkeys-lru), paperless-ngx as a queue (PVC + noeviction). The valkey chart treats those two values as a pair and warns against mixing them, so picking a half is a decision each parent argues in its own values.yaml rather than inherits. The general rule the two cases produce: allkeys-lru is wrong wherever the keyspace contains work rather than only derived data, because LRU evicts typelessly and queued work is exactly what it reads as cold.

When a dependency earns its place — all three, not two of three:

  1. The dependent piece would otherwise be a second Helm release that only exists to serve this one, with the wiring between them living in neither chart.
  2. Upstream is the right owner of it. We consume their chart as it comes: we do not patch it, do not re-harden it, do not re-implement its values. Where their defaults fall short of these guidelines, that is documented in our values.yaml, not fixed by us — and a values-level fix stays available to whoever installs it. This point does not apply when the subchart is one of ours (stalwart → valkey): there is no third party's defaults to leave alone, because the subchart follows these guidelines by being one of them. What that case adds instead is the PR-ordering rule below.
  3. It is optional and off by default (condition: in Chart.yaml), so the chart is complete without it and a default install renders none of its objects.

What it costs, all of it visible before the next one is added:

  • The subchart is invisible to everything downstream. Its version is inside our packaged .tgz, so anything watching Helm release versions — Fleet's fleet.yaml included — sees only our chart version. A new upstream release, security fix included, reaches a cluster only when we publish a new version of the outer chart, and nothing reports the gap. Renovate's built-in helmv3 manager opens the PR and ci/renovate-bump-chart.sh bumps the chart version that ships it — since Gitea #128 at the subchart's own level, so an upstream minor makes a chart minor even though our appVersion did not move; that chain is the entire safety net, and that number is the only place the jump becomes visible. State this in a comment at the pinned version, not only in a review.
  • Every pipeline that touches the chart needs a network fetch first. helm lint, package, template and install all exit 1 while a declared dependency is missing, so ci/helm-dependencies.sh runs before each. One call site is the render test that runs over every chart on every PR — a dependency-carrying chart can turn a third-party Helm repository's bad day into a red pipeline on an unrelated PR.
  • charts/*.tgz and Chart.lock are not committed. The archive is a third-party binary nobody reviews in a diff, and a committed lock would arrive stale because Renovate rewrites Chart.yaml and cannot regenerate it — which is also why the pipelines run helm dependency update, not build.
  • The repo's own mechanisms stop at the subchart boundary. checksum/config, the 1Password auto-restart annotation and scaleDown reach our templates only. A chart rendering a OnePasswordItem for its subchart documents the manual kubectl rollout restart that replaces the missing restart-on-change.

When the subchart is one of our own charts, one more rule, and it is the sharpest one here:

Never change a chart and a chart that depends on it in the same pull request.

The dependency resolves from the published index, not from the working tree, and a chart is published only by a push to main that touches it. A combined PR therefore runs every helm command — lint, package, render and the install-test — against the old subchart. It goes green on an artifact that is not the one in the diff, the subchart's own change is exercised by nothing, and the first real use of the new subchart is somebody's first install after the merge. Two consecutive PRs: change the subchart, merge, let it publish, then raise the version: in the dependent chart.

Two mechanical facts about a self-hosted dependency, measured for stalwart rather than assumed, because either could have broken the update path silently: the index is anonymously readable, so ci/helm-dependencies.sh needs no credential and resolves even a build-metadata version (1.0.0+up9.1.2.alpine) from the URL alone with an empty HELM_REPOSITORY_CONFIG — the state a fresh runner is in. And Renovate's built-in helmv3 manager does follow a self-hosted Gitea index: a dry run with the dependency pinned low returned newVersion: "1.0.0+up9.1.2.alpine", versioning: "helm". Without the second result the published-version form would have had no update path at all.

Three implementation notes that cost time otherwise: a values key containing a dash is unreachable as .Values.oauth2-proxy (that parses as a subtraction) and needs index .Values "oauth2-proxy", while condition: in Chart.yaml splits on dots only and needs no escaping. Inside a subchart .Chart.Name is the subchart's name — a value templated into it must spell the parent chart out literally; only .Release.Name is shared.

And the one that bites hardest: everything under the subchart's values key is handed to the subchart and validated against its schema, so the condition: key must not live there. Our charts ship additionalProperties: false, so the conventional valkey.enabled is rejected by the subchart — at '': additional properties 'enabled' not allowed. Helm skips a disabled subchart's schema validation, so the default path renders clean and the error appears only for whoever first switches it on. stalwart therefore gates on valkeySubchart.enabled, a key of the parent's own, which is also the honest shape: enabled was never a value of the subchart. It only looked like one because oauth2-proxy's upstream schema is permissive.

Two more, both measured for paperless-ngx (Gitea #78) and both general:

  • A cross-chart values rule belongs in values.schema.json, not in a fail(). Helm renders a subchart's templates before the parent's, so a guard in the parent always loses the race to the subchart's own required() — and that message names the value by its path inside the subchart (secrets.authSecretRef, not valkey.secrets.authSecretRef) and cannot know what the parent knows about it. Schema validation runs earlier than any rendering, so an if/then in the parent's schema is the only form that both fires and names the real path. A parent-side fail() for this is not merely redundant; it is a check that cannot fire, which is the failure mode §12 exists to prevent.
  • An erased condition: key switches a subchart ON. Setting the switch to null deletes the key from the coalesced values, Helm then cannot resolve the condition: path, and an unresolvable condition counts as enabled. So the one block in a chart that must not be null-safe is the dependency switch — the opposite of §12's rule for every other block, and in the worst direction, since the subchart comes up unconfigured. Require the key in the schema. An empty block ({}) is harmless: Helm merges it with the chart default.

template-chart remains dependency-free: the scaffold should not carry a dependencies: block a new chart has to remember to delete.

15. NetworkPolicies

Every chart renders a NetworkPolicy, and networkPolicy.enabled defaults to true (Gitea #5). Reference implementation: template-chart. Adding one to an existing chart is a major bump.

The default is the decision the convention rests on. A policy that ships switched off is reached only when somebody remembers to switch it on — and the issue is then closed while nothing is filtered. That is not hypothetical: garage and keycloak were both created after #5 was filed and both arrived with no policy at all, keycloak with its unauthenticated management surface on 9000. ci/run-networkpolicy-test.sh fails a chart that renders none, which is what keeps that from happening again.

What the object is, and what it is not

A ceiling on reachability, not an authentication layer. Three limits are real:

  1. Only the declared ports are reachable at all — whatever else the process binds, now or after the next app update, is blocked without anyone having to notice it exists. For most charts here this is where nearly all of the value sits.
  2. Egress is capped by port, so a compromised pod cannot open arbitrary outbound connections.
  3. Ingress is capped by source for every port no network probe touches.

The four port classes — the part to get right first

Every port falls into one of four, and the class decides the rule:

Class Rule Why
probe path — a port a network probe addresses no from by default; restricted once probeSources is set the kubelet probes from the IP of the worker node running the pod, which no chart can know. A CIDR that misses it drops every probe: the pod never becomes Ready, the rollout times out, and the symptom reads as a broken application
public — published by a NodePort Service no from the senders are on the internet and, after masquerading, the source is a cluster node anyway. A selector could only be a list of node IPs — a restriction in appearance, a maintenance trap in fact
closed — no probe, not published outside namespace + allowedNamespaces (+ ingressController for HTTP) the only class that yields a real inbound restriction. The charts that have one are exactly those whose probes use a different port: keycloak's http, garage's s3/web, minecraft-server's RCON, teamspeak-server's query port
self — a bus between a release's own replicas bare podSelector, no namespaceSelector the narrowest rule the object can express. And policyTypes: Egress means the outbound side needs its own rule, or the bus is open one way and the cluster never forms while every node looks healthy

exec probes are not network probes, and that is load-bearing rather than pedantic: an exec probe runs inside the container through the kubelet's runtime and puts no packet on the network. It is the entire reason valkey and minecraft-server have the tightest policies in the repository — every port restricted, no probeSources at all — and every HTTP-probed web app has a looser one.

Values, and shipping only the live ones

networkPolicy.enabled, .allowedNamespaces, .probeSources, .ingressController, .egress.extraPorts. A chart ships only the ones that reach a rule: a value that silently does nothing is worse than one that is absent, because it reads as a control. samba and satisfactory carry enabled alone; teamspeak-server has allowedNamespaces but no probeSources.

A chart may add a named egress switch where a port depends on something the values cannot see. paperless-ngx's egress.mailImport opens IMAPS 993, off by default, because its mail accounts are rows in its own database and nothing in the values says whether a release fetches mail. Two conditions for one of those: it names what it opens (so mailImport covers 993 and not also 143, 110, 995 — those stay in extraPorts), and it is not a second switch for a port another value already decides. That second half is Gitea #136's doing: the Tika/Gotenberg rule was deliberately left gated on the feature value paperless.files.tikaGotenberg.enabled and given no policy switch of its own, because two values that can drift apart re-arm the outage the rule was being fixed for — whoever enables the feature and forgets the policy switch lands in the same timeout, with the same error that does not mention a policy.

No chart ships allowedNamespaces pre-filled, ever. A chart naming mc-router, paperless-jk or stalwart in its defaults would bake in an assumption about this cluster's namespace layout — a claim a chart cannot keep true. So the default is the empty list and the chart is restrictive; filling it is the deployment adaptation, made where the namespace layout is actually visible. That is why a sweep like Gitea #5 produces a list of release-side one-liners: they are the expected procedure, not a defect list, and the breaking-change major on each chart is what announces them. ingressController is the single exception, because it names a cluster component rather than a tenant — and it is still overridable.

ingressController is its own two-key value rather than a line in allowedNamespaces for one reason: its two selectors render as one from entry, which is an AND — those pods in that namespace. As two list entries they would be an OR, and kube-system in allowedNamespaces would open the port to every pod in the cluster's own namespace. Its default is measured, not assumed (2026-10-14): ingress-nginx is gone from this cluster, the only IngressClass is traefik, and the controller is the DaemonSet rke2-traefik in kube-system — which also serves the Gateway API here, so one source covers the Ingress and the HTTPRoute path alike. ingress.className says nothing about it: a class names an implementation, not the pods the traffic comes from.

What to write down per chart, because the object will not say it

  • Egress is coarse: by port, never by destination. A NetworkPolicy matches CIDRs and selectors and never hostnames, so "only to the database host" is inexpressible. This cluster's CNI is Canal (Flannel plus Calico) and has no FQDN rule; Calico's GlobalNetworkSet could express one but would bind every chart to Calico — the same argument that kept implementation-specific objects out of the Gateway-API sweep — so it is not used.
  • Most dependency ports are the standard ones, not the configured ones. Databases, caches and OIDC issuers arrive as connection URLs or fields inside 1Password items the charts never parse. A dependency elsewhere is blocked; egress.extraPorts is the escape hatch. The exceptions derive theirs from values: keycloak (keycloak.db.port), stalwart, and minecraft-router from its own routing table.
  • The probed port stays open inside the cluster by default — for a single-port web app the policy is a port and egress ceiling, not pod-level isolation. Say so rather than letting the object imply otherwise.
  • A subchart's pods are not covered. ytdlp-web-player's oauth2-proxy is the sharp case: with the proxy on, the pod that actually faces the Ingress is unrestricted both ways, and an upstream chart is consumed as it comes (§14). paperless-ngx is the opposite — its valkey brings its own, tighter policy. Name which case the chart is in.
  • A policy is only a boundary where the CNI enforces one. Unenforced, the API server accepts it and nothing is filtered, so everything reads as configured. Verify by connecting from an unrelated namespace.

Where "Ingress only" is the right answer

policyTypes names only what there is something to restrict. valkey established that for a workload that opens no outbound connection. Three charts now invoke the same rule in the other direction, for outbound traffic nobody can enumerate — and each argues it in its own file rather than inheriting a blanket excuse: homeassistant (the union of every integration an operator enables), satisfactory (Steam's content delivery, and the one chart with no install-test at all). gotenberg is the instructive contrast: it keeps a DNS-only cap deliberately, because it drives a headless Chromium on its probe port, and pays for it with HTML conversions that silently lose remote assets.

Mechanics

  • Pod ports, never NodePorts. A policy is evaluated at the pod's interface, after kube-proxy translated the port. 30025 where the pod listens on 25 matches nothing: accepted, strict-looking, and blocking every inbound connection instead.
  • And pod ports, never Service ports — in egress too (Gitea #136, paid for with an outage). The same destination NAT runs on the way out: kube-proxy rewrites a ClusterIP's address and port before the egress policy is evaluated, so the policy sees podIP:targetPort and never clusterIP:port. An egress rule therefore names the pod port of its destination. A Service port matches only by coincidence — wherever port and targetPort are the same number, which is true of 5432, 6379, 993 and 443, and is why the mistake stayed invisible until a chart dialled a Service forwarding 80 to 9998. For a destination outside the cluster there is no NAT in the path and the port dialled is the port to name: the deciding question is the DNAT, not the direction. The tell is the symptom — a dropped packet runs into a timeout, a missing listener answers connection refused, so a timeout to a service that demonstrably exists is a policy question, and no Kubernetes error will say so. Where the destination is another chart of this repository, ci/run-networkpolicy-test.sh asserts it; everywhere else it is on whoever writes the rule.
  • Protocol as well as number. teamspeak-server's voice port is UDP and satisfactory's game port is both — a TCP-only rule on either is accepted, looks complete, and drops the traffic that arrives.
  • Never from: []. It allows everything, identically to having no key, while reading as a restriction. Write no from where open is meant and say so in a template comment.
  • Reasoning in Go template comments, never #. A # comment is part of the rendered object: it ships to the cluster and lands in every release diff. And such a comment must not spell out its own closing delimiter — the parser ends it early and the rest is emitted as manifest content, failing the render with did not find expected node content while pointing at the template rather than at the sentence.
  • A conditional containerPort needs a conditional rule. minecraft-server's RCON port renders only when server.rcon.enabled; a rule without the same gate names a port the pod does not declare.
  • A policy applies to traffic that came through a Service, and that is why the ingressController rule works at all. A Service is only destination NAT and the policy is evaluated at the pod's interface, so a ClusterIP exempts nothing. What the Service changes is the source address: pod → ClusterIP → pod preserves it, so a podSelector can still match the Traefik pod and a namespaceSelector a consumer's namespace. Traffic from outside through a NodePort with the default externalTrafficPolicy: Cluster is masqueraded to a node, which is why those rules cannot be restricted by source and are left open. stalwart's template already carried that second half; the first half is what everything else rests on.

The one cluster setting this convention depends on and cannot check

Traefik must not run with hostNetwork. Measured 2026-10-14: the rke2-traefik DaemonSet has none, and its pods carry real pod IPs — 10.42.3.120, 10.42.4.94, 10.42.0.187, in the pod CIDR 10.42.0.0/16, while the nodes sit on entirely different addresses. Only because of that does a podSelector match it, and networkPolicy.ingressController is built on exactly that.

Switch the ingress controller to host networking and its pods share the node's network namespace. They stop being selectable as pods, the ingressController entry matches nothing, and every web chart in this repository goes dark at once in the restricted configuration — while every policy is still accepted and still reads as correct.

The symptom to recognise is that shape: all web traffic stops together and every pod stays Ready, because the probe path does not run through the controller.

Nothing in this repository can detect it and nothing can fail usefully on it — the same class of dependency as --enable-certificate-owner-ref in #126. It is recorded here and in the header of template-chart/templates/xxx.networkpolicy.yaml, so whoever changes the controller's networking meets the consequence attached to the thing they are changing.

See CI/CD for the gate and for what it provably does not cover.