
A Helm chart is code that generates Kubernetes manifests, and it can generate insecure ones: containers running as root, no resource limits, writable root filesystems, missing probes. Reviewing templates by eye does not scale — a chart with a dozen conditionals and three values files can render a dozen different ways, and the dangerous combination is rarely the default one. A better approach is to lint every chart automatically in CI and fail the pull request when it introduces a misconfiguration.
In this tutorial you will combine three tools, each catching a different class of problem, wire them into GitHub Actions, see a real insecure-to-fixed example, and set up a local pre-commit hook so problems surface before a PR is even opened.
| Tool | What it checks | Operates on |
|---|---|---|
helm lint | Chart structure: required fields, template syntax, malformed values | The chart source |
| kube-linter | Kubernetes best practices: missing probes, no limits, risky securityContext | Rendered manifests |
| Checkov | Broader security policy set: CIS benchmarks, RBAC, network policy gaps | Rendered manifests |
The overlap between kube-linter and Checkov is intentional and useful — each has checks the other lacks, so running both is cheap insurance, not duplicated work.
Step 1: Structural checks with helm lint
Start with the basics. helm lint catches broken templates, missing required fields and malformed values before anything else runs.
helm lint ./charts/myapp --strict -f ./charts/myapp/values.yaml
--strict turns warnings into failures. Note that this only validates the chart itself, not whether the resulting workload is secure, which is why the next two tools matter.
Step 2: A concrete example — insecure defaults vs. hardened defaults
Before running the scanners, it helps to see exactly what they're built to catch. Here is a typical first-draft Deployment template:
# templates/deployment.yaml — before
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
spec:
replicas: {{ .Values.replicaCount }}
template:
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: {{ .Values.service.port }}
Nothing here is syntactically wrong — helm lint passes it. But it has at least five findings a security scanner will flag: no securityContext (runs as root by default), no resource requests/limits (one pod can starve the node), no liveness or readiness probe, an image tag that's almost certainly latest in a starter chart, and no readOnlyRootFilesystem. Fixed, driven from values.yaml so every consumer of the chart gets the hardened defaults automatically:
# templates/deployment.yaml — after
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
spec:
replicas: {{ .Values.replicaCount }}
template:
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: {{ .Values.service.port }}
securityContext:
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: {{ .Values.resources.requests.cpu }}
memory: {{ .Values.resources.requests.memory }}
limits:
cpu: {{ .Values.resources.limits.cpu }}
memory: {{ .Values.resources.limits.memory }}
livenessProbe:
httpGet:
path: /healthz
port: {{ .Values.service.port }}
initialDelaySeconds: 5
readinessProbe:
httpGet:
path: /ready
port: {{ .Values.service.port }}
initialDelaySeconds: 5
# values.yaml — pin a real tag, never latest, and set sane defaults
image:
repository: myorg/myapp
tag: "1.4.2"
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
This is exactly the shape of thing kube-linter and Checkov are built to catch automatically, across every template in every chart, every time someone opens a PR.
Step 3: Best-practice checks with kube-linter
kube-linter understands Helm charts directly and renders them with your values before checking.
kube-linter lint ./charts/myapp
Against the "before" template above, it reports findings close to this:
KubeLinter 0.6.x
templates/deployment.yaml: (object: <unnamed> apps/v1, Kind=Deployment myapp)
no-read-only-root-fs: container "myapp" does not have a read-only root file system
run-as-non-root: container "myapp" is not set to runAsNonRoot
unset-cpu-requirements: container "myapp" has cpu request and/or limit not set
unset-memory-requirements: container "myapp" has memory request and/or limit not set
latest-tag: image is using the "latest" tag, which is mutable and can lead to unpredictable behavior
no-liveness-probe: container "myapp" does not specify a livenessProbe
no-readiness-probe: container "myapp" does not specify a readinessProbe
To tune which checks run for your team, add a .kube-linter.yaml file:
checks:
addAllBuiltIn: true
exclude:
- "no-anti-affinity" # example: not required for single-replica dev workloads
When one workload legitimately needs an exception, annotate that object in the template instead of disabling the check globally:
metadata:
annotations:
ignore-check.kube-linter.io/no-read-only-root-fs: "app writes temp files to /data"
Writing the reason into the annotation keeps exceptions reviewable in the diff, instead of silently disappearing into a config file nobody reads again.
Step 4: Security policy checks with Checkov
Checkov ships a large library of Kubernetes security policies and has a Helm framework that renders the chart itself.
checkov -d ./charts/myapp --framework helm
Each failure includes a check ID, the resource, and the offending lines. Some of the Kubernetes checks that come up constantly on a first run — exact IDs can shift slightly between Checkov versions, so always read the ID from your own output rather than hardcoding these:
| Check ID (typical) | What it means |
|---|---|
| CKV_K8S_8 | Liveness probe should be configured |
| CKV_K8S_9 | Readiness probe should be configured |
| CKV_K8S_20 | Containers should not run as root |
| CKV_K8S_22 | Root filesystem should be read-only where possible |
| CKV_K8S_43 | Image should be pinned by digest, not a mutable tag |
To skip a check you have consciously accepted, pass its ID:
checkov -d ./charts/myapp --framework helm --skip-check CKV_K8S_43
Replace the ID with the real one from your output, and record why it is skipped — a one-line comment above the flag in your CI config is enough.
Step 5: Run everything in GitHub Actions
name: helm-security-lint
on:
pull_request:
paths:
- "charts/**"
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v4
- name: helm lint
run: helm lint ./charts/myapp --strict
- name: kube-linter
uses: stackrox/kube-linter-action@v1
with:
directory: charts/myapp
config: .kube-linter.yaml
- name: Checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: charts/myapp
framework: helm
soft_fail: false
The paths filter keeps the job from running on unrelated changes. Because soft_fail is false, a failing check blocks the merge once you mark this job as a required status check in the repository's branch protection settings.
Step 6: Lint each environment's values separately
A chart can be safe with its default values and unsafe with a production override — a values file that drops a resource limit, re-enables hostNetwork, or widens an RBAC rule for a "just this once" debugging session is exactly the kind of change that should trip a scanner. Run the scanners against every values file you actually deploy with, not just the chart defaults:
for env in dev staging prod; do
helm template myapp ./charts/myapp -f values-$env.yaml > rendered-$env.yaml
kube-linter lint rendered-$env.yaml
checkov -f rendered-$env.yaml --framework kubernetes
done
Step 7: Catch it before the PR — a local pre-commit hook
CI is the backstop, not the first line of defense. A local hook using the pre-commit framework runs the same three tools before a commit is even made, so the feedback loop is seconds, not a CI queue:
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: helm-lint
name: helm lint
entry: helm lint ./charts/myapp --strict
language: system
pass_filenames: false
files: ^charts/
- id: kube-linter
name: kube-linter
entry: kube-linter lint ./charts/myapp
language: system
pass_filenames: false
files: ^charts/
- id: checkov-helm
name: checkov (helm)
entry: checkov -d ./charts/myapp --framework helm --compact
language: system
pass_filenames: false
files: ^charts/
These use language: system because the hooks shell out to CLIs that are already installed, rather than asking pre-commit to manage a separate environment for each. Run pre-commit install once per clone, and every commit touching charts/ runs all three automatically.
Rolling this out without friction
- Start in report-only mode. Run the checks with failures allowed for a week to see the baseline, then fix or consciously exclude each finding.
- Fix the chart defaults first. Setting a secure
securityContextand resource limits invalues.yaml— as in the worked example above — clears most findings for every consumer of the chart at once, rather than fixing one deployment's template at a time. - Make exceptions explicit. Use per-object annotations or documented skip lists with a reason attached, never a blanket disable.
- Then make it required. Once the baseline is clean, enforce the job as a required check, and add the pre-commit hook so most violations never reach CI at all.
Where to go next
Linting catches problems before merge, but it cannot stop someone deploying a manifest by hand, or a chart that bypassed this pipeline entirely. Pair it with an admission policy engine such as Kyverno so the cluster enforces the same baseline — no probes, no limits, no root — at runtime, regardless of how the manifest got there.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


