Helm Charts Best Practices: From Development to Production

Aug 30, 2024

Helm has become the de facto package manager for Kubernetes, but creating maintainable, secure, and production-ready charts requires following established best practices. This guide covers everything from chart structure to advanced templating techniques.

Chart Structure and Organization

A well-organized Helm chart follows a predictable structure that makes it easy to understand and maintain:

my-application/
├── Chart.yaml
├── values.yaml
├── charts/
├── templates/
   ├── deployment.yaml
   ├── service.yaml
   ├── ingress.yaml
   ├── configmap.yaml
   ├── secret.yaml
   ├── serviceaccount.yaml
   ├── rbac.yaml
   ├── hpa.yaml
   ├── pdb.yaml
   ├── tests/
      └── test-connection.yaml
   └── _helpers.tpl
├── .helmignore
└── README.md

Chart.yaml Best Practices

Your Chart.yaml should provide comprehensive metadata and follow semantic versioning:

apiVersion: v2
name: my-application
description: A production-ready microservice application
type: application
version: 1.2.3
appVersion: "2.1.0"
keywords:
  - microservice
  - api
  - web
home: https://github.com/myorg/my-application
sources:
  - https://github.com/myorg/my-application
maintainers:
  - name: DevOps Team
    email: devops@myorg.com
dependencies:
  - name: postgresql
    version: "12.1.9"
    repository: https://charts.bitnami.com/bitnami
    condition: postgresql.enabled
  - name: redis
    version: "17.3.7"
    repository: https://charts.bitnami.com/bitnami
    condition: redis.enabled
annotations:
  artifacthub.io/changes: |
    - Added support for custom annotations
    - Improved security with non-root containers
    - Added network policies support

Values.yaml Design Patterns

Structure your values.yaml file logically with clear sections and comprehensive documentation:

# Default values for my-application
# This is a YAML-formatted file.

# Global configuration
global:
  imageRegistry: ""
  imagePullSecrets: []
  storageClass: ""

# Application configuration
app:
  # Application name override
  nameOverride: ""
  fullnameOverride: ""
  
  # Container image configuration
  image:
    registry: docker.io
    repository: myorg/my-application
    tag: "2.1.0"
    pullPolicy: IfNotPresent
    pullSecrets: []
  
  # Application-specific configuration
  config:
    logLevel: "info"
    environment: "production"
    features:
      monitoring: true
      tracing: true
      caching: true

# Deployment configuration
deployment:
  replicaCount: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 1
  
  # Resource management
  resources:
    limits:
      cpu: 1000m
      memory: 1Gi
    requests:
      cpu: 100m
      memory: 256Mi
  
  # Security context
  securityContext:
    runAsNonRoot: true
    runAsUser: 1001
    fsGroup: 1001
    capabilities:
      drop:
        - ALL
    readOnlyRootFilesystem: true
    allowPrivilegeEscalation: false

# Auto-scaling configuration
autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70
  targetMemoryUtilizationPercentage: 80

# Service configuration
service:
  type: ClusterIP
  port: 80
  targetPort: 8080
  annotations: {}

# Ingress configuration
ingress:
  enabled: true
  className: "nginx"
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
  hosts:
    - host: api.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: api-tls
      hosts:
        - api.example.com

Values.yaml Design Patterns

Structure your values.yaml file logically with clear sections and comprehensive documentation:

# Default values for my-application
# This is a YAML-formatted file.

# Global configuration
global:
  imageRegistry: ""
  imagePullSecrets: []
  storageClass: ""

# Application configuration
app:
  # Application name override
  nameOverride: ""
  fullnameOverride: ""
  
  # Container image configuration
  image:
    registry: docker.io
    repository: myorg/my-application
    tag: "2.1.0"
    pullPolicy: IfNotPresent
    pullSecrets: []
  
  # Application-specific configuration
  config:
    logLevel: "info"
    environment: "production"
    features:
      monitoring: true
      tracing: true
      caching: true

# Deployment configuration
deployment:
  replicaCount: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 1
  
  # Resource management
  resources:
    limits:
      cpu: 1000m
      memory: 1Gi
    requests:
      cpu: 100m
      memory: 256Mi
  
  # Security context
  securityContext:
    runAsNonRoot: true
    runAsUser: 1001
    fsGroup: 1001
    capabilities:
      drop:
        - ALL
    readOnlyRootFilesystem: true
    allowPrivilegeEscalation: false

# Auto-scaling configuration
autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70
  targetMemoryUtilizationPercentage: 80

# Service configuration
service:
  type: ClusterIP
  port: 80
  targetPort: 8080
  annotations: {}

# Ingress configuration
ingress:
  enabled: true
  className: "nginx"
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
  hosts:
    - host: api.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: api-tls
      hosts:
        - api.example.com

Template Best Practices

Write maintainable templates using Helm's templating features effectively:

Helper Templates (_helpers.tpl)

{{/*
Expand the name of the chart.
*/}}
{{- define "myapp.name" -}}
{{- default .Chart.Name .Values.app.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Create a default fully qualified app name.
*/}}
{{- define "myapp.fullname" -}}
{{- if .Values.app.fullnameOverride }}
{{- .Values.app.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.app.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{/*
Common labels
*/}}
{{- define "myapp.labels" -}}
helm.sh/chart: {{ include "myapp.chart" . }}
{{ include "myapp.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
Selector labels
*/}}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/*
Create the name of the service account to use
*/}}
{{- define "myapp.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "myapp.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

Deployment Template with Best Practices

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "myapp.fullname" . }}
  labels:
    {{- include "myapp.labels" . | nindent 4 }}
  {{- with .Values.deployment.annotations }}
  annotations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.deployment.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "myapp.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
        {{- with .Values.deployment.podAnnotations }}
        {{- toYaml . | nindent 8 }}
        {{- end }}
      labels:
        {{- include "myapp.selectorLabels" . | nindent 8 }}
    spec:
      {{- with .Values.app.image.pullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      serviceAccountName: {{ include "myapp.serviceAccountName" . }}
      securityContext:
        {{- toYaml .Values.deployment.securityContext | nindent 8 }}
      containers:
        - name: {{ .Chart.Name }}
          securityContext:
            {{- toYaml .Values.deployment.securityContext | nindent 12 }}
          image: "{{ .Values.app.image.registry }}/{{ .Values.app.image.repository }}:{{ .Values.app.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.app.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.service.targetPort }}
              protocol: TCP
          livenessProbe:
            httpGet:
              path: /health
              port: http
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /ready
              port: http
            initialDelaySeconds: 5
            periodSeconds: 5
          env:
            - name: LOG_LEVEL
              value: {{ .Values.app.config.logLevel | quote }}
            - name: ENVIRONMENT
              value: {{ .Values.app.config.environment | quote }}
          resources:
            {{- toYaml .Values.deployment.resources | nindent 12 }}
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: config
              mountPath: /app/config
              readOnly: true
      volumes:
        - name: tmp
          emptyDir: {}
        - name: config
          configMap:
            name: {{ include "myapp.fullname" . }}
      {{- with .Values.deployment.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.deployment.affinity }}
      affinity:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.deployment.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}

Security Best Practices

Implement security controls directly in your Helm charts:

💡

Security Checklist: Always run containers as non-root, use read-only filesystems, drop unnecessary capabilities and implement proper RBAC controls.

Pod Security Standards

# Pod Security Policy (or Pod Security Standards)
apiVersion: v1
kind: Namespace
metadata:
  name: {{ .Release.Namespace }}
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted

Testing and Validation

Implement comprehensive testing for your Helm charts:

Helm test templates

# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
  name: "{{ include "myapp.fullname" . }}-test"
  labels:
    {{- include "myapp.labels" . | nindent 4 }}
  annotations:
    "helm.sh/hook": test
    "helm.sh/hook-weight": "1"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  restartPolicy: Never
  containers:
    - name: wget
      image: busybox
      command: ['wget']
      args: ['{{ include "myapp.fullname" . }}:{{ .Values.service.port }}/health']

Chart Linting and Validation

# Makefile for chart development
.PHONY: lint test install upgrade uninstall

lint:
	helm lint .
	helm template . --debug --dry-run > /dev/null

test:
	helm test $(RELEASE_NAME) --namespace $(NAMESPACE)

install:
	helm install $(RELEASE_NAME) . 		--namespace $(NAMESPACE) 		--create-namespace 		--wait 		--timeout 5m

upgrade:
	helm upgrade $(RELEASE_NAME) . 		--namespace $(NAMESPACE) 		--wait 		--timeout 5m

uninstall:
	helm uninstall $(RELEASE_NAME) --namespace $(NAMESPACE)

Advanced Templating Techniques

Use advanced Helm features for complex scenarios:

Conditional Resources

{{- if .Values.monitoring.enabled }}
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: {{ include "myapp.fullname" . }}
  labels:
    {{- include "myapp.labels" . | nindent 4 }}
spec:
  selector:
    matchLabels:
      {{- include "myapp.selectorLabels" . | nindent 6 }}
  endpoints:
  - port: metrics
    interval: 30s
    path: /metrics
{{- end }}

Multi-Environment Support

# values-production.yaml
app:
  config:
    environment: "production"
    logLevel: "warn"

deployment:
  replicaCount: 5
  resources:
    limits:
      cpu: 2000m
      memory: 2Gi
    requests:
      cpu: 500m
      memory: 512Mi

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 20

Following these Helm best practices ensures your charts are maintainable, secure, and production-ready. Remember to version your charts properly and maintain comprehensive documentation for your users.

Ops & Cloud