ServerlessBase Blog
  • Writing Helm Charts: A Practical Guide

    A comprehensive guide to creating, testing, and publishing Helm charts for Kubernetes applications

    Writing Helm Charts: A Practical Guide

    You've built a Kubernetes application. You've tested it locally. You've verified it works in your cluster. Now you need to share it with your team or deploy it to production. You could copy-paste your manifests into a new directory every time, but that's error-prone and tedious. You need a better way.

    Helm is the package manager for Kubernetes. It lets you define, install, and upgrade even the most complex Kubernetes applications. A Helm chart is a collection of files that describe a related set of Kubernetes resources. Think of it as a template for your application deployment.

    This guide walks you through creating a Helm chart from scratch, testing it locally, and publishing it to a repository. You'll learn the structure of a chart, how to use templates, and best practices for production-ready charts.

    Understanding Helm Chart Structure

    A Helm chart is a directory containing at least these files:

    my-app/
    ├── Chart.yaml          # Chart metadata
    ├── values.yaml         # Default configuration values
    ├── values.schema.json  # JSON schema for values (optional)
    ├── templates/          # Kubernetes manifest templates
    │   ├── deployment.yaml
    │   ├── service.yaml
    │   ├── ingress.yaml
    │   └── _helpers.tpl    # Template helpers
    └── charts/             # Dependencies (optional)

    Chart.yaml

    The Chart.yaml file contains metadata about your chart:

    apiVersion: v2
    name: my-app
    description: A Helm chart for my application
    type: application
    version: 0.1.0
    appVersion: "1.0.0"
    • apiVersion: Always v2 for current Helm charts
    • name: Chart name (must be lowercase, alphanumeric, hyphens, underscores)
    • description: Brief description
    • type: application for regular apps, library for reusable chart components
    • version: Chart version (semantic versioning)
    • appVersion: Application version (not necessarily the same as chart version)

    values.yaml

    This file defines default configuration values. Templates reference these values to generate Kubernetes manifests:

    replicaCount: 2
     
    image:
      repository: myregistry/my-app
      pullPolicy: IfNotPresent
      tag: "1.0.0"
     
    service:
      type: ClusterIP
      port: 80
     
    resources:
      limits:
        cpu: 500m
        memory: 512Mi
      requests:
        cpu: 250m
        memory: 256Mi

    templates/ Directory

    This is where your Kubernetes manifests live. Helm uses Go templates with the {{ }} syntax to inject values from values.yaml and other sources.

    Creating Your First Helm Chart

    Let's create a simple web application chart. We'll deploy a Deployment, a Service, and an Ingress.

    helm create my-app

    This command scaffolds a complete chart with sample templates. Let's examine what it generates:

    tree my-app

    Output:

    my-app/
    ├── Chart.yaml
    ├── values.yaml
    ├── charts/
    ├── templates/
    │   ├── deployment.yaml
    │   ├── hpa.yaml
    │   ├── ingress.yaml
    │   ├── NOTES.txt
    │   ├── serviceaccount.yaml
    │   ├── service.yaml
    │   └── tests/
    │       └── test-connection.yaml
    └── .helmignore

    Now let's customize this chart for our application. Open 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:
          {{- with .Values.podAnnotations }}
          annotations:
            {{- toYaml . | nindent 8 }}
          {{- end }}
          labels:
            {{- include "my-app.selectorLabels" . | nindent 8 }}
        spec:
          {{- with .Values.imagePullSecrets }}
          imagePullSecrets:
            {{- toYaml . | nindent 8 }}
          {{- end }}
          serviceAccountName: {{ include "my-app.serviceAccountName" . }}
          securityContext:
            {{- toYaml .Values.podSecurityContext | nindent 8 }}
          containers:
          - name: {{ .Chart.Name }}
            securityContext:
              {{- toYaml .Values.securityContext | nindent 10 }}
            image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
            imagePullPolicy: {{ .Values.image.pullPolicy }}
            ports:
            - name: http
              containerPort: {{ .Values.service.port }}
              protocol: TCP
            livenessProbe:
              httpGet:
                path: /
                port: http
            readinessProbe:
              httpGet:
                path: /
                port: http
            resources:
              {{- toYaml .Values.resources | nindent 10 }}
          {{- with .Values.nodeSelector }}
          nodeSelector:
            {{- toYaml . | nindent 8 }}
          {{- end }}
          {{- with .Values.affinity }}
          affinity:
            {{- toYaml . | nindent 8 }}
          {{- end }}
          {{- with .Values.tolerations }}
          tolerations:
            {{- toYaml . | nindent 8 }}
          {{- end }}

    This template uses several Helm features:

    • {{ include "my-app.fullname" . }} - Includes a helper function to generate a safe name
    • {{ .Values.replicaCount }} - Injects the replica count from values
    • {{ .Chart.Name }} - Uses the chart name
    • {{ .Values.image.repository }} - Injects the image repository
    • {{- toYaml .Values.resources | nindent 10 }} - Converts the resources object to YAML with proper indentation

    Using Template Functions and Objects

    Helm provides many built-in functions and objects to make template development easier.

    The . Object

    The root object (.) contains all the information available to templates:

    # Access chart metadata
    {{ .Chart.Name }}           # my-app
    {{ .Chart.Version }}        # 0.1.0
    {{ .Chart.AppVersion }}     # 1.0.0
     
    # Access release information
    {{ .Release.Name }}         # my-release
    {{ .Release.Namespace }}    # default
    {{ .Release.Revision }}     # 1
     
    # Access values
    {{ .Values.replicaCount }}  # 2
     
    # Access file system information
    {{ .Files.Get "config.txt" }}  # Read a file
    {{ .Files.Glob "templates/*.yaml" }}  # Get file list

    Common Template Functions

    # String manipulation
    {{ .Values.name | upper }}           # HELLO
    {{ .Values.name | lower }}           # hello
    {{ .Values.name | title }}           # Hello
    {{ .Values.name | quote }}           # "hello"
    {{ .Values.name | trunc 5 }}         # "hell"
     
    # Conditional logic
    {{ if .Values.enabled }}
      enabled: true
    {{ end }}
     
    {{ if eq .Values.environment "production" }}
      production: true
    {{ end }}
     
    # Loops
    {{ range .Values.services }}
    - {{ .name }}
    {{ end }}
     
    # YAML conversion
    {{ toYaml .Values.resources }}
    {{ toJson .Values }}

    Helper Templates

    Create reusable components in _helpers.tpl:

    {{/*
    Expand the name of the chart.
    */}}
    {{- define "my-app.name" -}}
    {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
    {{- end }}
     
    {{/*
    Create a default fully qualified app name.
    */}}
    {{- define "my-app.fullname" -}}
    {{- if .Values.fullnameOverride }}
    {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
    {{- else }}
    {{- $name := default .Chart.Name .Values.nameOverride }}
    {{- if contains $name .Release.Name }}
    {{- .Release.Name | trunc 63 | trimSuffix "-" }}
    {{- else }}
    {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
    {{- end }}
    {{- end }}
    {{- end }}
     
    {{/*
    Create chart name and version as used by the chart label.
    */}}
    {{- define "my-app.chart" -}}
    {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
    {{- end }}
     
    {{/*
    Common labels
    */}}
    {{- define "my-app.labels" -}}
    helm.sh/chart: {{ include "my-app.chart" . }}
    {{ include "my-app.selectorLabels" . }}
    {{- if .Chart.AppVersion }}
    app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
    {{- end }}
    app.kubernetes.io/managed-by: {{ .Release.Service }}
    {{- end }}
     
    {{/*
    Selector labels
    */}}
    {{- define "my-app.selectorLabels" -}}
    app.kubernetes.io/name: {{ include "my-app.name" . }}
    app.kubernetes.io/instance: {{ .Release.Name }}
    {{- end }}

    Testing Your Chart Locally

    Before deploying to production, test your chart thoroughly.

    Using helm lint

    helm lint my-app

    This checks for common issues like missing required fields, invalid values, and template errors.

    Using helm template

    Render the chart to see what Kubernetes manifests it generates:

    helm template my-release my-app --set replicaCount=3

    This outputs the rendered manifests without actually deploying them.

    Using helm install with dry-run

    helm install my-release my-app --dry-run --debug

    The --dry-run flag prevents actual deployment, and --debug shows detailed information about what Helm is doing.

    Using helm test

    Create a test in templates/tests/test-connection.yaml:

    apiVersion: v1
    kind: Pod
    metadata:
      name: "{{ include "my-app.fullname" . }}-test"
      labels:
        {{- include "my-app.labels" . | nindent 4 }}
      annotations:
        "helm.sh/hook": test
        "helm.sh/hook-delete-policy": hook-succeeded
    spec:
      containers:
      - name: test
        image: curlimages/curl:latest
        command:
        - sh
        - -c
        - |
          set -e
          kubectl wait --for=condition=ready pod -l app.kubernetes.io/name={{ include "my-app.name" . }} -n {{ .Release.Namespace }} --timeout=60s
          kubectl get pods -l app.kubernetes.io/name={{ include "my-app.name" . }} -n {{ .Release.Namespace }}
      restartPolicy: Never

    Run the tests:

    helm test my-release

    Publishing Your Chart

    Once your chart is ready, publish it to a chart repository.

    Creating a Helm Repository

    You can use any web server to host your charts. Common options include:

    • Artifact Hub: Publish charts to the public Helm registry
    • GitHub: Host charts in a GitHub repository
    • Nexus: Enterprise-grade repository manager
    • Chartmuseum: Simple chart repository server

    Creating a Chart Repository with Chartmuseum

    # Install Chartmuseum
    helm repo add chartmuseum https://chartmuseum.github.io/charts
    helm install chartmuseum chartmuseum/chartmuseum --set persistence.enabled=true
     
    # Create a directory for your charts
    mkdir -p /tmp/my-charts
     
    # Package your chart
    helm package my-app -d /tmp/my-charts
     
    # Upload to Chartmuseum
    curl -u admin:admin -F "chart=@/tmp/my-charts/my-app-0.1.0.tgz" http://localhost:8080/api/charts

    Adding Your Repository

    helm repo add my-repo http://your-repo-url
    helm repo update

    Installing from Your Repository

    helm install my-app my-repo/my-app --set replicaCount=3

    Best Practices for Production Charts

    1. Use Values Separation

    Separate default values from production overrides:

    # values.yaml
    replicaCount: 2
     
    # values-production.yaml
    replicaCount: 5

    2. Validate Values with Schemas

    Create a values.schema.json file to enforce valid configurations:

    {
      "type": "object",
      "properties": {
        "replicaCount": {
          "type": "integer",
          "minimum": 1,
          "maximum": 10
        },
        "image": {
          "type": "object",
          "properties": {
            "repository": { "type": "string" },
            "tag": { "type": "string" },
            "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }
          },
          "required": ["repository", "tag"]
        }
      },
      "required": ["replicaCount", "image"]
    }

    3. Use Helm Hooks

    Define hooks for lifecycle events:

    apiVersion: batch/v1
    kind: Job
    metadata:
      name: "{{ include "my-app.fullname" . }}-pre-upgrade"
      annotations:
        "helm.sh/hook": pre-upgrade
        "helm.sh/hook-weight": "-5"
        "helm.sh/hook-delete-policy": hook-succeeded
    spec:
      template:
        spec:
          containers:
          - name: pre-upgrade
            image: busybox
            command: ["/bin/sh", "-c", "echo 'Running pre-upgrade hook'"]
          restartPolicy: Never

    4. Document Your Chart

    Include a README.md in your chart directory:

    # My App Helm Chart
     
    A Helm chart for deploying my application to Kubernetes.
     
    ## Installation
     
    ```bash
    helm repo add my-repo http://your-repo-url
    helm install my-app my-repo/my-app

    Configuration

    See values.yaml for all configurable parameters.

    Values

    KeyTypeDefaultDescription
    replicaCountint2Number of replicas
    image.repositorystring"myregistry/my-app"Image repository
    image.tagstring"1.0.0"Image tag
    
    ### 5. Use Labels and Annotations
    
    Add consistent labels for observability:
    
    ```yaml
    metadata:
      labels:
        app.kubernetes.io/name: {{ include "my-app.name" . }}
        app.kubernetes.io/instance: {{ .Release.Name }}
        app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
        app.kubernetes.io/managed-by: {{ .Release.Service }}

    6. Handle Secrets Properly

    Never hardcode secrets in templates. Use Helm secrets or external secret managers:

    # Use a secret
    apiVersion: v1
    kind: Secret
    metadata:
      name: {{ include "my-app.fullname" . }}-config
    type: Opaque
    stringData:
      api-key: {{ .Values.secrets.apiKey }}

    7. Support Multiple Environments

    Create environment-specific value files:

    helm install my-app my-repo/my-app -f values.yaml -f values-staging.yaml
    helm install my-app my-repo/my-app -f values.yaml -f values-production.yaml

    Common Pitfalls

    1. Forgetting to Quote Values

    # WRONG - String values without quotes
    image: {{ .Values.image.repository }}
     
    # CORRECT - Quote string values
    image: "{{ .Values.image.repository }}"

    2. Using Hardcoded Values

    # WRONG - Hardcoded values
    replicaCount: 3
     
    # CORRECT - Use values
    replicaCount: {{ .Values.replicaCount }}

    3. Not Using Helper Templates

    # WRONG - Repeating name generation logic
    metadata:
      name: {{ .Release.Name }}-my-app
     
    # CORRECT - Use helper template
    metadata:
      name: {{ include "my-app.fullname" . }}

    4. Ignoring Namespace

    # WRONG - Hardcoded namespace
    metadata:
      namespace: production
     
    # CORRECT - Use release namespace
    metadata:
      namespace: {{ .Release.Namespace }}

    Advanced Topics

    Multi-Chart Applications

    Create a parent chart that depends on child charts:

    # Chart.yaml
    dependencies:
      - name: database
        version: 1.0.0
        repository: https://charts.bitnami.com/bitnami
      - name: redis
        version: 1.0.0
        repository: https://charts.bitnami.com/bitnami

    Using Helm Plugins

    Install plugins to extend Helm functionality:

    helm plugin install https://github.com/helm/helm-secrets
    helm secrets encrypt values.yaml

    Customizing the helm install Output

    Modify templates/NOTES.txt to provide post-install instructions:

    Thank you for installing {{ .Chart.Name }}!
    
    Your release is named {{ .Release.Name }}.
    
    To learn more about the release, try:
    
      $ helm status {{ .Release.Name }}
      $ helm get all {{ .Release.Name }}

    Conclusion

    Helm charts provide a powerful way to package and deploy Kubernetes applications. By following the structure and best practices outlined in this guide, you can create production-ready charts that are easy to maintain and share across your organization.

    Remember that a good chart is not just about deploying resources—it's about providing a consistent, predictable way to configure and manage your applications. Take the time to document your chart, validate your values, and test thoroughly before publishing.

    Platforms like ServerlessBase simplify the deployment process by handling the reverse proxy configuration and SSL certificate provisioning automatically, so you can focus on creating excellent Helm charts rather than managing infrastructure manually.

    The next step is to start building your own charts. Begin with a simple application, then gradually add complexity as you become more comfortable with Helm's template system. Before long, you'll be creating charts that make Kubernetes deployment a breeze for your entire team.

    Leave comment