The Practical Developer

Helm Charts in Production: Packaging Kubernetes Deployments Without the Pain

Your service needs a Deployment, Service, Ingress, ConfigMap, HorizontalPodAutoscaler, and PodDisruptionBudget. Without Helm, that is six YAML files you edit by hand every deploy. Here is the production Helm chart pattern with environment-specific values, CI/CD integration, and the conventions that keep charts maintainable.

Rows of server racks in a blue-lit data center, representing the infrastructure layer that Helm charts manage

Your team’s web service needs a Deployment, a Service, an Ingress, a ConfigMap, a HorizontalPodAutoscaler, and a PodDisruptionBudget. That is six YAML files. Each has a replicas field, an image tag, a set of environment variables, and a half-dozen other values that differ between staging and production. The current process for a new release is: open the staging YAML, update the image tag, check the replicas, check the env vars, commit, push, wait for ArgoCD, and repeat for production while hoping nobody forgets to change the env: prod label in the ConfigMap.

Last week someone did forget. Staging got deployed with production’s database connection string. Nobody noticed for four hours because staging was using a read replica that happened to work. The fix was a forced restart and a rotation of a credential that should never have been shared.

This is the problem Helm solves. It packages all six YAML files into one chart, parameterizes the differences with a values.yaml file, and lets you deploy any environment with a single helm upgrade --install command. The image tag, the replicas, the environment variables, the ingress hostname, everything that changes between environments goes in a values file, and everything that stays the same lives in the templates.

This post is the production Helm setup: the chart structure that scales, the template patterns that do not make you hate Go templates, the environment strategy, the CI/CD integration, and the versioning convention that makes rollbacks a one-flag operation.

What Helm actually gives you

Helm is three things bundled into one CLI:

Templating. Your raw Kubernetes YAML becomes Go templates with values injected from a values.yaml file. One chart produces the right manifests for dev, staging, and production by swapping values files.

Packaging. A chart is a versioned, portable directory you can publish to a registry (OCI or classic Helm repo), download, and install. Your chart is your deployable unit.

Lifecycle management. helm upgrade --install creates or updates a release. helm rollback reverts to a previous revision. helm history shows every change. You get deployment versioning without setting up a separate tool.

If you are not using Helm, your deployment process is either a shell script that runs kubectl apply -f against a directory of YAML files, or a GitOps tool (ArgoCD, Flux) that points at a monorepo of raw YAML. Both work. Both become painful at around five services because every environment variation requires either a copy-pasted YAML file or a fragile sed command in the deploy script.

A production chart structure

Start with this directory layout:

chart/
├── Chart.yaml
├── values.yaml
├── values.staging.yaml
├── values.production.yaml
├── templates/
│   ├── _helpers.tpl
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── configmap.yaml
│   ├── hpa.yaml
│   └── pdb.yaml
└── charts/

The Chart.yaml file sets the metadata:

apiVersion: v2
name: checkout-api
description: Checkout service for the product platform
type: application
version: 1.4.0
appVersion: 1.4.0

The version field is the chart version. The appVersion is the application version. They can move independently, which matters when you update manifests without deploying new code.

The master values.yaml file holds every sensible default:

# values.yaml
replicaCount: 2

image:
  repository: ghcr.io/myorg/checkout-api
  tag: latest
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 3000

ingress:
  enabled: true
  className: nginx
  host: checkout-api.staging.example.com
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod

config:
  env: staging
  logLevel: debug
  dbPoolMin: 2
  dbPoolMax: 10

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

autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 75

pdb:
  enabled: true
  minAvailable: 1

Every value has a default. Every value can be overridden by an environment-specific values file. A template that references a nonexistent value will fail at render time, not at runtime.

Templates that do not make you hate templates

Go templates in Helm are not pleasant to write. The syntax is ugly, the function list is long, and the error messages are unhelpful. The trick is to keep templates simple and push complexity into values.yaml.

A clean deployment template looks like this:

{{- template "checkout-api.name" . }}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "checkout-api.fullname" . }}
  labels:
    {{- include "checkout-api.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "checkout-api.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "checkout-api.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.service.port }}
              protocol: TCP
          envFrom:
            - configMapRef:
                name: {{ include "checkout-api.fullname" . }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

Three rules to follow:

Use the _helpers.tpl naming conventions. Helm has a standard set of named templates (name, fullname, labels, selectorLabels) that every chart should include. They handle truncation, unique naming, and label consistency.

Keep logic out of templates. Do not write {{- if and .Values.ingress.enabled (eq .Values.config.env "production") -}} in a template. Either put the complex condition in a helper, or encode it as a single boolean in values. Templates are hard to test. Values files are easy to read.

Use toYaml and nindent for structured blocks. Instead of listing every resource field in template YAML, pass structured blocks through from values:

# values.yaml
extraEnv:
  - name: NODE_OPTIONS
    value: "--max-old-space-size=512"
  - name: SENTRY_DSN
    value: "https://key@sentry.io/project"
# templates/deployment.yaml
{{- with .Values.extraEnv }}
env:
  {{- toYaml . | nindent 12 }}
{{- end }}

This pattern handles environment variables, sidecar containers, volumes, init containers, and any other variable-length list without template gymnastics.

The _helpers.tpl that every chart needs

Put this in your templates/_helpers.tpl:

{{- define "checkout-api.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "checkout-api.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 }}

{{- define "checkout-api.labels" -}}
helm.sh/chart: {{ include "checkout-api.name" . }}-{{ .Chart.Version | replace "+" "_" }}
{{ include "checkout-api.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{- define "checkout-api.selectorLabels" -}}
app.kubernetes.io/name: {{ include "checkout-api.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

These four named templates appear in almost every Helm chart. They are Helm’s version of a linter rule: predictable, standard, and they prevent silly naming collisions.

Environment-specific values files

The environment values files override only the values that change:

# values.staging.yaml
replicaCount: 2
image:
  tag: staging-abc1234
ingress:
  host: checkout-api.staging.example.com
config:
  env: staging
  logLevel: debug
  dbPoolMax: 10
# values.production.yaml
replicaCount: 6
image:
  tag: v1.4.0
ingress:
  host: checkout-api.example.com
config:
  env: production
  logLevel: info
  dbPoolMax: 25

Deploy with:

helm upgrade --install checkout-api ./chart \
  --namespace checkout \
  --values ./chart/values.yaml \
  --values ./chart/values.production.yaml

Helm merges values files in order. Later files override earlier ones. The base values.yaml provides every default, the environment file overrides what is different, and any one-off override can pass --set image.tag=v1.4.1 on the command line.

Do not put secrets in values files. Use --set with values from your CI secrets manager, or better, use External Secrets Operator or Sealed Secrets to pull secrets from Vault or AWS Secrets Manager at runtime.

CI/CD integration

A Helm deployment in CI is a four-step pipeline:

# .github/workflows/deploy.yml
deploy:
  runs-on: ubuntu-22.04
  steps:
    - uses: actions/checkout@v4

    - name: Lint chart
      run: helm lint ./chart

    - name: Render manifests
      run: |
        helm template checkout-api ./chart \
          --values ./chart/values.yaml \
          --values ./chart/values.${{ inputs.environment }}.yaml \
          > /tmp/rendered.yaml

    - name: Deploy
      run: |
        helm upgrade --install checkout-api ./chart \
          --namespace checkout \
          --create-namespace \
          --values ./chart/values.yaml \
          --values ./chart/values.${{ inputs.environment }}.yaml \
          --set image.tag=${{ inputs.tag }}

The helm lint step catches YAML syntax errors and missing required values. The helm template step lets you inspect the rendered output in CI logs without touching the cluster. The actual deploy uses helm upgrade --install, which is idempotent: run it a hundred times and the cluster state is the same as after one run.

For GitOps workflows (ArgoCD, Flux), the chart lives in a repository and the GitOps tool runs helm template and applies the result. The chart structure is the same; only the deployment mechanism changes.

Versioning and rollback

Every helm upgrade creates a new revision. Run helm history checkout-api -n checkout to see them:

REVISION    UPDATED                    STATUS        CHART              APP VERSION
1           Tue Jun 10 14:22:01 2026   superseded    checkout-api-1.3.0 1.3.0
2           Wed Jun 11 09:15:32 2026   superseded    checkout-api-1.3.1 1.3.1
3           Thu Jun 12 11:04:17 2026   deployed      checkout-api-1.4.0 1.4.0

If revision 3 breaks, roll back:

helm rollback checkout-api 2 -n checkout

The rollback is a new revision (4) that re-applies the revision-2 manifests. It is not a git revert. It is not a redeploy of an old image. It is Helm re-running the templates with the old values and applying the result.

To make rollbacks reliable, version your chart in lockstep with your application. When you tag a release v1.4.0, bump both version and appVersion in Chart.yaml:

version: 1.4.0
appVersion: "1.4.0"

Now helm list shows exactly which version of the application is deployed, and helm rollback restores both the manifests and the semantic intent.

The three mistakes that break production charts

Mistake 1: Missing values validation. Helm 3 has built-in schema validation with a values.schema.json file in the chart root. Use it to enforce types and required fields:

{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["replicaCount", "image", "ingress", "config"],
  "properties": {
    "replicaCount": {
      "type": "integer",
      "minimum": 1
    },
    "image": {
      "type": "object",
      "required": ["repository", "tag"],
      "properties": {
        "repository": { "type": "string" },
        "tag": { "type": "string" }
      }
    }
  }
}

Apply the schema at deploy time:

$ helm template ./chart --values values.production.yaml --validate
Error: values don't meet the specifications of the schema(s) in the following chart(s):
checkout-api:
- replicaCount: Invalid type. Expected: integer, Given: string

Without this, a typo like replicaCount: "6" (string instead of integer) silently passes through and produces a broken Deployment.

Mistake 2: Storing secrets in values.yaml. A values file in git is not encrypted. A --set flag printed in CI logs is visible to anyone with access. Use External Secrets Operator, Sealed Secrets, or a Vault sidecar to inject secrets at runtime. The chart should reference a secret name, not a secret value.

Mistake 3: Overriding too much in --set. Every --set image.tag=v1.4.1 on the command line is a deployment that cannot be reproduced from the values files alone. If you rely on CI variables for critical values, your helm rollback might recreate the right chart version but the wrong image tag. Always encode the full release state in values files, and use --set only for one-off debugging.

When Helm is not the right tool

Helm is good for single-service deployments and shared infrastructure components (ingress controllers, monitoring stacks, cert-manager). It is less good when:

  • Every service has a wildly different set of resources. If each service needs a unique combination of a dozen resource types, the template overhead outweighs the benefit. Raw YAML or a tool like kustomize (which overlays patches without templates) may be simpler.

  • You need to manage cross-service dependencies. Helm has a dependencies feature in Chart.yaml, but it is designed for third-party chart composition, not for wiring up your own services. For cross-service orchestration, use a higher-level tool like ArgoCD ApplicationSets or a Pulumi/CDK program.

  • Your team does not know Go templates. This is a real constraint. Helm templates are the worst part of Helm. If the team cannot read a {{- range $key, $value := .Values.configMap -}} block without a documentation tab open, you will accumulate technical debt in the templates. Consider kustomize as a template-free alternative, or invest in writing clean helpers that hide the template complexity.

The practical takeaway

A Helm chart is the packaging layer your Kubernetes deployments are missing. It standardizes the deploy process, eliminates copy-paste errors between environments, and gives you versioned rollbacks without adding another control plane.

Start small. Pick one service that has at least four YAML files. Build a chart for it. Deploy it to staging with helm upgrade --install. Add a values.production.yaml with the differences. Drop helm lint into your CI pipeline. Expand to the rest of the services when the team sees the difference between “edit six files” and helm upgrade --install --values values.production.yaml.

The chart templates in this post are ready to copy. Adjust the names, fill in your values, and delete one deployment script from your repo.

A note from Yojji

The gap between “it works on my machine” and “it deploys reliably across three environments” is where most teams lose time. Packaging infrastructure into versioned, testable charts is the kind of production discipline that does not make the feature roadmap but prevents the Friday afternoon deploy that takes down staging. Yojji’s engineering teams build this discipline into every cloud-native product they ship, from the first chart to the CI pipeline that deploys it.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their senior teams specialize in the JavaScript ecosystem, cloud platforms (AWS, Azure, Google Cloud), and Kubernetes-native architectures, handling the full product cycle from discovery through DevOps and production operations.