Creating a new chart
Copy template-chart/, replace every xxx, adapt what the template cannot know, then wire up CI. The order matters: the wiring steps are the ones that fail silently if forgotten.
1. Copy the scaffold and replace xxx
cp -r template-chart <chart-name>
xxx appears in five places, and missing one of them leaves a chart that renders but does not do what it says:
- template file names —
templates/xxx.deployment.yaml→templates/<app>.deployment.yaml Chart.yaml— name, description,icon:(mandatory), version, appVersionvalues.yaml— the top-level keys- the templates — every
.Values.xxx.*reference values.schema.json— both the values paths and thexxx.helper prefixes
2. Adapt what the template cannot know
The schema and the null-safe fallback helpers come with the template, but their content is chart-specific and wrong until adapted. Walk the six points of §12 Values that must not lie:
- Does anything outside the Ingress read
ingress.domain? Then that read needs its ownrequired(). - Probes, security contexts and resources go through the null-safe helper — never behind an
if. - Liveness must not hang on an external dependency where a dependency-free path exists; readiness must hang on it, and a separate path is not proof that it does.
- The fallbacks reproduce this chart's defaults, deviations included, not the repo standard.
- Probe defaults carry the probe's context — port names,
Hostheaders,execcommands. - A value that reads as the negation of the variable it feeds must actually be negated, and Go's
notcannot do it.
Validate the schema against the chart's real bundle values, not only against its defaults. Defaults are valid by construction, so they prove nothing.
templates/NOTES.txt also comes as a worked example and is placeholder text until replaced. Its leading {{/* … */}} block explains the bar and is meant to be deleted. What it must print: 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.
3. Wire up CI — four steps, each silent when skipped
.github/workflows/publish-<chart-name>.yml— copy an existing one (e.g.publish-outline.yml). It triggers on pushes tomaintouching<chart-name>/**and callspublish-helm-chart.yml, which lives inworkflow-util/pipelinesand is bound to a tag. Copy, do not write from memory: theuses:path, the tag,registry-owner: ${{ github.repository_owner }}andsecrets: inheritall have to be right, and a hull missing the owner input fails only when the publish actually runs. Without the file at all the chart is never published, and nothing complains. See CI/CD..github/workflows/change-app-version.yml— add the chart to thechart-namechoice list.ci/<chart-name>/— install-test fixtures:test-values.yaml(required), plusfixtures.yaml,fixtures.env.yamlandsettings.envwhere the chart needs them. Alternatively an entry with justification inci/excluded-charts.txt. The install-test fails if both are missing. See CI/CD. If the chart offers Gateway mode (§4), either the fixture installs in that mode or the chart gets a render-only overlay atci/httproute/<chart-name>.yaml— without one of the two thehttproute-matchjob is green over nothing for it, which that job fails on.renovate.json5— acustomManagersblock for the chart's main image. Without it the app version is never updated. Check the image's tag list first: a flavour, date or prerelease suffix needs aversioningTemplatewith a comment saying why, and floating tags likelatest/stablebelong outside the pattern. See Renovate.
4. Only if the chart needs a dependency
Read §14 Chart dependencies first — it is a bar, not a formality. Nothing has to be added to the pipelines (ci/helm-dependencies.sh already runs everywhere and keys off the dependencies: block), but the subchart's updates then depend entirely on Renovate's built-in helmv3 manager plus a chart-version bump. Write that down in a comment at the pinned version.
5. Validate before opening the PR
helm lint --strict <chart-name>
helm template <chart-name> --set ingress.domain=example.com --set ingress.clusterIssuer=letsencrypt
Required values vary per chart, so the second command usually needs more --set flags. Then verify at runtime in a throwaway k3d cluster — see Local development.
6. Document it
A new chart needs a wiki page in the same round of work: copy the structure of outline, add a row to the chart index and an entry to _Sidebar.md. A chart that exists without a page is one nobody else can install without reading its templates.
Checklist
- every
xxxreplaced, file names included icon:set inChart.yamlvalues.schema.jsonadapted,additionalProperties: falseon every chart-owned level- null-safe fallbacks reproduce this chart's own defaults
- probes: liveness, readiness, startup, with calibrated budgets
- hardening defaults, every deviation commented in
values.yaml replicasper workload and the globalscaleDownchecksum/configon every pod template consuming a ConfigMap- additive
envblock per container, rendered throughtpl NOTES.txtrewritten, no real domain- publish workflow added
change-app-version.ymlchoice list extended- CI fixtures or a justified exclusion
- if the chart offers Gateway mode: its fixture installs in that mode, or
ci/httproute/<chart>.yamlexists renovate.json5block addedhelm lint --strictclean, render verified, k3d install verified- wiki page, chart index row, sidebar entry
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