Helm Hooks and Chart Tests: Lifecycle Management Done Right
Helm hooks and chart tests are two features that most people underuse — or get wrong when they first try them. Hooks let you run Jobs at specific points in a release lifecycle. Tests let you validate that a release actually works after deployment. Together, they turn your Helm chart from a "deploy and hope" operation into something with guardrails.
Understanding Helm Hooks
A hook is just a Kubernetes manifest with a special annotation that tells Helm when to create it and what to do with it after it runs. The lifecycle points are:
| Hook | When It Runs |
|---|---|
pre-install | Before any resources are created on first install |
post-install | After all resources are created on first install |
pre-upgrade | Before any resources are updated on upgrade |
post-upgrade | After all resources are updated on upgrade |
pre-delete | Before any resources are deleted |
post-delete | After all resources are deleted |
pre-rollback | Before rollback |
post-rollback | After rollback |
The most common real-world use case is running database migrations before your app starts. Let's build that properly.
Database Migration Hook
# templates/migrations-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "myapp.fullname" . }}-migrations
labels:
{{- include "myapp.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 3
ttlSecondsAfterFinished: 300
template:
metadata:
name: {{ include "myapp.fullname" . }}-migrations
spec:
restartPolicy: Never
containers:
- name: migrations
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
command: ["./run-migrations.sh"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "myapp.fullname" . }}-secret
key: database-url
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
Three annotations are doing the work here:
helm.sh/hook — registers this as a hook and specifies when it runs. You can target multiple events by comma-separating them.
helm.sh/hook-weight — controls ordering when you have multiple hooks at the same lifecycle point. Lower numbers run first. The range is -100 to 100.
helm.sh/hook-delete-policy — what to do with the Job resource after it runs:
before-hook-creation: delete any previous instance before creating the new onehook-succeeded: delete after successhook-failed: delete after failure (useful to keep failed jobs for debugging — omit this one)
The combination of before-hook-creation,hook-succeeded is what I use in production: clean up old successful runs, but keep failed ones around so you can inspect logs.
Hook Sequencing with Weights
When you have multiple hooks at the same lifecycle point — say, a schema migration and a seed data loader — weights control the order:
# First: create the database schema
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-10"
# Second: run seed data (only on install)
"helm.sh/hook": pre-install
"helm.sh/hook-weight": "-5"
# Third: warm the cache
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-weight": "5"
Helm processes hooks with the same lifecycle tag in ascending weight order. Hooks at the same weight run in parallel (no guaranteed order between them), so give them distinct weights if sequencing matters.
Handling Hook Failures
By default, if a hook Job fails, Helm marks the release as failed and stops. This is usually what you want — you don't want the app to start if migrations failed. But you need to verify what "failure" means to Helm:
- The Job's Pod must exit with code 0 for the hook to succeed
- If the Job hits
backoffLimit, Helm sees it as a hook failure - The
--timeoutflag onhelm install/upgradeapplies to hooks too
For long-running migrations, increase the timeout:
helm upgrade myapp ./charts/myapp \
--timeout 10m \
--wait \
--atomic
The --atomic flag is important: if anything fails (hooks or resources), it automatically rolls back to the last successful state. Combine it with --wait to ensure pods are actually running before marking success.
Chart Tests
Chart tests are hooks with the helm.sh/hook: test annotation. They run when you explicitly call helm test. The idea is to validate that the deployed app is working — not just that the Kubernetes objects exist, but that the service actually responds.
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: {{ include "myapp.fullname" . }}-test-connection
labels:
{{- include "myapp.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args:
- '--spider'
- '--timeout=10'
- 'http://{{ include "myapp.fullname" . }}:{{ .Values.service.port }}/health'
restartPolicy: Never
Run it after deployment:
helm test myapp --logs
The --logs flag streams the Pod's output, which is essential for debugging test failures.
More Realistic Test Examples
A basic connectivity test is fine, but you can go further. Here's a test that validates the API returns the right response:
# templates/tests/test-api-smoke.yaml
apiVersion: v1
kind: Pod
metadata:
name: {{ include "myapp.fullname" . }}-test-api
annotations:
"helm.sh/hook": test
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
restartPolicy: Never
containers:
- name: api-test
image: curlimages/curl:8.5.0
command:
- sh
- -c
- |
set -e
echo "Testing health endpoint..."
curl -sf http://{{ include "myapp.fullname" . }}:{{ .Values.service.port }}/health
echo "Testing API version..."
RESPONSE=$(curl -sf http://{{ include "myapp.fullname" . }}:{{ .Values.service.port }}/api/version)
echo "Response: $RESPONSE"
echo $RESPONSE | grep -q '"status":"ok"' || (echo "Unexpected response" && exit 1)
echo "All tests passed."
Multiple test Pods are perfectly valid — create one per concern:
templates/tests/
├── test-connection.yaml # basic TCP connectivity
├── test-api-smoke.yaml # API endpoints respond correctly
├── test-db-connectivity.yaml # app can reach the database
└── test-config-loaded.yaml # expected config values are present
Integrating Tests into CI/CD
This is where chart tests really earn their keep. In your deployment pipeline:
#!/bin/bash
set -e
RELEASE_NAME="myapp"
NAMESPACE="production"
CHART_PATH="./charts/myapp"
echo "Deploying release..."
helm upgrade --install "$RELEASE_NAME" "$CHART_PATH" \
--namespace "$NAMESPACE" \
--values "charts/myapp/values-production.yaml" \
--set "image.tag=${IMAGE_TAG}" \
--timeout 10m \
--wait \
--atomic
echo "Running chart tests..."
helm test "$RELEASE_NAME" \
--namespace "$NAMESPACE" \
--timeout 5m \
--logs
echo "Deployment and tests complete."
If the tests fail, the script exits non-zero and your CD system marks the deployment as failed. The --atomic flag on upgrade means the Helm release itself didn't roll back (that only happens if the upgrade itself failed), so you may want to add explicit rollback logic:
if ! helm test "$RELEASE_NAME" --namespace "$NAMESPACE" --timeout 5m; then
echo "Tests failed — rolling back"
helm rollback "$RELEASE_NAME" 0 --namespace "$NAMESPACE" # 0 = previous version
exit 1
fi
Debugging Hook and Test Failures
# List all hook resources for a release
kubectl get jobs,pods -l "helm.sh/chart=myapp" -n production
# Get logs from a failed migration job
kubectl logs -l "job-name=myapp-migrations" -n production --previous
# List completed test pods (if delete policy kept them)
kubectl get pods -l "helm.sh/chart=myapp" -n production
# Manually trigger hooks (useful for testing the hook itself)
# There's no direct way — you have to run the Job manually:
kubectl create job --from=cronjob/myapp migrations-test -n production
One gotcha: hook resources are not managed by Helm's regular release lifecycle unless you specify a delete policy. Without hook-delete-policy, old Job objects from previous hook runs pile up. Always set before-hook-creation at minimum.
Hooks and tests add real operational maturity to your Helm charts. The migration hook pattern alone has saved me from more than a few "why is the app broken after upgrade" incidents. The test suite integration means you get deployment verification for free, without wiring up a separate smoke test framework.
Was this article helpful?
CI/CD Engineering Lead
Automation evangelist who believes no deployment should require a human. I write pipelines, break pipelines, and write about both. Code-first, always.
Related Articles
Helm Chart Best Practices: Structure, Versioning, and Templating
Practical Helm chart best practices covering directory structure, versioning strategies, and templating patterns for production-grade charts.
Helmfile: Manage Multiple Helm Releases Like a Pro
Use Helmfile to declaratively manage multiple Helm releases across environments, with layered values, dependencies, and GitOps-friendly workflows.
Helm Error: Release Has No Deployed Revisions - How To Recover Failed Installations
If you've been working with Helm for any length of time, you've probably encountered this frustrating error: "Error: release has no deployed revisions." It...
Helm Error: Repository Name Already Exists - Complete Fix Guide For Duplicate Repo Names
If you've been working with Helm for more than five minutes, you've probably encountered this frustrating error: "repository name already exists". It's one...
Fix Helm 'Another Operation in Progress' Error
Resolve the Helm 'another operation (install/upgrade/rollback) is in progress' error caused by stuck releases with safe recovery steps.
Production-Ready Helm Charts: Templates, Values, Hooks, and Testing
Battle-tested patterns for writing Helm charts that survive production — covering values design, template structure, lifecycle hooks, and chart testing.