Managing Kubernetes Applications with Helm

A practical guide to Helm charts, releases, and values - packaging, templating, and upgrading Kubernetes applications

Introduction

Helm is the standard package manager for Kubernetes: a chart bundles a set of manifests as reusable, versioned, parameterized templates, and a release is one deployed instance of a chart with a specific set of values. This guide covers chart structure, installing and upgrading releases, and writing your own chart.

Installing Helm

curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm version

Working with Existing Charts

# Add a chart repository
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update

# Search for a chart
helm search repo bitnami/postgresql

# Inspect a chart's default values before installing
helm show values bitnami/postgresql > postgresql-values.yaml

# Install a release
helm install my-db bitnami/postgresql \
  --namespace data \
  --create-namespace \
  --values postgresql-values.yaml \
  --set auth.database=appdb

# List releases
helm list --namespace data

# Upgrade a release with new values
helm upgrade my-db bitnami/postgresql \
  --namespace data \
  --reuse-values \
  --set primary.persistence.size=50Gi

# Roll back to the previous revision
helm rollback my-db --namespace data

# Uninstall
helm uninstall my-db --namespace data

--reuse-values on upgrade merges your new --set/--values on top of the release’s currently deployed values rather than resetting to the chart’s defaults - without it, an upgrade that only changes one setting silently reverts every other value you’d previously customized.

Chart Structure

my-app/
├── Chart.yaml          # Chart metadata: name, version, appVersion
├── values.yaml          # Default configuration values
├── values.schema.json   # Optional: JSON Schema validating values
├── charts/               # Bundled dependency charts
├── templates/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── _helpers.tpl     # Reusable template snippets
│   └── NOTES.txt        # Printed after install/upgrade
└── .helmignore
# Chart.yaml
apiVersion: v2
name: my-app
description: A Helm chart for my-app
type: application
version: 0.1.0        # Chart version - bump on every change to the chart itself
appVersion: "1.4.2"    # Version of the application the chart deploys

version and appVersion track two different things and change independently: version is the chart’s own release number (bump it whenever templates or defaults change), appVersion is just informational metadata about which version of your application image the chart currently points at.

Writing a Template

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-app.fullname" . }}
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "my-app.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "my-app.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          ports:
            - containerPort: {{ .Values.service.port }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
# values.yaml
replicaCount: 2

image:
  repository: myregistry/my-app
  tag: ""   # defaults to .Chart.AppVersion when unset

service:
  port: 8080

resources:
  requests:
    cpu: 250m
    memory: 256Mi
  limits:
    cpu: 500m
    memory: 512Mi

{{ include "my-app.fullname" . }} calls a named template usually defined in _helpers.tpl - the convention (generated by helm create) computes a consistent <release-name>-<chart-name> naming scheme so every resource in the chart is named predictably without repeating the logic in every template file.

Testing and Validating

# Render templates locally without installing anything
helm template my-app ./my-app --values values.yaml

# Validate against the Kubernetes API without persisting (server-side dry run)
helm install my-app ./my-app --dry-run --debug

# Lint the chart for common mistakes
helm lint ./my-app

helm template is the fastest feedback loop for template logic - it never touches a cluster - while --dry-run on helm install/upgrade additionally validates the rendered manifests against the live API server’s schema (catching things like an invalid resources value that helm template alone wouldn’t).

Dependencies

# Chart.yaml
dependencies:
  - name: postgresql
    version: "14.x.x"
    repository: "https://charts.bitnami.com/bitnami"
    condition: postgresql.enabled
helm dependency update ./my-app

condition: postgresql.enabled lets consumers of your chart opt out of the bundled PostgreSQL dependency entirely via --set postgresql.enabled=false, useful when they already run a database elsewhere and only want your application chart’s own resources.

Best Practices

  1. Pin chart versions explicitly (helm install --version 14.2.1, not the latest floating version) in anything beyond local experimentation - an unpinned chart install can pull in breaking changes between when you tested and when a pipeline actually runs it.
  2. Use --atomic on upgrades in CI/CD - it automatically rolls back to the previous release if the upgrade fails, rather than leaving the release in a partially-applied state.
  3. Keep secrets out of values.yaml committed to version control - reference an existing Kubernetes Secret, or use a tool like helm-secrets or the External Secrets Operator instead of plaintext values.
  4. Use values.schema.json to validate required values and their types at install time, catching a misconfigured --set before it reaches the API server rather than after.

Conclusion

Helm’s chart/release/values model solves the same “reusable, parameterized manifests” problem Kustomize solves with overlays and patches, but takes a templating approach instead - useful when the same chart needs to serve many different consumers with very different configuration needs, at the cost of the string-templating footguns (accidental YAML indentation errors, values that silently don’t apply) that a patch-based tool avoids.

Additional Resources