Learn Helm Chart - Hooks & Lifecycle Management
Episode 12 of 30

Learn Helm Chart - Hooks & Lifecycle Management

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.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

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.

The Hooks Concept

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:

HookWhen It Executes
pre-installAfter templates are rendered, before any resource is created
post-installAfter all release resources have been successfully created
pre-deleteBefore any resource is deleted
post-deleteAfter all release resources have been deleted
pre-upgradeBefore the release resources are upgraded
post-upgradeAfter all release resources have been successfully upgraded
pre-rollbackBefore the release resources are rolled back
post-rollbackAfter all release resources have been successfully rolled back
pre-crd-installBefore CRDs (Custom Resource Definitions) are installed
testWhen 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.

Implementing Hooks: Annotations and Attributes

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.

templates/hooks/migrate.yaml - hook structure example
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.

Hook Resources: Jobs, Pods, and Their Behavior

Helm runs hooks differently depending on the resource type:

  • Job — the most common and most recommended way. Helm waits for the Job to finish (status Succeeded) before continuing the main operation. If the Job Fails, the operation is considered failed and (depending on flags) can be auto-rolled back.
  • Pod — runs, and Helm waits for the Pod to finish. Less ideal than a Job because Pods have no built-in retry semantics.
  • Other resources (ConfigMap, Secret, PVC) — created as hooks, but nothing "waits" on them; their only function is providing objects other hooks need (for example, a migration configuration ConfigMap read by the Job).

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.

Common Hook Use Cases

Hooks become truly useful when applied to real needs. These five patterns are the most common in production:

1. Database Migration (pre-upgrade and pre-install)

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.

templates/hooks/migrate-job.yaml
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"]

2. Backup Before Upgrade (pre-upgrade)

"Better safe than sorry" in automated form. A hook with a small weight (-10) dumps the database to external storage before anything changes:

templates/hooks/backup.yaml
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: url

3. Smoke Test After Install (post-install)

Making sure the application actually responds before it's considered successful. This hook creates a Pod that curls the application endpoint:

templates/hooks/smoke-test.yaml
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 1

4. Cleanup Job (post-delete)

When 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:

templates/hooks/cleanup.yaml
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 }}"'

5. Secret Generation (pre-install)

Generate a secret once before the release is installed, then let other resources read it:

templates/hooks/secret-generator.yaml
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.

6. Debugging Failed Hooks

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:

  1. Look at the release status. helm status <release> (or helm list --failed) tells you which hook failed — the error message usually names the problematic Job.
  2. Inspect the Job before it's deleted. If the delete policy doesn't remove failed Jobs, kubectl get jobs -n <ns> will show them. Read the container logs with kubectl logs job/<name> -n <ns>.
  3. Force hooks to stay for inspection. When debugging, set 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.
  4. Use --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.
Hook debugging flow
helm list --failed -A
helm status <release> -n <ns>
kubectl get jobs -n <ns>
kubectl logs job/<job-name> -n <ns> --tail=50

One 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.

Hook Best Practices

Hooks are a powerful mechanism, and that power comes with responsibility. These disciplines keep hooks safe in production:

  1. Idempotency is the first law. Hooks can run more than once (retries, repeated upgrades, rollbacks). Migrations, backups, and cleanup must be safe to run repeatedly — use --force for idempotent migrations, timestamped backup file names, and cleanup that doesn't error when the data is already gone.
  2. Use 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.
  3. Set the delete policy carefully. 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.
  4. Configure timeouts. Helm operations have a global timeout (--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.
  5. Explicit error handling. Use restartPolicy: OnFailure or Never, and a reasonable backoffLimit. Scripts inside containers must exit with a non-zero code on failure — don't swallow errors.
  6. Don't store important data in hooks. Hooks are temporary; if you need persistence, use a PVC or external storage. And never put static secrets in hook templates — read from an existing Secret.
  7. Consider --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.
A safe upgrade with hooks
helm upgrade myapp ./myapp \
  --namespace staging \
  --atomic \
  --timeout 10m

Conclusion

In 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!

Learn Helm Chart - Hooks & Lifecycle Management | Learn Helm Chart