Skip to content
Back to Blog
1 min read

Helm Charts for Azure Kubernetes Service

I wrote “2021-06-30-helm-charts-azure” to share practical, production-minded guidance on this topic.

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.