Back to Blog
8 min read

Helm Charts for Azure Kubernetes Service

Introduction

Helm is the package manager for Kubernetes, enabling you to define, install, and upgrade complex Kubernetes applications. For Azure Kubernetes Service (AKS), Helm charts streamline deployments with Azure-specific configurations like managed identities, Key Vault integration, and Azure Monitor. This guide covers creating production-ready Helm charts for AKS.

Chart Structure

myapp/
├── Chart.yaml
├── values.yaml
├── values-dev.yaml
├── values-prod.yaml
├── templates/
│   ├── _helpers.tpl
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── hpa.yaml
│   ├── serviceaccount.yaml
│   ├── configmap.yaml
│   ├── secret.yaml
│   ├── pdb.yaml
│   └── tests/
│       └── test-connection.yaml
└── charts/

Chart.yaml

apiVersion: v2
name: myapp
description: A Helm chart for deploying my application to AKS
type: application
version: 1.0.0
appVersion: "1.0.0"
keywords:
  - dotnet
  - api
  - azure
maintainers:
  - name: Platform Team
    email: platform@company.com
dependencies:
  - name: redis
    version: "17.x.x"
    repository: "https://charts.bitnami.com/bitnami"
    condition: redis.enabled

Values Configuration

values.yaml (Default)

# Default values for myapp
replicaCount: 2

  repository: myacr.azurecr.io/myapp
  pullPolicy: IfNotPresent
  tag: ""  # Overridden by appVersion

imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""

serviceAccount:
  create: true
  annotations:
    azure.workload.identity/client-id: ""
  name: ""

podAnnotations:
  prometheus.io/scrape: "true"
  prometheus.io/port: "80"
  prometheus.io/path: "/metrics"

podSecurityContext:
  fsGroup: 1000

securityContext:
  runAsNonRoot: true
  runAsUser: 1000
  capabilities:
    drop:
      - ALL
  readOnlyRootFilesystem: true

service:
  type: ClusterIP
  port: 80

ingress:
  enabled: true
  className: "nginx"
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
  hosts:
    - host: api.myapp.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: api-tls
      hosts:
        - api.myapp.com

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

autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70
  targetMemoryUtilizationPercentage: 80

nodeSelector:
  kubernetes.io/os: linux

tolerations: []

affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 100
        podAffinityTerm:
          labelSelector:
            matchExpressions:
              - key: app.kubernetes.io/name
                operator: In
                values:
                  - myapp
          topologyKey: topology.kubernetes.io/zone

# Azure-specific configuration
azure:
  keyVault:
    enabled: true
    name: kv-myapp-prod
    tenantId: ""
    secrets:
      - secretName: connection-string
        keyVaultKey: DatabaseConnectionString
      - secretName: api-key
        keyVaultKey: ExternalApiKey

  appInsights:
    enabled: true
    connectionString: ""

  managedIdentity:
    enabled: true
    clientId: ""

# Application configuration
config:
  aspnetcoreEnvironment: Production
  logLevel: Information

# Feature flags
features:
  enableSwagger: false
  enableHealthUI: true

# Redis subchart configuration
redis:
  enabled: true
  architecture: standalone
  auth:
    enabled: true
    existingSecret: redis-secret

values-prod.yaml

replicaCount: 3

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

autoscaling:
  minReplicas: 3
  maxReplicas: 20

ingress:
  hosts:
    - host: api.myapp.com
      paths:
        - path: /
          pathType: Prefix

azure:
  keyVault:
    name: kv-myapp-prod
  managedIdentity:
    clientId: "prod-client-id"

config:
  aspnetcoreEnvironment: Production
  logLevel: Warning

features:
  enableSwagger: false

Template Files

_helpers.tpl

{{/*
Expand the name of the chart.
*/}}
{{- define "myapp.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Create a default fully qualified app name.
*/}}
{{- define "myapp.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 "myapp.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Common labels
*/}}
{{- define "myapp.labels" -}}
helm.sh/chart: {{ include "myapp.chart" . }}
{{ include "myapp.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
Selector labels
*/}}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/*
Create the name of the service account to use
*/}}
{{- define "myapp.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "myapp.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "myapp.fullname" . }}
  labels:
    {{- include "myapp.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "myapp.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
        {{- with .Values.podAnnotations }}
        {{- toYaml . | nindent 8 }}
        {{- end }}
      labels:
        {{- include "myapp.selectorLabels" . | nindent 8 }}
        {{- if .Values.azure.managedIdentity.enabled }}
        azure.workload.identity/use: "true"
        {{- end }}
    spec:
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      serviceAccountName: {{ include "myapp.serviceAccountName" . }}
      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: {{ .Chart.Name }}
          securityContext:
            {{- toYaml .Values.securityContext | nindent 12 }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: 80
              protocol: TCP
          env:
            - name: ASPNETCORE_ENVIRONMENT
              value: {{ .Values.config.aspnetcoreEnvironment | quote }}
            - name: Logging__LogLevel__Default
              value: {{ .Values.config.logLevel | quote }}
            {{- if .Values.azure.appInsights.enabled }}
            - name: APPLICATIONINSIGHTS_CONNECTION_STRING
              valueFrom:
                secretKeyRef:
                  name: {{ include "myapp.fullname" . }}-secrets
                  key: appinsights-connection-string
            {{- end }}
            {{- if .Values.redis.enabled }}
            - name: Redis__ConnectionString
              valueFrom:
                secretKeyRef:
                  name: redis-secret
                  key: redis-password
            {{- end }}
          envFrom:
            - configMapRef:
                name: {{ include "myapp.fullname" . }}-config
          {{- if .Values.azure.keyVault.enabled }}
          volumeMounts:
            - name: secrets-store
              mountPath: "/mnt/secrets-store"
              readOnly: true
          {{- end }}
          livenessProbe:
            httpGet:
              path: /health/live
              port: http
            initialDelaySeconds: 30
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /health/ready
              port: http
            initialDelaySeconds: 5
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 3
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
      {{- if .Values.azure.keyVault.enabled }}
      volumes:
        - name: secrets-store
          csi:
            driver: secrets-store.csi.k8s.io
            readOnly: true
            volumeAttributes:
              secretProviderClass: {{ include "myapp.fullname" . }}-secrets
      {{- end }}
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.affinity }}
      affinity:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}

secretproviderclass.yaml (Key Vault Integration)

{{- if .Values.azure.keyVault.enabled }}
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: {{ include "myapp.fullname" . }}-secrets
  labels:
    {{- include "myapp.labels" . | nindent 4 }}
spec:
  provider: azure
  parameters:
    usePodIdentity: "false"
    useVMManagedIdentity: "false"
    clientID: {{ .Values.azure.managedIdentity.clientId | quote }}
    keyvaultName: {{ .Values.azure.keyVault.name | quote }}
    tenantId: {{ .Values.azure.keyVault.tenantId | quote }}
    objects: |
      array:
        {{- range .Values.azure.keyVault.secrets }}
        - |
          objectName: {{ .keyVaultKey }}
          objectType: secret
        {{- end }}
  secretObjects:
    - secretName: {{ include "myapp.fullname" . }}-kv-secrets
      type: Opaque
      data:
        {{- range .Values.azure.keyVault.secrets }}
        - objectName: {{ .keyVaultKey }}
          key: {{ .secretName }}
        {{- end }}
{{- end }}

hpa.yaml

{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "myapp.fullname" . }}
  labels:
    {{- include "myapp.labels" . | nindent 4 }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "myapp.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 }}

Deployment Commands

# Add Helm repo for dependencies
helm dependency update ./myapp

# Install to development
helm install myapp ./myapp \
  --namespace dev \
  --create-namespace \
  -f ./myapp/values-dev.yaml \
  --set image.tag=1.0.0

# Upgrade production
helm upgrade myapp ./myapp \
  --namespace prod \
  -f ./myapp/values-prod.yaml \
  --set image.tag=1.0.1 \
  --atomic \
  --timeout 10m

# Rollback if needed
helm rollback myapp 1 --namespace prod

# Template for debugging
helm template myapp ./myapp -f ./myapp/values-prod.yaml > output.yaml

Azure DevOps Pipeline

trigger:
  - main

variables:
  chartPath: './charts/myapp'
  acrName: 'myacr'
  aksClusterName: 'aks-prod'
  aksResourceGroup: 'rg-aks-prod'

stages:
  - stage: Build
    jobs:
      - job: BuildAndPush
        steps:
          - task: Docker@2
            inputs:
              containerRegistry: 'ACR'
              repository: 'myapp'
              command: 'buildAndPush'
              Dockerfile: '**/Dockerfile'
              tags: '$(Build.BuildId)'

  - stage: Deploy
    jobs:
      - deployment: DeployToAKS
        environment: 'production'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: HelmDeploy@0
                  inputs:
                    connectionType: 'Azure Resource Manager'
                    azureSubscription: 'Azure-Prod'
                    azureResourceGroup: '$(aksResourceGroup)'
                    kubernetesCluster: '$(aksClusterName)'
                    namespace: 'prod'
                    command: 'upgrade'
                    chartType: 'FilePath'
                    chartPath: '$(chartPath)'
                    releaseName: 'myapp'
                    valueFile: '$(chartPath)/values-prod.yaml'
                    overrideValues: 'image.tag=$(Build.BuildId)'
                    arguments: '--atomic --timeout 10m'

Conclusion

Helm charts provide a powerful way to package and deploy applications to AKS with Azure-specific integrations. By leveraging values files for environment-specific configurations, Azure Key Vault for secrets, and managed identities for authentication, you create secure, maintainable deployments. Combined with CI/CD pipelines, Helm enables reliable, repeatable deployments across environments.

References

Michael John Peña

Michael John Peña

Senior Data Engineer based in Sydney. Writing about data, cloud, and technology.