Day 10 - Wrap the App in a Helm Chart
Package your Kubernetes application as a Helm chart for easy deployment and management across environments.
Description
You've been manually applying Kubernetes YAML files for each environment (dev, staging, prod), copying and modifying values each time. There's a better way: Helm charts package everything together with templating for easy customization.
Task
Create a Helm chart for your application.
Requirements:
- Package deployment, service, and configmap as chart
- Use templating for environment-specific values
- Support multiple environments via values files
- Include proper chart metadata
- Test chart deployment
Target
- ✅ Working Helm chart structure
- ✅ Templated resources
- ✅ Values files for dev/staging/prod
- ✅ Chart validates successfully
- ✅ Deploys to Kubernetes cluster
Sample App
Chart Directory Structure
demo-app/
├── Chart.yaml # Chart metadata
├── values.yaml # Default values
├── values-dev.yaml # Dev overrides
├── values-staging.yaml # Staging overrides
├── values-prod.yaml # Production overrides
├── templates/
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── configmap.yaml
│ ├── ingress.yaml
│ ├── hpa.yaml
│ ├── _helpers.tpl # Template helpers
│ └── NOTES.txt # Post-install notes
├── charts/ # Dependency charts
└── .helmignore # Files to ignore
View Solution
Solution
Chart Files
Chart.yaml
apiVersion: v2
name: demo-app
description: A Helm chart for deploying the demo application
type: application
version: 1.0.0
appVersion: "1.0.0"
keywords:
- demo
- nodejs
- example
maintainers:
- name: DevOps Team
email: [email protected]
home: https://github.com/yourorg/demo-app
sources:
- https://github.com/yourorg/demo-app
dependencies: []
annotations:
category: Application
licenses: MIT
values.yaml
# Default values for demo-app
replicaCount: 2
image:
repository: your-registry/demo-app
pullPolicy: IfNotPresent
tag: "" # Defaults to chart appVersion
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
annotations: {}
name: ""
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
service:
type: ClusterIP
port: 80
targetPort: 3000
annotations: {}
ingress:
enabled: false
className: nginx
annotations: {}
hosts:
- host: demo-app.local
paths:
- path: /
pathType: Prefix
tls: []
resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
nodeSelector: {}
tolerations: []
affinity: {}
config:
appName: "Demo App"
logLevel: "info"
nodeEnv: "production"
env:
- name: PORT
value: "3000"
healthCheck:
liveness:
enabled: true
path: /health
initialDelaySeconds: 10
periodSeconds: 10
readiness:
enabled: true
path: /ready
initialDelaySeconds: 5
periodSeconds: 5
templates/_helpers.tpl
{{/*
Expand the name of the chart.
*/}}
{{- define "demo-app.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "demo-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 "demo-app.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "demo-app.labels" -}}
helm.sh/chart: {{ include "demo-app.chart" . }}
{{ include "demo-app.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "demo-app.selectorLabels" -}}
app.kubernetes.io/name: {{ include "demo-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "demo-app.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "demo-app.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Image name
*/}}
{{- define "demo-app.image" -}}
{{- $tag := .Values.image.tag | default .Chart.AppVersion }}
{{- printf "%s:%s" .Values.image.repository $tag }}
{{- end }}
templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "demo-app.fullname" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "demo-app.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "demo-app.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "demo-app.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: {{ include "demo-app.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
env:
{{- range .Values.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
envFrom:
- configMapRef:
name: {{ include "demo-app.fullname" . }}
{{- if .Values.healthCheck.liveness.enabled }}
livenessProbe:
httpGet:
path: {{ .Values.healthCheck.liveness.path }}
port: http
initialDelaySeconds: {{ .Values.healthCheck.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.healthCheck.liveness.periodSeconds }}
{{- end }}
{{- if .Values.healthCheck.readiness.enabled }}
readinessProbe:
httpGet:
path: {{ .Values.healthCheck.readiness.path }}
port: http
initialDelaySeconds: {{ .Values.healthCheck.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.healthCheck.readiness.periodSeconds }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ include "demo-app.fullname" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "demo-app.selectorLabels" . | nindent 4 }}
templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "demo-app.fullname" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
data:
APP_NAME: {{ .Values.config.appName | quote }}
LOG_LEVEL: {{ .Values.config.logLevel | quote }}
NODE_ENV: {{ .Values.config.nodeEnv | quote }}
templates/ingress.yaml
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "demo-app.fullname" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "demo-app.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
templates/hpa.yaml
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "demo-app.fullname" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "demo-app.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}
templates/NOTES.txt
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "demo-app.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "demo-app.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "demo-app.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "demo-app.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}
Environment-Specific Values
values-dev.yaml
replicaCount: 1
image:
tag: "dev-latest"
pullPolicy: Always
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 50m
memory: 64Mi
config:
appName: "Demo App - DEV"
logLevel: "debug"
nodeEnv: "development"
ingress:
enabled: true
hosts:
- host: demo-dev.example.com
paths:
- path: /
pathType: Prefix
autoscaling:
enabled: false
values-prod.yaml
replicaCount: 5
image:
tag: "1.0.0"
pullPolicy: IfNotPresent
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 250m
memory: 256Mi
config:
appName: "Demo App"
logLevel: "warn"
nodeEnv: "production"
service:
type: LoadBalancer
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: nlb
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: demo.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: demo-app-tls
hosts:
- demo.example.com
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 10
targetCPUUtilizationPercentage: 70
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- demo-app
topologyKey: kubernetes.io/hostname
Explanation
Helm Concepts
1. Templates
Go templating with Helm functions:
name: {{ include "demo-app.fullname" . }}
{{ }}: Template action.Values: Access values.yaml.Chart: Chart metadata.Release: Release informationinclude: Include helper template
2. Values Hierarchy
Merged in order:
- Default
values.yaml - Environment-specific
values-<env>.yaml --setflags
helm install my-app ./demo-app -f values-prod.yaml --set replicaCount=10
3. Helpers (_helpers.tpl)
Reusable template snippets:
{{- define "demo-app.labels" -}}
app: {{ .Chart.Name }}
version: {{ .Chart.Version }}
{{- end }}
Use with: {{- include "demo-app.labels" . | nindent 4 }}
Try to solve the challenge yourself first!
Click "Reveal Solution" when you're ready to see the answer.
Result
Install the Chart
# Lint the chart
helm lint demo-app/
# Dry run to see generated manifests
helm install my-app demo-app/ --dry-run --debug
# Install to dev
helm install my-app-dev demo-app/ \
-f demo-app/values-dev.yaml \
--namespace demo-dev \
--create-namespace
# Output:
# NAME: my-app-dev
# LAST DEPLOYED: Fri Dec 8 12:00:00 2023
# NAMESPACE: demo-dev
# STATUS: deployed
# REVISION: 1
# NOTES:
# 1. Get the application URL by running these commands:
# export POD_NAME=$(kubectl get pods --namespace demo-dev -l "app.kubernetes.io/name=demo-app" -o jsonpath="{.items[0].metadata.name}")
# kubectl --namespace demo-dev port-forward $POD_NAME 8080:3000
# List releases
helm list -n demo-dev
# Check deployment
kubectl get all -n demo-dev
Upgrade Release
# Modify values
echo "replicaCount: 3" >> values-dev.yaml
# Upgrade
helm upgrade my-app-dev demo-app/ \
-f demo-app/values-dev.yaml \
-n demo-dev
# Check history
helm history my-app-dev -n demo-dev
Rollback
# Rollback to previous version
helm rollback my-app-dev -n demo-dev
# Rollback to specific revision
helm rollback my-app-dev 1 -n demo-dev
Validation
Testing Checklist
# 1. Lint the chart
helm lint demo-app/
# Should return: 1 chart(s) linted, 0 chart(s) failed
# 2. Template validation
helm template my-app demo-app/ -f demo-app/values-dev.yaml > /tmp/rendered.yaml
kubectl apply --dry-run=client -f /tmp/rendered.yaml
# Should validate successfully
# 3. Install and verify
helm install test-release demo-app/ -f demo-app/values-dev.yaml --namespace test --create-namespace
kubectl get pods -n test
# Pods should be Running
# 4. Test upgrade
helm upgrade test-release demo-app/ --set replicaCount=2 -n test
# Should succeed
# 5. Verify values applied
kubectl get deployment -n test -o jsonpath='{.items[0].spec.replicas}'
# Should return: 2
# 6. Cleanup
helm uninstall test-release -n test
kubectl delete namespace test
Best Practices
✅ Do's
- Use helpers: Keep templates DRY
- Version charts: Semantic versioning
- Default values: Sensible defaults in values.yaml
- Document values: Comment all options
- Test thoroughly: Lint, template, and test deploys
- Use checksum annotations: Force pod restart on config changes
❌ Don'ts
- Don't hardcode: Use values for everything
- Don't skip linting: Catch errors early
- Don't ignore NOTES.txt: Help users
- Don't forget .helmignore: Exclude unnecessary files
- Don't make charts too complex: Keep them focused
Links
Share Your Success
Created your first Helm chart? Share it!
Tag @thedevopsdaily on X with:
- Chart name and purpose
- Number of templates
- Environments supported
- Link to chart repo
Use hashtags: #AdventOfDevOps #Helm #Kubernetes #Day10
Ready to complete this challenge?
Mark this challenge as complete once you've finished the task. We'll track your progress!
Completed this challenge? Share your success!
Tag @thedevopsdaily on X (Twitter) and share your learning journey with the community!
We earn commissions when you shop through the links below.
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
Acronis
The most secure backup
Acronis: the most secure backup solution for your data
Pluralsight
Technology skills platform
Expert-led courses in software development, IT ops, data, and cybersecurity
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?