Helm Charts on EKS: Packaging, Versioning, and Managing Kubernetes Applications

Bits Lovers
Written by Bits Lovers on
Helm Charts on EKS: Packaging, Versioning, and Managing Kubernetes Applications

Helm is the package manager for Kubernetes. Raw YAML manifests work fine for a single deployment in one environment. Once you need the same application in staging, production, and three team environments with slight differences in replicas, resource limits, and image tags, raw YAML becomes copy-paste maintenance hell. Helm templates the differences, packages everything into a versioned chart, and tracks what’s installed in each cluster through releases. A helm upgrade command rolls out a new version with rollback support if something breaks.

This guide covers chart structure, writing templates with values, using ECR as an OCI chart registry, managing multi-environment releases with Helmfile, and integrating with ArgoCD.

Chart Structure

# Create a new chart scaffold
helm create my-api

# Result:
# my-api/
#   Chart.yaml          # Chart metadata
#   values.yaml         # Default values
#   charts/             # Chart dependencies
#   templates/          # Kubernetes manifest templates
#     deployment.yaml
#     service.yaml
#     ingress.yaml
#     serviceaccount.yaml
#     hpa.yaml
#     _helpers.tpl       # Template helper functions
#     NOTES.txt          # Post-install instructions

Chart.yaml defines the chart’s identity:

# Chart.yaml
apiVersion: v2
name: my-api
description: My API service
type: application
version: 1.5.2      # Chart version — increment on chart changes
appVersion: "2.1.4" # Application version — what's inside the chart
dependencies:
  - name: postgresql
    version: "13.2.x"
    repository: https://charts.bitnami.com/bitnami
    condition: postgresql.enabled

Writing Templates

Templates use Go’s text/template with Helm’s Sprig functions. The `` syntax pulls values from values.yaml or override files:

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: 
  namespace: 
  labels:
spec:
  replicas: 
  selector:
    matchLabels:
  template:
    metadata:
      labels:
    spec:
      serviceAccountName: 
      containers:
        - name: 
          image: ":"
          imagePullPolicy: 
          ports:
            - name: http
              containerPort: 
          env:
            - name: 
              value: 
          resources:
          livenessProbe:
            httpGet:
              path: 
              port: http
            initialDelaySeconds: 
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: 
              port: http
            initialDelaySeconds: 5
            periodSeconds: 5
# values.yaml
replicaCount: 2

image:
  repository: 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-api
  pullPolicy: IfNotPresent
  tag: ""  # Defaults to Chart.AppVersion if empty

service:
  type: ClusterIP
  port: 8080

autoscaling:
  enabled: false
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

resources:
  limits:
    cpu: 500m
    memory: 512Mi
  requests:
    cpu: 100m
    memory: 128Mi

healthCheck:
  enabled: true
  path: /health
  initialDelaySeconds: 30

env: {}
  # KEY: value

_helpers.tpl contains reusable template functions. The generated one has my-api.fullname, my-api.labels, and my-api.selectorLabels. Customize them to include your required labels (cost center, team, environment):

# templates/_helpers.tplhelm.sh/chart: 

app.kubernetes.io/managed-by: 
app.kubernetes.io/version: 
environment: 
team: 

Installing and Managing Releases

# Install a release
helm install my-api ./my-api \
  --namespace my-api \
  --create-namespace \
  --values values/production.yaml \
  --set image.tag=2.1.4 \
  --set replicaCount=3

# Upgrade an existing release (--install creates if doesn't exist)
helm upgrade --install my-api ./my-api \
  --namespace my-api \
  --values values/production.yaml \
  --set image.tag=2.2.0 \
  --atomic \           # Roll back automatically if upgrade fails
  --cleanup-on-fail \  # Clean up new resources on failure
  --timeout 5m

# Check release status
helm status my-api -n my-api

# View rendered templates without installing (dry run)
helm template my-api ./my-api \
  --values values/production.yaml \
  --set image.tag=2.1.4

# Roll back to previous release
helm rollback my-api -n my-api

# Roll back to a specific revision
helm history my-api -n my-api
helm rollback my-api 3 -n my-api  # Roll back to revision 3

# Uninstall
helm uninstall my-api -n my-api

--atomic is the most important flag for production upgrades. It makes the upgrade wait for all pods to be ready, and automatically rolls back if any pod fails to become ready within the timeout. Without it, a failed upgrade leaves the cluster in a broken partial state.

Environment-Specific Values Files

Don’t use --set for anything complex. Keep overrides in versioned values files:

# values/staging.yaml
replicaCount: 1
image:
  tag: latest

resources:
  limits:
    cpu: 200m
    memory: 256Mi
  requests:
    cpu: 50m
    memory: 64Mi

env:
  DATABASE_URL: "postgresql://staging-db:5432/myapp"
  LOG_LEVEL: "debug"
  ENVIRONMENT: "staging"
# values/production.yaml
replicaCount: 3
image:
  tag: "2.1.4"  # Pinned version, updated by CI

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 20

resources:
  limits:
    cpu: 1000m
    memory: 1Gi
  requests:
    cpu: 200m
    memory: 256Mi

env:
  DATABASE_URL: "postgresql://prod-db:5432/myapp"
  LOG_LEVEL: "warn"
  ENVIRONMENT: "production"
# Apply staging
helm upgrade --install my-api ./my-api \
  -n staging --values values/staging.yaml

# Apply production
helm upgrade --install my-api ./my-api \
  -n production --values values/production.yaml

Never put passwords or tokens in values files. They end up in Git and in Kubernetes Secrets as base64 (not encrypted). Use the External Secrets Operator to pull from Secrets Manager, or pass sensitive values via --set from a CI secret:

helm upgrade --install my-api ./my-api \
  --values values/production.yaml \
  --set env.DB_PASSWORD="${DB_PASSWORD}"  # from CI environment variable

ECR as OCI Helm Chart Registry

ECR supports OCI artifacts, including Helm charts. Storing charts in ECR keeps everything in AWS without a separate chart registry:

# Authenticate to ECR OCI
aws ecr get-login-password --region us-east-1 | \
  helm registry login \
  --username AWS \
  --password-stdin \
  123456789012.dkr.ecr.us-east-1.amazonaws.com

# Create ECR repository for the chart
aws ecr create-repository \
  --repository-name helm-charts/my-api \
  --region us-east-1

# Package and push the chart
helm package my-api
helm push my-api-1.5.2.tgz oci://123456789012.dkr.ecr.us-east-1.amazonaws.com/helm-charts

# Install directly from ECR
helm install my-api \
  oci://123456789012.dkr.ecr.us-east-1.amazonaws.com/helm-charts/my-api \
  --version 1.5.2 \
  --namespace my-api \
  --values values/production.yaml

ECR chart repositories follow the same IAM permissions as container image repos. Your EKS node role or IRSA role needs ecr:GetAuthorizationToken and ecr:BatchGetImage permissions to pull charts.

Helmfile for Multi-Release Management

Helmfile declaratively manages multiple Helm releases across environments. Instead of maintaining scripts with helm upgrade commands, define all releases in a helmfile.yaml:

# helmfile.yaml
repositories:
  - name: bitnami
    url: https://charts.bitnami.com/bitnami

environments:
  staging:
    values:
      - environments/staging.yaml
  production:
    values:
      - environments/production.yaml

releases:
  - name: my-api
    chart: oci://123456789012.dkr.ecr.us-east-1.amazonaws.com/helm-charts/my-api
    version: 1.5.2
    namespace: my-api
    createNamespace: true
    values:
      - my-api/values/common.yaml
      - my-api/values/.yaml

  - name: redis
    chart: bitnami/redis
    version: "18.x.x"
    namespace: infrastructure
    values:
      - redis/values.yaml
    set:
      - name: auth.password
        value: 

  - name: nginx-ingress
    chart: ingress-nginx/ingress-nginx
    version: "4.x.x"
    namespace: ingress-nginx
    values:
      - ingress/values.yaml
# Preview changes (diff)
helmfile -e production diff

# Apply all releases
helmfile -e production apply

# Apply only specific release
helmfile -e production apply --selector name=my-api

# Destroy a specific release
helmfile -e staging delete --selector name=my-api

Helmfile’s diff command runs helm diff (requires the helm-diff plugin) and shows what would change before applying. This is the Helm equivalent of terraform plan — essential for production changes.

Helm Hooks

Hooks run Kubernetes jobs at specific points in the release lifecycle:

# templates/db-migrate-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: -migrate
  annotations:
    "helm.sh/hook": pre-upgrade,pre-install
    "helm.sh/hook-weight": "-5"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ":"
          command: ["python", "manage.py", "migrate"]
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: my-api-db-secret
                  key: url

pre-upgrade runs the database migration before deploying the new application version. hook-delete-policy: before-hook-creation,hook-succeeded cleans up the Job after it completes successfully (and removes a stuck previous job before creating a new one). Without deletion policies, failed hook jobs block future upgrades until manually cleaned up.

For ArgoCD deployments that use this chart, the ArgoCD on EKS guide covers how ArgoCD handles Helm hooks and how to configure sync waves for ordering operations across multiple Helm releases. The EKS RBAC guide covers service account permissions for the jobs these hooks run.

Bits Lovers

Bits Lovers

Professional writer and blogger. Focus on Cloud Computing.

Comments

comments powered by Disqus