4
template chart
Justin Klein edited this page 2026-10-14 14:57:00 +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.

template-chart

The scaffold every new chart starts from, and the reference implementation of the chart style guide. It is not an application chart and not installable: the image is a placeholder, both workload shapes are rendered side by side, and the example env block points at a ConfigMap that does not exist. It is never published, excluded from the install-test, and exempt from the version-bump check.

This page is the practical companion to Creating a new chart: what to replace, in what order, and which traps the copy carries with it.

Image placeholder ghcr.io/ORG/IMAGE:xxx, which does not exist
Versions not listed on purpose — the scaffold's own pair in template-chart/Chart.yaml is meaningless for a copy, see Chart index
Workloads a Deployment and a StatefulSet — the two shapes to choose between
Storage one RWO PVC <release>-xxx-pvc (optional for a copy)
Ingress yes, gated by ingress.enabled; domain + clusterIssuer required
Gateway API yes, httpRoute.enabled (off by default) — the reference implementation of style guide §4
External dependencies none
Secrets 1 × OnePasswordItem (placeholder field secretKey)
Install-test excluded (ci/excluded-charts.txt)
Published never — there is no publish-template-chart.yml

It is not installable

Three things keep an install of template-chart itself from becoming Ready, all of them on purpose:

  • The image ghcr.io/ORG/IMAGE:xxx does not exist, so the pods stay in ImagePullBackOff. That is why the chart sits in ci/excluded-charts.txt ("scaffold with placeholder image ghcr.io/ORG/IMAGE:xxx, never published") and why no publish workflow exists for it.
  • Both a Deployment and a StatefulSet render. The Service selects the Deployment's pods, so a stateful app has to move that selector along with the workload it keeps.
  • The env block demonstrates the three supported forms and is an example, not a configuration: ENV_FROM_CONFIGMAP names <release>-xxx-config-map while the ConfigMap the chart creates is <release>-xxx-configmap.

What does work, and is the point of the scaffold, is rendering it:

helm lint --strict template-chart
helm template template-chart \
  --set secrets.xxxSecretRef=op://vault/item \
  --set ingress.domain=example.com \
  --set ingress.clusterIssuer=letsencrypt

What to replace, and in what order

  1. Copy the directory: cp -r template-chart <chart-name>. Everything below happens inside the copy.
  2. Chart.yaml — name (this is the xxx in every rendered object name), description, icon (mandatory in every chart), version and appVersion. appVersion is the exact image tag; version is <chart-semver>+up<appVersion> with dashes normalized to dots (Versioning). The scaffold's 4.2.0+upxxx is its own version and carries nothing into the copy.
  3. File names: templates/xxx.<kind>.yaml → templates/<app>.<kind>.yaml. The <app> part is the chart name; the <kind> part is what the object is (deployment, sfs, service, secret, configmap, ingress, pvc).
  4. Decide the workload shape and delete the other file. Stateful apps take <app>.sfs.yaml; if you keep it, move the Service selector from …-deployment to …-sfs. Delete xxx.pvc.yaml if the app needs no volume, and xxx.secret.yaml plus the secrets block if it needs no 1Password item.
  5. values.yaml: rename the xxx: top-level key to the app, rename secrets.xxxSecretRef, and replace the placeholder env, ports and probe content with the app's real ones.
  6. The templates: every .Values.xxx.* reference, and every helper call {{ include "xxx.<helper>" … }}.
  7. templates/_helpers.tpl: the xxx. prefix in each define — the helpers are duplicated per chart precisely because the charts have no shared library — and the default blocks inside them, which must reproduce your chart's defaults, not the scaffold's.
  8. values.schema.json: both the values paths and the xxx. prefixes, then the content along the six points of style guide §12.
  9. The image in the workload: replace host/org/image, keep the registry host — fully qualified on purpose, because an unqualified registry/image resolves to Docker Hub. Keep the tag as {{ .Chart.AppVersion }}.
  10. The probes: point them at a real health endpoint, and carry the probe's context into the fallback defaults in _helpers.tpl, not just its timings. A rebuilt default that loses a port name, a Host header or an exec command leaves the workload with a probe that cannot succeed.
  11. templates/NOTES.txt: delete the leading {{/* … */}} block (it explains the bar and renders nothing) and replace the body with what an operator of your app needs — the rendered access URL, which fields the 1Password item must carry, the options actually active in this release, and the chart's traps, all conditional on the values really set. No real domain anywhere; example.com where an example is unavoidable.
  12. Check where ingress.domain is read. In the scaffold it is read outside the Ingress template twice — the NORMAL_ENV entry and the HTTPRoute's hostname fallback — which is why the env read carries a required() of its own; otherwise switching the Ingress off would render an empty address silently. Decide this per chart instead of assuming either way.
  13. Decide the routing modes. httpRoute ships off, so a copy works unchanged in Ingress mode. If the app offers Gateway mode, say in its NOTES.txt what that mode costs it — an upload limit set through proxy-body-size has no Gateway-API equivalent, and an auth annotation has none either.

Then wire up CI — the four steps that live outside the chart directory: a publish-<chart>.yml workflow, the chart name in the change-app-version.yml choice list, ci/<chart>/ fixtures (or a justified line in ci/excluded-charts.txt), and a customManagers block in renovate.json5. Details in Creating a new chart and Renovate.

Where the placeholder appears

Placeholder Where Replace with
name: xxx Chart.yaml the chart name — it is {{ .Chart.Name }} in every object name
version: 4.1.0+upxxx, appVersion: "xxx" Chart.yaml the real pair, per Versioning
xxx.<kind>.yaml template file names <app>.<kind>.yaml
xxx: / secrets.xxxSecretRef values.yaml the app's values key and secret ref names
.Values.xxx.* all templates, NOTES.txt the renamed values paths
"xxx.<helper>" _helpers.tpl, all templates "<chart>.<helper>"
xxx paths and prefixes values.schema.json both the values paths and the helper prefixes
ghcr.io/ORG/IMAGE xxx.deployment.yaml, xxx.sfs.yaml the real registry/org/image
secretKey, example-setting: "value", NORMAL_ENV/ENV_FROM_* xxx.secret.yaml consumers, xxx.configmap.yaml, values.yaml the app's real secret fields, config keys and variables

Configurable values

The scaffold's values, as they render today. In a copy, xxx becomes the app's key.

Global

Key Type Default Description
scaleDown bool false Renders every workload with replicas: 0 without uninstalling. Takes precedence over xxx.replicas. PVC, Secret, Service and Ingress stay.

secrets

Key Type Default Description
secrets.xxxSecretRef string null Required. op:// path of the 1Password item; the operator materializes it as <release>-xxx-secret, with one Secret key per field. The scaffold reads the field secretKey (the ENV_FROM_SECRET entry) — list your chart's fields in NOTES.txt, it is the one piece of information otherwise only findable by reading the templates.

xxx

Key Type Default Description
xxx.replicas int 1 Replica count; an explicit 0 is allowed and is not swallowed, because the helper uses a kindIs "invalid" nil check rather than default.
xxx.resources.limitFactor number 3 Memory and ephemeral-storage limits are computed as factor × request.
xxx.resources.requests object cpu: 0.1, memory: 512Mi, ephemeral-storage: 10Mi
xxx.resources.limits object unset An explicit limit always wins. A CPU limit is only rendered when set here.
xxx.livenessProbe object GET / on port http, period 10s, timeout 5s, 3 failures Placeholder — point it at a real endpoint.
xxx.readinessProbe object GET / on port http, period 10s, timeout 5s, 3 failures
xxx.startupProbe object GET / on port http, period 5s, 30 failures (= 150s) Gates liveness and readiness, so neither needs an initialDelaySeconds.
xxx.securityContext.pod object non-root 20000:1000, fsGroup: 1000, fsGroupChangePolicy: Always, seccompProfile: RuntimeDefault Passed into the pod spec. The seccomp profile sits here on purpose: it covers every container including init containers.
xxx.securityContext.container object readOnlyRootFilesystem: true, capabilities.drop: [ALL], add: [], no privilege escalation, non-root The baseline. An image-forced deviation MUST be documented with a comment in the chart's own values.yaml.
xxx.ports list one entry: containerPort: 8080, protocol: TCP, name: http Rendered verbatim as the container's ports. Unprivileged on purpose — see Why 8080 and not 80.
xxx.env list three example entries (NORMAL_ENV, ENV_FROM_SECRET, ENV_FROM_CONFIGMAP) The three supported forms. Rendered through tpl. Replace the whole block rather than repairing the example.

Probe, securityContext and resources blocks may be erased (null) — the chart then falls back to the defaults in _helpers.tpl instead of rendering an unhardened, unprobed or unaccounted container.

storage

Key Type Default Description
storage.amount string 10Gi Size of the PVC.
storage.storageClass string null Storage class of the PVC; null omits the field, so the cluster default applies.

ingress

Key Type Default Description
ingress.enabled bool true Gates the whole Ingress template. false also makes clusterIssuer unnecessary — but not domain, which the env block reads as well.
ingress.clusterIssuer string null Required while the Ingress is enabled. cert-manager ClusterIssuer.
ingress.domain string null Required in the scaffold even with the Ingress off, because NORMAL_ENV reads it. Whether that holds in a copy depends on what the copy does with the value.
ingress.className string nginx Ingress class.
ingress.annotations object {} Merged with the cert-manager annotation the chart always sets. Annotations are interpreted by the ingress controller, so a misspelled key is silently ignored — it does not fail the render.

TLS is stored in tls-<release>-xxx-ingress.

httpRoute

The Gateway-API alternative, off by default and mutually exclusive with ingress.enabled — with both true the render aborts naming both values. templates/xxx.httproute.yaml is the reference implementation of the convention; the whole argument is in style guide §4.

Key Type Default Description
httpRoute.enabled bool false Renders <release>-xxx-httproute instead of the Ingress.
httpRoute.parentRefs list [] Gateway API ParentReference entries, passed through unchanged. Required while enabled: the render aborts on an empty list, because a route without a parent attaches to nothing and reports nothing. The schema requires name per entry and stays otherwise open — Kubernetes owns that schema.
httpRoute.hostnames list [] Empty means [ingress.domain], one source of truth for the hostname.

The only thing to adapt when copying the file is the backend: the Service name follows the naming scheme, but backendRefs takes a port number and cannot reference a port by name the way the Ingress backend does — so the number has to match <app>.service.yaml by hand, and ci/run-httproute-match-test.sh is what holds that pair on every PR.

Note also what the scaffold demonstrates on purpose: ingress.domain stays required in Gateway mode here, because the env block reads it for NORMAL_ENV. That is a property of this chart, not of the convention — check it per chart (§12.1).

networkPolicy

The reference implementation of the convention from Gitea #5: templates/xxx.networkpolicy.yaml, plus the values block, the schema entry and the NOTES.txt section that go with it. networkPolicy.enabled defaults to true, so a chart copied from here ships a policy without anyone having to remember one — which is the whole point: a policy that ships switched off is reached only when somebody switches it on, and then the issue is closed while nothing is filtered.

Key Type Default Description
networkPolicy.enabled bool true Renders the policy. Only a boundary where the CNI enforces one — unenforced, the API server accepts it and nothing is filtered.
networkPolicy.allowedNamespaces list [] Namespaces allowed in addition to the release's own. Namespace-granular: an entry opens the port to every pod in it.
networkPolicy.probeSources list [] CIDRs allowed to reach the probed port. Empty means open, not blocked.
networkPolicy.ingressController object kube-system / app.kubernetes.io/name: rke2-traefik The controller allowed in once probeSources restricts the rule. Its two keys render as one from entry, i.e. an AND.
networkPolicy.egress.extraPorts list [] Additional outbound TCP ports, to any destination.

What to adapt when copying, and it is more than renaming xxx: classify every port of your workload first, because the class decides the rule — a port a network probe addresses stays open, a NodePort-published port stays open, anything else is restricted by source, and a replica-to-replica bus gets a bare podSelector. The scaffold shows only the first of those four, because it has only one port. keycloak and garage are the worked examples of all of them; the four classes are in style guide §15.

Then drop the values your chart does not use. A chart whose every inbound rule is open to any source has no use for allowedNamespaces or probeSources, and a value that silently reaches no rule is worse than one that is absent, because it reads as a control — samba and satisfactory ship enabled alone for exactly that reason. The scaffold cannot know which of them your chart needs, so it ships all of them.

And note which of the two comment styles this file uses: every comment in it is a Go template comment. A # comment is part of the rendered object — it travels into the cluster, appears in helm get manifest, and lands in every release diff — and ci/run-networkpolicy-test.sh fails on one. The header of the file also carries the second-order trap found while writing it: prose inside a template comment must not spell out the comment's own closing delimiter, or the parser ends the comment early and the remaining prose is emitted as manifest content.

Traps the scaffold carries into the copy

Why 8080 and not 80

Not because a non-root container cannot bind port 80 — it can: the kubelet sets net.ipv4.ip_unprivileged_port_start=0 in the pod network namespace, and this was measured in k3d (k3s v1.35.5) with runAsUser: 20000 and all capabilities dropped. Anyone who "fixes" it back to 80 and tests it once will find that it works, which is exactly why the reason is written down: that sysctl is a runtime default, not a guarantee, and a cluster that restores ip_unprivileged_port_start=1024 breaks every workload that copied the 80 — with the failure appearing in the copied chart, not here. Every real chart in this repo listens above 1024; samba is the one exception and adds NET_BIND_SERVICE with a documented justification. Note also that containerPort is declarative: point the application at the same port.

value: must be quoted

value: {{ tpl $value.value $ | quote }} — the quote is required, not cosmetic. A value like "8080" or "true" renders unquoted as a YAML number or bool, and the API server then rejects the whole manifest with cannot unmarshal number into Go struct field EnvVar.value of type string. Every chart in this repo quotes here.

The checksum/config annotation is not decoration

Both workload templates carry checksum/config over the rendered ConfigMap, and it is mandatory (style guide §9): a subPath mount is never refreshed by the kubelet and most apps read their config only at start, so without it a config change applies to nothing while everything stays green. The hash is content-based, so an unchanged config stays a no-op upgrade. One line per ConfigMap the workload consumes, with a distinguishing suffix where there are several. A chart that also wants a repointed secret to take effect adds the separately named checksum/secret-ref over the rendered OnePasswordItem — the scaffold does not, so in it neither editing the 1Password item nor repointing secrets.xxxSecretRef rolls the pods.

The ConfigMap key is called example-setting

Not key. A ConfigMap key literally called key matches the patterns secret scanners use to find hardcoded credentials, so every copy of the older template inherited a false positive.

Never wrap the helper includes in an if

Probes, resources and both security contexts go through the null-safe helpers. A guard around them hides the erased case completely instead of rendering a visible null — which is the failure mode the helpers exist to prevent: a workload that starts and reports healthy while being unhardened.

The defaults exist twice, and must stay in sync

values.yaml is the documented, user-facing place for probe, hardening and resource defaults; the copies inside _helpers.tpl are only the fallback for an erased block. They have to say the same thing — and in a copy they have to say what your chart needs, not what the repo baseline says. Five charts deviate from the hardening baseline on purpose, and "cleaning up" such a default is not tidying but an outage (style guide §12.4, Lessons learned).

Check the PVC claim before copying its wording

The scaffold's NOTES.txt states that helm uninstall does not delete the PVC. The template carries no helm.sh/resource-policy: keep annotation, and satisfactory's NOTES.txt says the opposite about the same construction. Verify the behaviour for your chart before repeating either sentence — an operator plans a backup around it.

CI

  • Excluded from the install-test, with the justification recorded in ci/excluded-charts.txt. An excluded chart is dropped from the matrix rather than skipped inside a job, so nothing ever reports a green install for it; the PR's job summary lists it with that justification instead (CI/CD).
  • Exempt from the chart-version bump check in validate-charts.yml — the scaffold's version means nothing, so changing it is not required to change the files.
  • Still linted and packaged like every other chart when a PR touches it, and always covered by the resource-quantity render test, which runs over every chart on every PR precisely because the resources helper travels by copy: a regression arrives in a new chart started from an older scaffold, and in that PR the copied bug is in no diff anyone reads.
  • Outside Renovate's reach: renovate.json5 sets ignorePaths: ["template-chart/**"], because the image is a literal placeholder.
  • Never published: no .github/workflows/publish-template-chart.yml exists, so no push to main uploads it to the registry.

See also