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.
Comments