Hooks & release lifecycle
Run jobs at install/upgrade.
A Helm release runs a bit like a stage play. The templates are your set and cast, the visible thing the audience watches. But between scenes something has to happen offstage: a database gets migrated, a cache warms up, an old secret rotates out. Hooks are the stagehands who work on cue. Helm pauses the main action, lets them do their job, and only raises the curtain on the next scene once they report back clean. A hook is any ordinary Kubernetes object, a Job that runs a task, a Pod that runs a container, even a ConfigMap that holds settings, that you tag with a special annotation (a free-form key-value label you can attach to any Kubernetes object) so Helm runs it at a chosen moment instead of folding it in with the rest of the app.
The release lifecycle
Every helm install, upgrade, and rollback is a versioned event. Helm writes each one into a ledger as a numbered revision you can list, inspect, and reverse, and that ledger is the release lifecycle. helm history shows every revision with its status and chart version. helm rollback winds the release back to an earlier revision, and records the rollback itself as a brand new revision, so you never lose the trail. helm status reports where the release stands right now. helm uninstall closes the ledger: on the way out it fires the release's delete hooks, and unless you pass --keep-history it purges the revision records too.
Hooks hang off the phases of that lifecycle. You mark a normal resource as a hook with the helm.sh/hook annotation, and Helm lifts it out of the regular manifest (the full set of resource definitions the chart would otherwise apply in one go), applies it at the phase you named, and waits for it to finish before doing anything else. The phases fire in a fixed order: pre-install then post-install, pre-upgrade then post-upgrade, pre-rollback then post-rollback, pre-delete then post-delete. There is one more, test, which runs only when you call helm test (that one belongs to the linting and testing lesson). A pre-install hook runs after Helm renders your templates but before it loads a single one of the chart's own resources into the cluster, which is exactly why it is the right place to gate a rollout.
# install, upgrade, and rollback each write a numbered revisionhelm history webhelm status web
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION1 Fri Jul 17 09:14:02 2026 superseded web-1.4.0 2.0.0 Install complete2 Fri Jul 17 10:02:41 2026 superseded web-1.4.1 2.1.0 Upgrade complete3 Fri Jul 17 11:20:18 2026 deployed web-1.4.0 2.0.0 Rollback to 1NAME: webLAST DEPLOYED: Fri Jul 17 11:20:18 2026NAMESPACE: defaultSTATUS: deployedREVISION: 3TEST SUITE: None
A pre-upgrade migration Job
The most common hook is a Job that has to finish before new code goes live, and a database schema migration is the textbook case. A Job here means a Kubernetes workload that starts a pod (the smallest unit Kubernetes runs, one or more containers that share an address and a lifecycle), runs it once to completion, and then stops. You want the migration to run on a fresh install and on every upgrade, and you want it to run before the new pods appear, so new code never talks to an old schema. Fix the wiring before you throw the switch. You get all of that from three annotations on an otherwise ordinary Job.
apiVersion: batch/v1kind: Jobmetadata:name: {{ include "web-chart.fullname" . }}-migrateannotations:# Run before the new pods roll out, on install AND upgrade"helm.sh/hook": pre-install,pre-upgrade# Lower weights run first; default is 0, so -5 sorts ahead of everything"helm.sh/hook-weight": "-5"# Clear a same-named leftover first, then delete this one when it passes"helm.sh/hook-delete-policy": before-hook-creation,hook-succeededspec:backoffLimit: 2 # retry twice before Kubernetes calls it failedtemplate:spec:restartPolicy: Never # each attempt is a fresh podcontainers:- name: migrateimage: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"command: ["/app/bin/migrate", "up"]
Read the annotations top to bottom. helm.sh/hook: pre-install,pre-upgrade attaches the Job to both phases, so it runs on the first install and on every upgrade after. helm.sh/hook-weight: "-5" is a sort key: within a phase Helm runs hooks from lowest weight to highest, the default is 0, and a -5 guarantees this migration goes ahead of anything else in the phase. helm.sh/hook-delete-policy controls cleanup, which the next section covers. backoffLimit: 2 lets the Job retry twice before Kubernetes marks it failed, and restartPolicy: Never makes each attempt a fresh pod. When you run the upgrade, Helm creates the Job, waits for its pod to exit 0, and only then applies the new Deployment (the Kubernetes object that keeps a set of identical pods running at the count you asked for).
# roll out image 2.1.0; the pre-upgrade Job must pass before new pods appearhelm upgrade web ./web-chart --set image.tag=2.1.0
Release "web" has been upgraded. Happy Helming!NAME: webLAST DEPLOYED: Fri Jul 17 10:02:41 2026NAMESPACE: defaultSTATUS: deployedREVISION: 2TEST SUITE: None
Because the delete policy includes hook-succeeded, Helm removes the migration Job the moment it passes, so a clean run leaves nothing behind to collide with next time. The safety payoff shows up when the migration does not pass.
Ordering, cleanup, and failure
Two annotations give you the control. helm.sh/hook-weight orders hooks that share a phase. Helm reads the weights as whole numbers and runs the hooks from lowest to highest, so negative values go first. Hooks that carry the same weight fall back to alphabetical order by name, which is a coin toss you do not want to lean on, so set explicit weights whenever order actually matters. helm.sh/hook-delete-policy decides when Helm removes the hook object: before-hook-creation deletes a leftover of the same name before creating a new one, hook-succeeded deletes after a clean run, hook-failed deletes after a failure. Set no policy and before-hook-creation is the default. This matters most for Jobs, because a Job's name cannot be reused while an old one with that name still exists.
Failure handling is strict, and that strictness is the whole point. If a hook never reaches its finished state, Helm marks the release failed at that phase and stops. It does not apply the rest of the manifest, and it does not roll back on its own. Pass --atomic and the rule changes: any failure during the upgrade, a failed hook included, reverts the entire release to the previous revision, and --timeout bounds how long Helm waits before giving up (five minutes by default). Here is the same broken migration, first bare and then guarded.
# push a broken migration; the pre-upgrade hook fails and blocks the rollouthelm upgrade web ./web-chart --set image.tag=2.2.0
Error: UPGRADE FAILED: pre-upgrade hooks failed: 1 error occurred:* job web-migrate failed: BackoffLimitExceeded
# the failed hook Job is still there, so read exactly why it brokekubectl logs job/web-migrate# and confirm the release statehelm history web
migrate: applying 20260717_add_orders_index.up.sqlERROR: relation "orders" does not exist (SQLSTATE 42P01)migrate: 1 migration(s) failedREVISION UPDATED STATUS CHART APP VERSION DESCRIPTION1 Fri Jul 17 09:14:02 2026 deployed web-1.4.0 2.0.0 Install complete2 Fri Jul 17 10:41:55 2026 failed web-1.5.0 2.2.0 pre-upgrade hooks failed
The old pods are still serving the previous version. The pre-upgrade hook blocked before the new Deployment was ever applied, so the bad build never reached a user. The failed Job is still in the namespace too, because hook-succeeded only fires on success, which is what lets you read its logs after the fact. helm history shows revision 1 still deployed and revision 2 sitting in failed, and helm status agrees; to get back to a clean state you either fix the migration and upgrade again or run helm rollback web 1. Add --atomic and Helm does that rollback for you the instant the hook fails.
# make the failure self-healing: revert the whole upgrade on any errorhelm upgrade web ./web-chart --set image.tag=2.2.0 --atomic --timeout 5m
Error: UPGRADE FAILED: release web failed, and has been rolled back due to atomic being set: pre-upgrade hooks failed: 1 error occurred:* job web-migrate failed: BackoffLimitExceeded
Hooks are a trust boundary
A hook runs with the service account its pod names (the identity a pod presents to the Kubernetes API when it makes requests), at a moment when nobody is watching the screen, and Helm will not clean it up on uninstall. Turn that around and it is an attacker's dream. A pre-install hook Job buried in a chart you pulled from some public registry can read every secret that account can reach and phone home before a single application pod exists, and helm uninstall will leave that Job sitting in the namespace afterward. So treat hooks as code you are about to execute, not as configuration. For a release that is already installed, ask Helm directly what it carries.
# dump every hook a release will run, with its phase and weighthelm get hooks web
---# Source: web-chart/templates/db-migrate-job.yamlapiVersion: batch/v1kind: Jobmetadata:name: web-migrateannotations:"helm.sh/hook": pre-install,pre-upgrade"helm.sh/hook-weight": "-5""helm.sh/hook-delete-policy": before-hook-creation,hook-succeededspec:backoffLimit: 2template:spec:restartPolicy: Nevercontainers:- name: migrateimage: "registry.example.com/web:2.1.0"command: ["/app/bin/migrate", "up"]
helm get hooks prints every hook the release carries, so you can see precisely what fires on install, upgrade, and delete. For a chart you have not installed yet, helm template ./web-chart renders the hooks right alongside the rest of the manifest, so a grep for helm.sh/hook surfaces them before you commit to anything. Run charts under a scoped service account rather than a cluster-admin one, and scan that rendered output the same way you would scan any other Kubernetes manifest.
helm.sh/hook-delete-policy: hook-succeeded. It fails once, and the next helm upgrade dies with jobs.batch "web-migrate" already exists. What went wrong?One habit pays for itself: before you install or upgrade anything that carries hooks, run helm get hooks against the current release and diff it against what the new chart renders. A hook that changed phase, picked up a new weight, or started running under a different service account is the kind of change that never appears in a Deployment diff, and it is the one most worth catching before it runs.
Try this
Run helm history web 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: hook resources are not tracked by the release. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.