Table of Contents
- template-chart
- It is not installable
- What to replace, and in what order
- Configurable values
- Traps the scaffold carries into the copy
- Why 8080 and not 80
- value: must be quoted
- The checksum/config annotation is not decoration
- The ConfigMap key is called example-setting
- Never wrap the helper includes in an if
- The defaults exist twice, and must stay in sync
- Check the PVC claim before copying its wording
- CI
- See also
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:xxxdoes not exist, so the pods stay inImagePullBackOff. That is why the chart sits inci/excluded-charts.txt("scaffold with placeholder imageghcr.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
envblock demonstrates the three supported forms and is an example, not a configuration:ENV_FROM_CONFIGMAPnames<release>-xxx-config-mapwhile 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
- Copy the directory:
cp -r template-chart <chart-name>. Everything below happens inside the copy. Chart.yaml—name(this is thexxxin every rendered object name),description,icon(mandatory in every chart),versionandappVersion.appVersionis the exact image tag;versionis<chart-semver>+up<appVersion>with dashes normalized to dots (Versioning). The scaffold's4.2.0+upxxxis its own version and carries nothing into the copy.- 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). - Decide the workload shape and delete the other file. Stateful apps take
<app>.sfs.yaml; if you keep it, move the Service selector from…-deploymentto…-sfs. Deletexxx.pvc.yamlif the app needs no volume, andxxx.secret.yamlplus thesecretsblock if it needs no 1Password item. values.yaml: rename thexxx:top-level key to the app, renamesecrets.xxxSecretRef, and replace the placeholderenv,portsand probe content with the app's real ones.- The templates: every
.Values.xxx.*reference, and every helper call{{ include "xxx.<helper>" … }}. templates/_helpers.tpl: thexxx.prefix in eachdefine— 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.values.schema.json: both the values paths and thexxx.prefixes, then the content along the six points of style guide §12.- The image in the workload: replace host/org/image, keep the registry host — fully qualified on purpose, because an unqualified
registry/imageresolves to Docker Hub. Keep the tag as{{ .Chart.AppVersion }}. - 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, aHostheader or an exec command leaves the workload with a probe that cannot succeed. 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.comwhere an example is unavoidable.- Check where
ingress.domainis read. In the scaffold it is read outside the Ingress template twice — theNORMAL_ENVentry and theHTTPRoute's hostname fallback — which is why theenvread carries arequired()of its own; otherwise switching the Ingress off would render an empty address silently. Decide this per chart instead of assuming either way. - Decide the routing modes.
httpRouteships off, so a copy works unchanged in Ingress mode. If the app offers Gateway mode, say in itsNOTES.txtwhat that mode costs it — an upload limit set throughproxy-body-sizehas 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.json5setsignorePaths: ["template-chart/**"], because the image is a literal placeholder. - Never published: no
.github/workflows/publish-template-chart.ymlexists, so no push tomainuploads it to the registry.
See also
- Creating a new chart — the surrounding procedure, including the CI wiring
- Chart style guide — the rules this chart is the reference implementation of
- Versioning — the
+upscheme a new chart adopts - Renovate — the
customManagersblock a new chart needs - Chart index
helm-charts wiki
How we work
How it is built
How charts are written
Charts
- apache-tika
- bentopdf
- bulwark
- calibre-web-automated
- convertx
- cyberchef
- freesailarr
- garage
- gotenberg
- homeassistant
- keycloak
- matrix-synapse
- minecraft-router
- minecraft-server
- outline
- paperless-ngx
- patchmon
- rallly
- rss-bridge
- samba
- satisfactory
- searxng
- stalwart
- sure
- teamspeak-server
- template-chart
- urlaubsverwaltung
- valkey
- voucher-vault
- ytdlp-web-player
- zipline