Linting, testing & schemas
helm lint, test, values schema.
Before a car leaves the assembly line it gets three different checks. An inspector reads the build sheet to make sure nothing is missing. A gate on the line rejects any bolt that is the wrong size before it ever goes on. And at the end, someone turns the key to confirm the engine actually starts. A Helm chart (a packaged bundle of Kubernetes config templates) deserves the same three checks, and Helm gives you one tool for each. helm lint reads the chart on paper. A values schema rejects bad inputs before they reach the cluster (the pool of machines Kubernetes runs your workloads on). helm test turns the key on a running release. Each catches a different class of mistake, and none of them covers for the others.
Where each check runs matters as much as what it does. Lint and schema validation are cheap and need no cluster, so they belong on every pull request (a proposed code change waiting to be reviewed and merged), where they answer in seconds and can block a bad change before anyone approves it. helm test needs a real, running release (one installed, named instance of a chart), so it runs after you deploy, as a smoke test (a quick once-over to confirm the basic thing works at all, named for the old trick of powering on a device and watching for smoke) against the live workload. Get the order right and most mistakes die in a fast feedback loop instead of at 2 a.m. in production.
helm lint: read the chart before it runs
helm lint is the inspector reading the build sheet. It parses Chart.yaml (the file that names and versions your chart), walks every template, renders those templates with your values, and flags anything wrong on paper: malformed YAML (YAML is the indentation-based text format Kubernetes config is written in), a missing required field like the chart name or version, a version string that is not valid semantic versioning (the MAJOR.MINOR.PATCH numbering scheme, like 1.4.2), a missing icon, or a template that fails to render at all. It reports findings at three levels: INFO for suggestions, WARNING for likely problems, and ERROR for hard failures.
By default the command only fails on ERROR, so INFO and WARNING are advisory. Add --strict and warnings fail too, which is what you want in CI (continuous integration, the automated checks that run on every proposed change) so style problems can't quietly rot. Point it at the exact values a given environment uses with -f, and lint walks the same code path your users will hit instead of the built-in defaults. --with-subcharts lints bundled dependency charts in the same pass. And because a values schema is enforced during lint, a single helm lint run checks your inputs at the same time.
# check the chart on paper: render every template, no cluster needed.# --strict fails on warnings too, so CI can block on them.helm lint ./mychart --strict -f values-prod.yaml
==> Linting ./mychart[INFO] Chart.yaml: icon is recommended1 chart(s) linted, 0 chart(s) failed
Break a template and the level jumps straight to ERROR.
# someone fat-fingered a colon in the deployment templatehelm lint ./mychart
==> Linting ./mychart[ERROR] templates/deployment.yaml: unable to parse YAML: error converting YAML to JSON: yaml: line 22: mapping values are not allowed in this context[INFO] Chart.yaml: icon is recommendedError: 1 chart(s) linted, 1 chart(s) failed
The command exits non-zero on failure (echo $? prints 1), which is the hook CI keys on to fail the job. For a defender, lint is also a cheap first look at a chart you pulled from a public repository: it renders every template, so you can see what the chart intends to create and catch broken or never-tested manifests (a manifest is the YAML description of one Kubernetes object) before you point them at a real cluster.
values.schema.json: a contract for what people can pass in
A template will happily render nonsense. Pass replicaCount: "two" or forget image.tag and the engine produces a broken manifest without complaint, because to a template every value is only text to drop into a slot. A values schema fixes that. Think of it as a paper form with required fields and dropdown menus: you cannot write 'twelve' in a box that wants a number, and you cannot leave a required line blank. Drop a file named values.schema.json in the chart root and Helm enforces it automatically on install, upgrade, lint, and template.
The file is written in JSON Schema, draft-07. JSON (JavaScript Object Notation) is a plain-text format for structured data; JSON Schema is a standard, machine-checkable description of the shape that data must have. Helm validates the fully coalesced values, meaning your values.yaml defaults merged with every -f file and --set override, against the schema, and aborts with a readable message before a single manifest reaches the API server (the Kubernetes control-plane component that accepts and stores every object). Use required to make fields mandatory, type to pin a field to integer or string or object, enum to limit a field to a fixed set of allowed values, and minimum/maximum for numeric bounds.
{"$schema": "https://json-schema.org/draft-07/schema#","type": "object","additionalProperties": false,"required": ["image", "replicaCount"],"properties": {"replicaCount": { "type": "integer", "minimum": 1 },"image": {"type": "object","additionalProperties": false,"required": ["repository", "tag"],"properties": {"repository": {"type": "string","pattern": "^registry\\.internal\\.example\\.com/"},"tag": { "type": "string" },"pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] }}},"service": {"type": "object","properties": {"port": { "type": "integer", "minimum": 1, "maximum": 65535 }}}}}
# pass a string where the schema demands an integerhelm install web ./mychart --set replicaCount=two
Error: values don't meet the specifications of the schema(s) in the following chart(s):mychart:- replicaCount: Invalid type. Expected: integer, given: string
That same failure fires during helm template, with no cluster involved, so the identical guard runs in a pull-request check and again at install time.
Turning the schema into a security control
The schema is the one place you can guarantee an input contract instead of hoping every template guards every field, and that is what makes it a real hardening tool. Add a pattern (a regular expression the value must match) to force images to come from an approved registry, so nobody can point the chart at docker.io and pull an unreviewed image into your namespace (a named partition inside a cluster that isolates one group of objects from another). Escape the dots in the hostname, writing ^registry\.internal\.example\.com/ rather than leaving bare dots, because in a regular expression a bare . matches any character and a lookalike host would otherwise slip through the check.
# try to install from a public registry the schema doesn't allowhelm install web ./mychart --set image.repository=docker.io/library/nginx
Error: values don't meet the specifications of the schema(s) in the following chart(s):mychart:- image.repository: Does not match pattern '^registry\.internal\.example\.com/'
You can go further and set additionalProperties: false so a mistyped key like replicaCnt fails loudly instead of silently doing nothing. A key that silently does nothing is exactly what leaves a security setting sitting at its unsafe default, because the operator believes they turned it on.
values.yaml, every -f file, --set flags, the built-in global block, and each subchart's values. With additionalProperties: false at the root you must declare every top-level key you expect, including global and the name of every subchart (redis, postgresql, and so on), or a valid install starts failing with an 'Additional property ... is not allowed' error. Turn the strict flag on deliberately, then run helm template against your real values to confirm nothing legitimate trips it.helm test: prove the release actually works
A chart can install cleanly and still be broken: a wrong port, a bad readiness probe (the health check Kubernetes uses to decide a pod can take traffic), a Service (the stable network name that sits in front of your pods and load-balances to them) that resolves to nothing. Lint and schema can't catch these because they only read the chart, never the running system. helm test turns the key. You define a Pod (the smallest unit Kubernetes runs, one or more containers sharing a network address) or a Job (a Kubernetes object that runs a pod once, to completion) under templates/tests/, mark it with the annotation helm.sh/hook: test (an annotation is a key Helm reads to treat the resource specially), and after installing you run helm test on the release name. Helm creates each test resource, waits for it to finish, and reports success only if the container exits 0.
apiVersion: v1kind: Podmetadata:name: "{{ include "mychart.fullname" . }}-test-connection"labels:{{- include "mychart.labels" . | nindent 4 }}annotations:"helm.sh/hook": test"helm.sh/hook-delete-policy": before-hook-creation,hook-succeededspec:restartPolicy: Nevercontainers:- name: wgetimage: busybox:1.36command: ['wget']args: ['{{ include "mychart.fullname" . }}:{{ .Values.service.port }}']
A classic smoke test uses wget (a small command-line tool that fetches a URL) to hit the Service on its configured port, proving the app answers where it should. Run it with --logs and Helm streams the container output, so a failure tells you why, not merely that it failed. --filter name=web-test-connection runs a single named test, and --timeout 5m bounds how long Helm waits before giving up.
# the release is already installed; run the live smoke testhelm test web --logs
NAME: webLAST DEPLOYED: Fri Jul 17 09:14:02 2026NAMESPACE: defaultSTATUS: deployedREVISION: 1TEST SUITE: web-test-connectionLast Started: Fri Jul 17 09:15:20 2026Last Completed: Fri Jul 17 09:15:24 2026Phase: SucceededPOD LOGS: web-test-connectionConnecting to web:80 (10.96.44.180:80)saving to 'index.html'index.html 100% |********************************| 615 0:00:00 ETA'index.html' saved
If the container exits non-zero, the phase reads Failed and helm test returns an error. Your deploy job can treat that exactly like a failed rollout and roll back to the previous revision, so a broken release never sits live.
helm test collides with the leftover Pod and fails with Error: pods "web-test-connection" already exists, and stale pods clutter the namespace over time. Set helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded so Helm removes the old pod before each run and cleans up after a pass. Remember too that helm test needs a live release: it does nothing under helm template or a pure dry-run (a practice run that renders everything but changes nothing in the cluster), so it is a post-deploy gate, never a stand-in for the lint and schema checks that run in CI before anything reaches the cluster.helm lint ./mychart with no extra flags. It prints one WARNING and several INFO lines and no ERROR. What happens?"additionalProperties": false at the root of values.schema.json. Lint passes locally, but installing with your production values now fails with Additional property redis is not allowed. What is going on?Make lint and schema a required check on the pull request so a wrong value or a broken template fails in seconds, before review. Run helm test --logs in the deploy job right after helm upgrade --install, and wire its exit code to your rollback. The aim is boring on purpose: every failure that can be found on paper is found on paper, and the one check that needs a live cluster runs the moment you have one.
Try this
Run helm lint ./mychart --strict -f values-prod.yaml on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: additionalProperties: false validates every coalesced key, including ones you didn't write. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.