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!
These amazing companies help us create free, high-quality DevOps content for the community
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
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?