Leveraging the release lifecycle: the pre/post-install, upgrade, rollback, and delete hooks concept; the helm.sh/hook, hook-weight, and hook-delete-policy annotations; implementing migration Jobs, backups, smoke tests, cleanup, and the best practices of idempotency and timeouts.

After episode 11, where we composed charts from components via dependencies and subcharts — and in episodes 3 through 5, where we learned installation, upgrade, and rollback — in this episode we cover what makes a release more than just a set of manifests: hooks, Helm's mechanism for running actions at critical points in a release's lifecycle.
Why does this matter? Think of a real database application. Before a new application version is deployed, the database schema must be migrated first — otherwise the new version that needs a new column will error as soon as it runs. After the application is deployed, teams want a smoke test to run before traffic is switched over. Before a release is deleted, temporary data may need cleanup. None of these actions can be represented by an ordinary Deployment or Service — they need a proper execution order relative to other resources, and they only run at specific points. Without hooks, teams have to manage migrations, backups, and smoke tests manually outside Helm — fragile and easy to miss. With hooks, that whole sequence becomes part of the chart's own definition, deterministic and documented. This is what makes Helm a true package manager, not just a templating engine.
A hook is an ordinary Kubernetes resource (usually a Job or Pod) marked with a special annotation. When Helm performs an event in a release's lifecycle, it looks for templates whose helm.sh/hook annotation matches that event, renders them, and executes them at the right time.
Helm defines ten types of hooks:
| Hook | When It Executes |
|---|---|
pre-install | After templates are rendered, before any resource is created |
post-install | After all release resources have been successfully created |
pre-delete | Before any resource is deleted |
post-delete | After all release resources have been deleted |
pre-upgrade | Before the release resources are upgraded |
post-upgrade | After all release resources have been successfully upgraded |
pre-rollback | Before the release resources are rolled back |
post-rollback | After all release resources have been successfully rolled back |
pre-crd-install | Before CRDs (Custom Resource Definitions) are installed |
test | When helm test runs (covered in episode 14) |
Think of hooks as middleware or lifecycle callbacks in a web framework: the main application doesn't care about the details, but every phase has an insertion point you can leverage. The difference is that Helm runs these insertion points with strict semantics — one failed hook means the operation fails.
A template becomes a hook simply by adding the helm.sh/hook annotation. Three annotations control its behavior:
helm.sh/hook — the comma-separated list of events that trigger this resource. Example: helm.sh/hook: pre-upgrade,post-install.helm.sh/hook-weight — an integer determining the execution order among hooks on the same event. The smaller the number, the earlier it runs. Defaults to 0, can be negative.helm.sh/hook-delete-policy — determines when the hook resource is deleted after execution. Available values: before-hook-creation (delete the old hook before creating a new one — useful for repeated upgrades), hook-succeeded (delete after success), hook-failed (delete after failure). They can be combined with commas.Hook resources aren't managed as part of the release like ordinary resources. They're created, executed, and deleted per the delete policy; on helm rollback or helm uninstall, old hooks aren't rolled back with them. That's why helm get manifest doesn't show hooks — use helm get hooks to see them.
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "myapp.fullname" . }}-migrate
annotations:
"helm.sh/hook": pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": hook-succeeded,before-hook-creation
"helm.sh/hook-ttl": "0"
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
command: ["/bin/sh", "-c", "npm run migrate -- --force"]Note two extra annotations above: helm.sh/hook-ttl tells Helm to clean up a finished Job after a given TTL (a Kubernetes feature), useful if the delete policy doesn't handle failure cases. And restartPolicy: Never — a Job must not use Always, because a Job that finished successfully must not restart.
Helm runs hooks differently depending on the resource type:
Succeeded) before continuing the main operation. If the Job Fails, the operation is considered failed and (depending on flags) can be auto-rolled back.Hook output is stored by Helm in a ConfigMap/Secret named <release>-<hook-name>.hooks — that's what lets you view hook logs after they finish with helm get hooks. And a hook failure is recorded in the release status: the release becomes failed, and helm list --failed will show it.
Warning
If a hook fails during an install, Helm doesn't automatically uninstall the hook if the delete policy doesn't handle it. A hanging Job will stay and consume resources. The combination of restartPolicy: Never, a reasonable backoffLimit, the right helm.sh/hook-delete-policy, and (if needed) helm.sh/hook-ttl is the standard defense against zombie Jobs. Always check with kubectl get jobs --all-namespaces after a failure.
Hooks become truly useful when applied to real needs. These five patterns are the most common in production:
The most classic pattern — and the example we've already seen. Before a new application version is deployed, run a schema migration. Important: migrations should be idempotent (safe to rerun), because pre-upgrade will run every time the chart is upgraded, and if a hook fails and then retries, the migration could run twice. Common strategies: add a flag like --force for idempotent migrations, or separate the migration script into the same container.
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "myapp.fullname" . }}-migrate
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": hook-succeeded,before-hook-creation
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ .Values.database.existingSecret }}
key: url
command: ["/bin/sh", "-c", "npx prisma migrate deploy"]"Better safe than sorry" in automated form. A hook with a small weight (-10) dumps the database to external storage before anything changes:
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "myapp.fullname" . }}-backup
annotations:
"helm.sh/hook": pre-upgrade
"helm.sh/hook-weight": "-10"
"helm.sh/hook-delete-policy": hook-succeeded
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: "postgres:16"
command: ["/bin/sh", "-c"]
args:
- |
pg_dump "$DATABASE_URL" | gzip | \
aws s3 cp - "s3://myapp-backups/$(date -u +%F).sql.gz"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ .Values.database.existingSecret }}
key: urlMaking sure the application actually responds before it's considered successful. This hook creates a Pod that curls the application endpoint:
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "myapp.fullname" . }}-smoke-test
annotations:
"helm.sh/hook": post-install
"helm.sh/hook-weight": "5"
"helm.sh/hook-delete-policy": hook-succeeded
spec:
template:
spec:
restartPolicy: Never
containers:
- name: smoke
image: curlimages/curl:latest
command:
- /bin/sh
- -c
- |
code=$(curl -s -o /dev/null -w "%{http_code}" \
http://{{ include "myapp.fullname" . }}:{{ .Values.service.port }}/healthz)
test "$code" = "200" || exit 1When a release is uninstalled, the main resources are deleted — but external data (for example, an S3 bucket or records in a central database) doesn't get deleted along with them. The post-delete hook closes this gap:
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "myapp.fullname" . }}-cleanup
annotations:
"helm.sh/hook": post-delete
"helm.sh/hook-delete-policy": hook-succeeded
spec:
template:
spec:
restartPolicy: Never
containers:
- name: cleanup
image: "curlimages/curl:latest"
command:
- /bin/sh
- -c
- 'curl -X DELETE "https://api.internal/cleanup?release={{ .Release.Name }}"'Generate a secret once before the release is installed, then let other resources read it:
apiVersion: v1
kind: Secret
metadata:
name: {{ include "myapp.fullname" . }}-session-secret
annotations:
"helm.sh/hook": pre-install
"helm.sh/hook-weight": "-5"
type: Opaque
data:
sessionKey: {{ randAlphaNum 32 | b64enc | quote }}This pattern ensures the secret is created before the Deployment references it — because deployment hooks run after all normal resources, but this secret is forced to exist first thanks to the pre-install hook.
A failed hook is one of the hardest things to debug in Helm — not because the mechanism is complex, but because hook resources are temporary and can disappear before you get a chance to inspect them. The following step sequence will save you from frustration:
helm status <release> (or helm list --failed) tells you which hook failed — the error message usually names the problematic Job.kubectl get jobs -n <ns> will show them. Read the container logs with kubectl logs job/<name> -n <ns>.helm.sh/hook-delete-policy: hook-succeeded (without hook-failed) so failed Jobs are not deleted after inspection. Don't forget to delete manually when done.--debug. helm upgrade <release> <chart> --debug --dry-run shows the rendered hooks along with their annotations, confirming the hook is actually defined with the right events.helm list --failed -A
helm status <release> -n <ns>
kubectl get jobs -n <ns>
kubectl logs job/<job-name> -n <ns> --tail=50One annotation typo — for example, pre-install in a template that should be pre-upgrade — is the most common reason a hook never runs. Always verify with --dry-run --debug, which shows the rendered annotations, before chasing mysteries inside the cluster.
Hooks are a powerful mechanism, and that power comes with responsibility. These disciplines keep hooks safe in production:
--force for idempotent migrations, timestamped backup file names, and cleanup that doesn't error when the data is already gone.hook-weight for explicit ordering. The execution order among hooks on the same event is determined by weight, not declaration order in the template. Assign weights deliberately and document them in comments: backup -10, migrate -5, smoke test 5.hook-succeeded,before-hook-creation is the safest combination for repeated upgrades: the old hook is deleted before a new one is created, and successful hooks don't pile up. For debugging failures, add hook-failed so failed Jobs can still be inspected.--timeout). A migration Job that takes a long time could exceed it. Give the Job an activeDeadlineSeconds and adjust the Helm timeout, or run the migration as an asynchronous operation monitored outside the hook.restartPolicy: OnFailure or Never, and a reasonable backoffLimit. Scripts inside containers must exit with a non-zero code on failure — don't swallow errors.--atomic. On upgrade, the --atomic --timeout 5m combination ensures that if a hook or resource fails, the whole operation is rolled back to the previous revision — including already-created resources — so the cluster isn't left half-done.helm upgrade myapp ./myapp \
--namespace staging \
--atomic \
--timeout 10mIn episode 12 you've understood hooks as insertion points in the release lifecycle: the ten hook types (from pre-install, post-install, pre-upgrade, post-upgrade, to pre-delete, post-delete, pre-rollback, post-rollback, pre-crd-install, and test), how to mark templates with the helm.sh/hook annotation, order them with hook-weight, and control cleanup with hook-delete-policy. You've also seen five real production use cases — database migration, backup before upgrade, smoke tests, cleanup, and secret generation — along with the best practices that keep them idempotent, measurable, and safe.
Hooks are one of the main reasons teams choose Helm over plain kubectl apply: the operation sequence that engineers used to run manually (migrate first, then deploy) is now documented inside the chart itself and executed consistently every time. In the next episode, episode 13, we add the next layer of defense with schema validation using JSON Schema — how Helm validates input values before rendering, so typos and wrong configuration are caught with clear error messages, not after the application fails in the cluster. See you in episode 13!