CoursesHelmLinting, testing & schemas

Linting, testing & schemas

helm lint, test, values schema.

Advanced12 min · lesson 10 of 12

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.

terminal
# 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
output
==> Linting ./mychart
[INFO] Chart.yaml: icon is recommended
1 chart(s) linted, 0 chart(s) failed

Break a template and the level jumps straight to ERROR.

terminal
# someone fat-fingered a colon in the deployment template
helm lint ./mychart
output
==> 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 recommended
Error: 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.

values.schema.json
{
"$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 }
}
}
}
}
terminal
# pass a string where the schema demands an integer
helm install web ./mychart --set replicaCount=two
output
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.

terminal
# try to install from a public registry the schema doesn't allow
helm install web ./mychart --set image.repository=docker.io/library/nginx
output
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.

additionalProperties: false validates every coalesced key, including ones you didn't write
Helm checks the schema against the merged result of 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.

templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
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-succeeded
spec:
restartPolicy: Never
containers:
- name: wget
image: busybox:1.36
command: ['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.

terminal
# the release is already installed; run the live smoke test
helm test web --logs
output
NAME: web
LAST DEPLOYED: Fri Jul 17 09:14:02 2026
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: web-test-connection
Last Started: Fri Jul 17 09:15:20 2026
Last Completed: Fri Jul 17 09:15:24 2026
Phase: Succeeded
POD LOGS: web-test-connection
Connecting 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.

Test pods pile up and collide unless you set a delete policy
By default Helm leaves test Pods in the namespace after they run, successful and failed alike. Because the test resource has a fixed name, a second 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.
Three gates, three jobs
helm lint
When: every pull request
no cluster, seconds to run
Catches
bad YAML, missing Chart.yaml fields, templates that won't render
CI gate
--strict makes warnings fail; exit 1 blocks merge
values.schema.json
When: install, upgrade, lint, template
no cluster for template
Catches
wrong type, missing required, out-of-range, off-registry image
Contract
validates the coalesced values before the API server
helm test
When: after deploy
needs a live release
Catches
wrong port, bad probe, Service that resolves to nothing
Smoke test
container exits 0 = pass; --logs shows why it failed
Cheap, cluster-free checks (lint + schema) run before merge; the live smoke test runs once the release is up.
Quick check
01A chart installs cleanly, passes helm lint --strict, and satisfies values.schema.json, but nobody can reach the app because the Service points at the wrong port. Which check would have caught it?
Incorrect — Lint renders the templates and reads them as text, so a port number that parses correctly passes even when it points nowhere.
Incorrect — A range check proves the number is a legal port between 1 and 65535, and it stays happy whether you wrote 80 or 8080 in that slot.
Correct — The test pod fetches the Service on its configured port, so an unanswered request ends with a Failed phase and an error back from helm test.
Incorrect — Rendering prints YAML for a human to read and never fails a job on its own, and the port the app truly listens on lives inside the image.
02You run helm lint ./mychart with no extra flags. It prints one WARNING and several INFO lines and no ERROR. What happens?
Incorrect — A warning is advice on a plain run and the job carries on, which is why teams add --strict when they want style problems to block a merge.
Correct — The exit code is what CI keys on, and only an ERROR flips it unless you asked for --strict.
Incorrect — Lint does set an exit code: the broken-template run in this lesson ends with a failed chart and a status of 1, so grepping text is needless work.
Incorrect — The broken-template output still prints the INFO line about the missing icon after the ERROR, so lint works through the whole chart before it reports.
03You add "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?
Correct — Values from every source are folded together before the schema sees them, so list global alongside redis, postgresql and anything else you legitimately pass.
Incorrect — The rule is supported and it is doing exactly what you asked; the message names redis because redis is a real key that you never declared.
Incorrect — Flags and files land in the same merged result, so moving redis to --set changes nothing about what the schema is handed.
Incorrect — There is no exemption for global; turn the strict rule on and global has to be declared just like each subchart name.

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.

Related