MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Kubernetes Helm Charts: Package Management for K8s

Use Helm for K8s: chart structure, templates, values, releases, and chart repositories.

KubernetesHelmDevOpsPackage Management

By MinhVo

Introduction

Deploying a complex application to Kubernetes typically requires creating and managing dozens of YAML manifests: Deployments, Services, ConfigMaps, Secrets, Ingress rules, ServiceAccounts, and more. Managing these files manually is error-prone, tedious, and difficult to reproduce across environments. When you need to deploy the same application to development, staging, and production with slightly different configuration, the problem multiplies.

Helm solves this by providing a package manager for Kubernetes. Just as npm manages JavaScript packages and apt manages Debian packages, Helm manages Kubernetes manifests as "charts"—packaged collections of templated YAML files with configurable values. With Helm, you can install complex applications with a single command, upgrade them safely with automatic rollback on failure, and manage multiple releases of the same application in different namespaces or clusters.

Helm has become the de facto standard for Kubernetes application packaging. The Helm Hub and Artifact Hub host thousands of charts for popular software including PostgreSQL, Redis, NGINX, Prometheus, and virtually every CNCF project. Understanding Helm is essential for any developer or operator working with Kubernetes in production.

Package management concept

Understanding Helm: Core Concepts

Charts, Releases, and Repositories

Helm introduces three core concepts that map to familiar package management patterns:

Chart — A packaged set of Kubernetes manifest templates. A chart is the Helm equivalent of an npm package or a Debian package. It contains templates, default configuration values, metadata, and documentation.

Release — A running instance of a chart in a Kubernetes cluster. When you install a chart, Helm creates a release. You can install the same chart multiple times with different release names and configurations. Each release is independently managed and versioned.

Repository — A collection of charts that can be shared and downloaded. Charts are published to repositories like Artifact Hub, and you can create private repositories for your organization's internal charts.

The Chart Structure

A Helm chart follows a standard directory structure:

mychart/
├── Chart.yaml          # Chart metadata (name, version, description)
├── Chart.lock          # Dependency lock file
├── values.yaml         # Default configuration values
├── values.schema.json  # JSON Schema for values validation
├── templates/          # Kubernetes manifest templates
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── configmap.yaml
│   ├── secret.yaml
│   ├── serviceaccount.yaml
│   ├── _helpers.tpl    # Template helpers (partials)
│   ├── hpa.yaml
│   ├── NOTES.txt       # Post-install notes displayed to user
│   └── tests/
│       └── test-connection.yaml
├── charts/             # Dependency charts (sub-charts)
├── crds/               # Custom Resource Definitions
└── README.md           # Chart documentation

The Template Engine

Helm uses Go templates to generate Kubernetes manifests from templates and values. The template engine supports conditionals, loops, functions, and pipelines, giving you full control over the generated output.

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "mychart.fullname" . }}
  labels:
    {{- include "mychart.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "mychart.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "mychart.selectorLabels" . | nindent 8 }}
    spec:
      containers:
      - name: {{ .Chart.Name }}
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
        ports:
        - containerPort: {{ .Values.service.targetPort }}

Template rendering

Architecture and Design Patterns

Values Override Pattern

The values.yaml file provides default configuration. Users override these defaults during installation or upgrade:

# values.yaml (defaults)
replicaCount: 2
image:
  repository: nginx
  tag: "1.25"
  pullPolicy: IfNotPresent
service:
  type: ClusterIP
  port: 80
  targetPort: 8080
resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 256Mi

Override during installation:

helm install my-release ./mychart \
  --set replicaCount=5 \
  --set image.tag=1.26 \
  --set service.type=LoadBalancer

Or use a values file for environment-specific configuration:

helm install my-release ./mychart -f values-production.yaml
# values-production.yaml
replicaCount: 10
image:
  tag: "1.26"
service:
  type: LoadBalancer
resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    cpu: "2"
    memory: "2Gi"

Helper Templates

The _helpers.tpl file defines reusable template fragments (named templates) that reduce duplication:

# templates/_helpers.tpl
{{/*
Generate chart name and version for chart label.
*/}}
{{- define "mychart.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
 
{{/*
Common labels
*/}}
{{- define "mychart.labels" -}}
helm.sh/chart: {{ include "mychart.chart" . }}
{{ include "mychart.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
 
{{/*
Selector labels
*/}}
{{- define "mychart.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
 
{{/*
Fullname
*/}}
{{- define "mychart.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}

Dependency Management

Charts can depend on other charts, specified in Chart.yaml:

# Chart.yaml
apiVersion: v2
name: myapp
description: My application with PostgreSQL
type: application
version: 1.0.0
appVersion: "2.0"
 
dependencies:
- name: postgresql
  version: "12.x.x"
  repository: "https://charts.bitnami.com/bitnami"
  condition: postgresql.enabled
- name: redis
  version: "17.x.x"
  repository: "https://charts.bitnami.com/bitnami"
  condition: redis.enabled
# Update dependencies
helm dependency update ./mychart
 
# This creates Chart.lock and downloads charts to charts/

Step-by-Step Implementation

Creating a New Chart

# Create a new chart from the default template
helm create myapp
 
# The generated structure includes:
# - Deployment, Service, Ingress, HPA, ServiceAccount templates
# - Default values.yaml
# - Helper templates in _helpers.tpl
# - Test templates
 
# Customize the chart
cd myapp

Building a Complete Application Chart

Here is a complete chart for a Node.js API application:

# Chart.yaml
apiVersion: v2
name: node-api
description: A Node.js REST API with PostgreSQL
type: application
version: 0.1.0
appVersion: "1.0.0"
# values.yaml
replicaCount: 2
 
image:
  repository: myregistry.io/node-api
  tag: "1.0.0"
  pullPolicy: IfNotPresent
 
service:
  type: ClusterIP
  port: 80
  targetPort: 3000
 
ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
  hosts:
  - host: api.example.com
    paths:
    - path: /
      pathType: Prefix
  tls:
  - secretName: api-tls
    hosts:
    - api.example.com
 
resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 256Mi
 
autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 60
 
env:
  NODE_ENV: production
  LOG_LEVEL: info
 
secrets:
  DATABASE_URL: ""
  API_KEY: ""
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "node-api.fullname" . }}
  labels:
    {{- include "node-api.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "node-api.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
      labels:
        {{- include "node-api.selectorLabels" . | nindent 8 }}
    spec:
      serviceAccountName: {{ include "node-api.fullname" . }}
      containers:
      - name: {{ .Chart.Name }}
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
        imagePullPolicy: {{ .Values.image.pullPolicy }}
        ports:
        - name: http
          containerPort: {{ .Values.service.targetPort }}
        envFrom:
        - configMapRef:
            name: {{ include "node-api.fullname" . }}
        {{- if .Values.secrets.DATABASE_URL }}
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: {{ include "node-api.fullname" . }}
              key: DATABASE_URL
        {{- end }}
        resources:
          {{- toYaml .Values.resources | nindent 10 }}
        readinessProbe:
          httpGet:
            path: /health
            port: http
          initialDelaySeconds: 5
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /health
            port: http
          initialDelaySeconds: 15
          periodSeconds: 20

Installing, Upgrading, and Rolling Back

# Install a release
helm install my-api ./node-api \
  --namespace production \
  --create-namespace \
  --set secrets.DATABASE_URL=postgres://user:pass@host/db
 
# Check release status
helm status my-api -n production
 
# Upgrade with new values
helm upgrade my-api ./node-api \
  --namespace production \
  --set image.tag=2.0.0 \
  --set replicaCount=5
 
# View release history
helm history my-api -n production
 
# Rollback to previous revision
helm rollback my-api 1 -n production
 
# Uninstall
helm uninstall my-api -n production

Deployment workflow

Real-World Use Cases

Deploying a Full Application Stack

Deploy an application with its database, cache, and monitoring:

# Add repositories
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
 
# Install PostgreSQL
helm install my-postgres bitnami/postgresql \
  --set auth.database=myapp \
  --set auth.username=myuser \
  --set auth.existingSecret=my-postgres-secret
 
# Install Redis
helm install my-redis bitnami/redis \
  --set architecture=standalone \
  --set auth.existingSecret=my-redis-secret
 
# Install the application
helm install my-api ./node-api \
  --set secrets.DATABASE_URL="postgres://myuser:pass@my-postgres/myapp"
 
# Install Prometheus monitoring
helm install monitoring prometheus-community/kube-prometheus-stack

Multi-Environment Deployment with Kustomize + Helm

For teams that prefer Kustomize for overlay management but want Helm charts for third-party software:

# kustomization.yaml
helmCharts:
- name: postgresql
  repo: https://charts.bitnami.com/bitnami
  version: 12.x.x
  releaseName: my-postgres
  namespace: database
  values:
    auth:
      database: myapp

Chart Packaging and Distribution

# Lint the chart for errors
helm lint ./mychart
 
# Render templates locally without installing
helm template my-release ./mychart -f values.yaml
 
# Package the chart for distribution
helm package ./mychart
# Creates mychart-0.1.0.tgz
 
# Push to a chart repository (OCI-based)
helm push mychart-0.1.0.tgz oci://myregistry.io/helm-charts
 
# Install from OCI repository
helm install my-release oci://myregistry.io/helm-charts/mychart --version 0.1.0

Best Practices for Production

  1. Always use helm template before helm install — Render the templates locally to verify the generated YAML is correct before applying to the cluster.

  2. Use Chart.yaml versioning semantically — Follow SemVer for chart versions. Increment the patch version for bug fixes, minor for backward-compatible features, major for breaking changes.

  3. Pin dependency versions — Use exact versions or version ranges in Chart.yaml dependencies. Use Chart.lock to ensure reproducible builds.

  4. Use --atomic for production upgrades — The --atomic flag automatically rolls back if the upgrade fails, preventing partially deployed releases.

  5. Set --timeout for long-running deployments — Default timeout is 5 minutes. Increase it for large deployments or slow-starting applications.

  6. Use values.schema.json for validation — Define a JSON Schema for your values to catch configuration errors during helm install rather than at runtime.

  7. Store chart values in version control — Maintain separate values files for each environment (values-dev.yaml, values-staging.yaml, values-prod.yaml) in your Git repository.

  8. Use Helmfile or Helmsman for multi-chart deployments — Tools like Helmfile provide declarative management of multiple Helm releases, enabling you to deploy an entire stack with a single command.

Common Pitfalls and Solutions

PitfallImpactSolution
Hardcoded values in templatesChart not reusable across environmentsUse {{ .Values.xxx }} for all configurable values
No resource requests/limitsUnpredictable scheduling and OOMKillsAlways define resources in values.yaml with sensible defaults
Missing NOTES.txtUsers don't know how to access the deployed appAdd NOTES.txt with connection instructions
Not using helm lintTemplate errors caught during installationLint before every install and upgrade
Secrets in values.yamlSecrets committed to GitUse external secret managers or --set with CI/CD secret injection
No rollback strategyFailed upgrades require manual recoveryUse --atomic flag for automatic rollback
Large monolithic chartsDifficult to maintain and testSplit into smaller charts with dependencies
Ignoring helm diffUnexpected changes during upgradeUse helm-diff plugin to preview changes before upgrading

Performance Optimization

# Install helm-diff plugin to preview changes
helm plugin install https://github.com/databus23/helm-diff
 
# Preview changes before upgrading
helm diff upgrade my-api ./node-api -f values-prod.yaml
 
# Use --reuse-values to only change specific values during upgrade
helm upgrade my-api ./node-api --reuse-values --set replicaCount=10
 
# Use helmfile for parallel deployments
helmfile apply --concurrency 4

For large clusters with many releases, use Helm's release history limits to prevent storage bloat:

# Limit history to 10 revisions
helm upgrade my-api ./node-api --history-max 10

Comparison with Alternatives

FeatureHelmKustomizeJsonnet/TankaCarvel yttArgoCD
TemplatingGo templatesOverlaysJsonnetYAML-nativeN/A (GitOps)
Package formatChart (.tgz)DirectoryBundleDirectoryN/A
Dependency managementBuilt-inNoneJsonnet BundlerNoneGit-based
RollbackBuilt-inManualManualManualBuilt-in
Release managementYesNoNoNoYes (GitOps)
Learning curveMediumLowHighLowMedium
Community chartsThousandsFewFewFewN/A
Best forThird-party appsCustom overlaysComplex configsYAML-native configGitOps delivery

Helm is the best choice for deploying third-party software and managing releases. Kustomize is better for customizing base manifests without templating. Many teams use both: Helm for third-party charts, Kustomize for in-house applications.

Advanced Patterns

Chart Hooks for Lifecycle Events

Helm hooks run scripts at specific points in the release lifecycle:

# templates/job-migrate.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "mychart.fullname" . }}-migrate
  annotations:
    "helm.sh/hook": pre-upgrade,pre-install
    "helm.sh/hook-weight": "-5"
    "helm.sh/hook-delete-policy": before-hook-creation
spec:
  template:
    spec:
      containers:
      - name: migrate
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
        command: ["node", "scripts/migrate.js"]
      restartPolicy: Never

Nested Charts (Sub-charts)

Create parent-child chart relationships for modular applications:

# Parent Chart.yaml
dependencies:
- name: frontend
  version: "1.0.0"
  repository: "file://../frontend-chart"
- name: api
  version: "2.0.0"
  repository: "file://../api-chart"
# Override sub-chart values from parent values.yaml
frontend:
  replicaCount: 3
  ingress:
    enabled: true
 
api:
  replicaCount: 5
  autoscaling:
    enabled: true

Lookup Function for Dynamic Configuration

Use the lookup function to read existing cluster resources:

{{- $secret := lookup "v1" "Secret" .Release.Namespace "my-secret" }}
{{- if $secret }}
apiVersion: v1
kind: Secret
metadata:
  name: my-secret
data:
  # Preserve existing password if secret already exists
  password: {{ index $secret.data "password" }}
{{- else }}
apiVersion: v1
kind: Secret
metadata:
  name: my-secret
data:
  password: {{ randAlphaNum 24 | b64enc }}
{{- end }}

Testing Strategies

# Lint chart for errors
helm lint ./mychart
 
# Render templates and check output
helm template test-release ./mychart -f values.yaml > rendered.yaml
kubectl apply --dry-run=client -f rendered.yaml
 
# Run chart tests
helm test my-release -n production
 
# Use helm-unittest for unit testing templates
helm plugin install https://github.com/helm-unittest/helm-unittest
helm unittest ./mychart
# tests/deployment_test.yaml
suite: deployment
templates:
  - deployment.yaml
tests:
  - it: should set replica count
    set:
      replicaCount: 5
    asserts:
      - isKind:
          of: Deployment
      - equal:
          path: spec.replicas
          value: 5
 
  - it: should use custom image tag
    set:
      image.tag: "2.0"
    asserts:
      - equal:
          path: spec.template.spec.containers[0].image
          value: "nginx:2.0"

Future Outlook

Helm continues to be the dominant Kubernetes package manager. Helm 4 (currently in development) promises improved dependency management, better OCI registry integration, and enhanced security features. The Helm project has also been working on improved support for Kubernetes Custom Resources and better integration with GitOps workflows.

The OCI-based chart distribution model is replacing traditional chart repositories, enabling organizations to store charts alongside container images in their existing registry infrastructure. This simplifies the supply chain and enables unified access control and vulnerability scanning.

Conclusion

Helm is the standard package manager for Kubernetes, providing templating, release management, dependency handling, and chart distribution. It transforms complex multi-file Kubernetes deployments into manageable, versioned, and reproducible packages.

Key takeaways:

  1. Helm charts package Kubernetes manifests as templated, configurable units
  2. Releases are running instances of charts with independent lifecycle management
  3. Values files provide environment-specific configuration without modifying templates
  4. Helper templates (_helpers.tpl) reduce duplication and ensure consistent labeling
  5. Always lint charts and render templates before installing to catch errors early
  6. Use --atomic for production upgrades to enable automatic rollback on failure
  7. Store chart values in version control and use CI/CD for deployment
  8. Use helm-diff to preview changes before upgrading production releases

Start by creating a chart for your application with helm create, customize the templates and values, and progressively add features like autoscaling, ingress, and health probes. For deeper exploration, see the Helm documentation, the Helm Chart Best Practices, and the Artifact Hub for discovering community charts.