Helm Chart Deployment on AKS
Deploy a Flask microservice to AKS using Helm charts across multiple environments.
Introduction
30 Second Summary
Last week, someone on the payments team deployed the wrong version of their app to staging because they copied a configuration file from dev and missed a single value. When every environment needs its own manually edited copy, mistakes like this are inevitable.
In this project, you will build a Helm chart that replaces hand-edited deployment files with a single reusable template. You will deploy it to two Kubernetes environments with different settings, then perform upgrades and rollbacks with one command.
What You'll Build
Picture running one command and watching your Flask microservice deploy to both dev and staging on AKS, each with its own replica count and image tag, all from a single Helm chart you wrote from scratch.
By the end of this project, you'll have:
- Two Helm releases in dev and staging, visible with a single helm list command, each running the same chart with different configurations.
- A one-command upgrade to push a new image version to dev, and a one-command rollback to revert instantly if anything goes wrong.
- A reusable template that deploys to any new environment with just a values file, replacing the copy-paste YAML workflow entirely.
- Secret Mission: Package your Helm chart and push it to ACR as an OCI artifact, ready for your CI/CD pipeline in the next project.
Are there any prerequisites?
This project continues from the Deploy ACR and AKS with Terraform project. You need familiarity with ACR, AKS, kubectl, Docker, and Azure CLI, plus an Azure subscription with pay-as-you-go billing.
Before We Start
Before we dive in, let's get clear on what you're about to build and why it matters for the payments team. This step is your chance to commit to the problem you're solving before any hands-on work begins.
Provision Infrastructure and Install Helm
In the previous project, you tore down your Terraform-managed infrastructure with terraform destroy. To start working with Helm, you need ACR and AKS back up and running.
This time you will use Azure CLI directly instead of Terraform. You already proved your Terraform proficiency, and single CLI commands are faster for re-provisioning when the learning objective is Helm, not infrastructure-as-code.
In this step, get ready to:
- Create an ACR and AKS cluster using Azure CLI.
- Install Helm on Windows and connect kubectl to the new cluster.
- Rebuild and push the Flask app Docker image to ACR.
Sign in to Azure and create your resources
Every Azure CLI session starts with authentication. After signing in, you will create a resource group to hold all of the cloud resources for this project.
- Open PowerShell on your Windows machine by pressing the Windows key, typing PowerShell, and pressing Enter.
- Sign in to Azure by running this command:
az login
A browser window opens. Sign in with your Azure account credentials. Once authenticated, you see your subscription details printed in the terminal.
- Create a resource group called cloud-app-rg by running this command:
az group create --name cloud-app-rg --location eastus
What does this command do?
A resource group is a logical container that holds all related Azure resources for a project. The --location flag sets the region where metadata is stored and where resources run by default.
You should see JSON output with "provisioningState": "Succeeded" confirming the resource group was created.
- Create the Azure Container Registry by running this command:
az acr create --name cloudappcr --resource-group cloud-app-rg --sku basic
What does this command do?
This creates a private container registry named cloudappcr with the Basic SKU. The Basic tier is a cost-optimized entry point for development purposes.
ACR names must be globally unique across Azure, 5-50 alphanumeric characters, and lowercase only. If cloudappcr is taken, append your initials or a short number.
Seeing a name conflict error?
ACR names are globally unique. If cloudappcr is already taken by another Azure user, pick a different name like cloudappcr123. Use your chosen name wherever you see cloudappcr in the rest of this project.
Help me fix my ACR name conflict error.
Wait for the JSON output showing "provisioningState": "Succeeded". Now create the AKS cluster.
- Create the AKS cluster and attach it to your ACR by running this command:
az aks create --resource-group cloud-app-rg --name cloud-app-aks --tier free --node-count 1 --node-vm-size Standard_B2s --generate-ssh-keys --attach-acr cloudappcr
What does this command do?
This creates a Free-tier AKS cluster named cloud-app-aks with a single Standard_B2s node. The Free tier means you pay only for the node VM, not the control plane.
The --attach-acr flag automatically configures the AcrPull role on the cluster's managed identity. This lets AKS pull images from your ACR without any extra role assignment commands.
This command takes 3-5 minutes to complete. You will see a large JSON block once it finishes.
Cluster creation failing?
If you see a quota error, your subscription may not have enough capacity in the eastus region for Standard_B2s VMs. Try a different region by deleting the resource group and recreating it with --location westus2.
Help me troubleshoot my AKS cluster creation error.
Install Helm and connect kubectl to the cluster
Helm is the package manager you will use to template and deploy your Kubernetes manifests. You also need to point kubectl at the new AKS cluster so you can interact with it.
- Install Helm by running this command:
winget install Helm.Helm
After installation completes, close and reopen PowerShell so the helm command is available on your PATH.
- Verify Helm is installed by running:
helm version
✔️ I see version 4.x
You should see output containing v4 in the version string (e.g. 4.2.4). Helm is ready to go.
ⓧ I see version 3.x
You have an older version of Helm installed. Helm v3 bug fixes ended July 8, 2026, and the project requires Helm 4.
- Upgrade Helm by running:
winget upgrade Helm.Helm
- Close and reopen PowerShell, then run helm version again to confirm v4 appears.
ⓧ Command not found
The helm binary may not be on your PATH yet.
- Close PowerShell completely and reopen it. Run helm version again.
- If it still fails, try installing with Chocolatey instead:
choco install kubernetes-helm
- Close and reopen PowerShell, then verify with helm version.
- Connect kubectl to your new AKS cluster by running this command:
az aks get-credentials --resource-group cloud-app-rg --name cloud-app-aks
What does this command do?
This downloads the cluster credentials and merges them into your local kubeconfig file. After running it, all kubectl commands will target the cloud-app-aks cluster.
- Verify the connection by running:
kubectl get nodes
You should see one node listed with a status of Ready. This confirms your AKS cluster is running and kubectl can communicate with it.
Node not showing Ready?
The node can take a couple of minutes to become Ready after cluster creation. Wait 30 seconds and run kubectl get nodes again.
Help me troubleshoot my kubectl connection to AKS.
Rebuild and push the Flask app image to ACR
The AKS cluster needs a container image to run. You will recreate the Flask app files from Project 2 and push the built image to your new ACR.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create a project folder and move into it by running these commands:
mkdir cloud-app
cd cloud-app
- Create the Flask application file by running this command:
New-Item app.py
- Open app.py in a text editor and paste the following code:
from flask import Flask, jsonify
import os
# Create the Flask application
app = Flask(__name__)
# Health check endpoint for Kubernetes probes
@app.route('/health')
def health():
return jsonify({"status": "healthy"})
# Main endpoint returning app info
@app.route('/')
def home():
return jsonify({
"app": "cloud-app",
"version": "v1",
"environment": os.getenv("ENVIRONMENT", "default")
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
What does this code do?
This is the same Flask microservice from Project 2. It returns a JSON response with the app name, version, and environment. The /health endpoint is used by Kubernetes liveness and readiness probes.
The ENVIRONMENT variable defaults to "default" but will be overridden by your Helm chart in later steps.
- Save app.py.
- Create the requirements file by running:
New-Item requirements.txt
- Open requirements.txt and paste the following:
flask==3.1.1
- Save requirements.txt.
- Create the Dockerfile by running:
New-Item Dockerfile
- Open Dockerfile and paste the following:
# Use a slim Python base image
FROM python:3.12-slim
# Set the working directory
WORKDIR /app
# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the application code
COPY app.py .
# Expose the application port
EXPOSE 5000
# Start the Flask app
CMD ["python", "app.py"]
What does this Dockerfile do?
It starts from the python:3.12-slim base image, installs Flask from requirements.txt, copies your app code, and runs the Flask server on port 5000.
- Save Dockerfile.
- Confirm all three files exist by running:
ls
You should see app.py, requirements.txt, and Dockerfile listed in the output.
- Log in to your ACR so Docker can push images to it:
az acr login --name cloudappcr
You should see Login Succeeded in the output.
- Build the Docker image and tag it for your ACR by running this command:
docker build -t cloudappcr.azurecr.io/cloud-app:v1 .
What does this command do?
The -t flag tags the image with the full ACR path cloudappcr.azurecr.io/cloud-app:v1 so Docker knows where to push it. The . at the end tells Docker to use the current directory as the build context.
- Push the image to ACR by running:
docker push cloudappcr.azurecr.io/cloud-app:v1
You see layer progress bars as each layer uploads. Once complete, your image is stored in ACR and ready for AKS to pull.
Push denied or unauthorized error?
Make sure you ran az acr login --name cloudappcr before the push. The login token expires after 3 hours, so re-run it if you waited too long between login and push.
Also confirm Docker Desktop is running. az acr login needs the Docker daemon to cache the authentication token.
Help me fix my Docker push to ACR error.
Before you move on, do you think helm version and kubectl get nodes will still show the same results if you run them again now?
- Verify the end-to-end setup by running these commands:
helm version
kubectl get nodes
You should see Helm 4.x version output and one node in Ready status. Your infrastructure is fully provisioned.
Your cloud infrastructure is back online and Helm is installed. Next up, you will deploy the Flask app using raw kubectl to two different namespaces and experience firsthand why that approach falls apart at scale.
Deploy with kubectl and Feel the Duplication Pain
Your AKS cluster is running, your Flask image is in ACR, and Helm is installed. Before you use Helm, though, you need to experience the problem it was built to solve.
In this step, you will deploy the same Flask app to two separate Kubernetes namespaces using raw kubectl manifests. The second deployment will require copying and manually editing YAML files. This is exactly how the payments team's staging deployment went wrong.
In this step, get ready to:
- Create dev and staging namespaces and deploy the Flask app to dev.
- Copy and manually edit YAML files to deploy a different configuration to staging.
- Compare the two deployment files and see the configuration drift risk firsthand.
Create namespaces and deploy to dev
Kubernetes uses namespaces to isolate workloads inside the same cluster. You will create one for dev and one for staging so each environment gets its own space.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create a project folder and move into it by running these commands:
mkdir helm-project
cd helm-project
- Create the dev and staging namespaces by running these commands:
kubectl create namespace dev
kubectl create namespace staging
- Confirm both namespaces exist by running:
kubectl get namespaces
You should see dev and staging listed alongside the default Kubernetes namespaces.
What are Kubernetes namespaces?
A namespace is a virtual boundary inside a cluster. Resources in one namespace are isolated from resources in another, so you can run the same app in dev and staging without name collisions.
Now you need a Deployment manifest that tells Kubernetes how to run your Flask app and a Service manifest that exposes it to the internet.
- Create a new file called dev-deployment.yaml in your helm-project folder and add the following content:
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloud-app
namespace: dev
labels:
app: cloud-app
environment: dev # Labels this deployment as dev
spec:
replicas: 1 # Single replica for development
selector:
matchLabels:
app: cloud-app
template:
metadata:
labels:
app: cloud-app
environment: dev
spec:
containers:
- name: cloud-app
image: cloudappcr.azurecr.io/cloud-app:v1 # Flask image from ACR
ports:
- containerPort: 5000 # Flask default port
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "250m"
memory: "256Mi"
What does this deployment manifest do?
This YAML tells Kubernetes to run one replica of your Flask container in the dev namespace. The environment: dev label lets you identify which environment a pod belongs to.
The resources section sets CPU and memory guardrails. requests is the minimum Kubernetes reserves for the pod. limits is the ceiling it cannot exceed.
- Create a new file called dev-service.yaml in the same folder and add the following content:
apiVersion: v1
kind: Service
metadata:
name: cloud-app-service
namespace: dev
spec:
type: LoadBalancer # Exposes the app via a public IP
selector:
app: cloud-app # Routes traffic to pods with this label
ports:
- port: 80 # External port
targetPort: 5000 # Flask app port inside the container
What does this service do?
A LoadBalancer Service creates a public IP address in Azure so you can reach your app from the internet. It maps external port 80 to Flask's internal port 5000.
- Confirm both files exist by running:
ls
You should see dev-deployment.yaml and dev-service.yaml in the output.
- Deploy both manifests to the dev namespace by running:
kubectl apply -f dev-deployment.yaml -f dev-service.yaml
- Verify the pod is running by running:
kubectl get pods -n dev
You should see one pod with a Running status. If it shows ContainerCreating, wait about 30 seconds and run the command again.
Pod stuck in ImagePullBackOff?
This means the cluster cannot pull the image from ACR. Confirm the image name in your YAML matches exactly what you pushed. Run az acr repository list --name cloudappcr --output table to check what images exist in your registry.
Also verify the AKS cluster has the AcrPull role by running az aks check-acr --name your-cluster --resource-group cloud-app-rg --acr cloudappcr.azurecr.io.
Help me debug my ImagePullBackOff error in AKS
Copy the YAML files for staging and deploy
Dev is running. Now the staging environment needs the same app but with a different image version, more replicas, and a staging label. The quickest way to get those manifests? Copy the dev files and edit them by hand.
First, you need a v2 image in ACR so staging has a different version to pull.
- Create a v2 tag from your existing v1 image and push it to ACR by running these commands:
docker tag cloudappcr.azurecr.io/cloud-app:v1 cloudappcr.azurecr.io/cloud-app:v2
docker push cloudappcr.azurecr.io/cloud-app:v2
Why create a v2 tag?
In a real team, staging would run a newer release than dev. Tagging v1 as v2 simulates that scenario so your staging manifest can reference a different image tag, just like the payments team would in production.
- Copy the dev manifests to create staging versions by running:
cp dev-deployment.yaml staging-deployment.yaml
cp dev-service.yaml staging-service.yaml
- Verify you now have four YAML files by running:
ls
You should see dev-deployment.yaml, dev-service.yaml, staging-deployment.yaml, and staging-service.yaml.
Right now the staging files are identical copies of dev. You need to manually hunt through each one and change the values that differ for staging.
- Open staging-deployment.yaml in your text editor and make these five edits:
- Change namespace: dev to namespace: staging in the metadata section.
- Change environment: dev to environment: staging in the metadata labels.
- Change replicas: 1 to replicas: 3.
- Change environment: dev to environment: staging in the template labels.
- Change the image from cloudappcr.azurecr.io/cloud-app:v1 to cloudappcr.azurecr.io/cloud-app:v2.
- Save staging-deployment.yaml.
Want to make sure your staging deployment file looks right?
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before continuing.
ⓧ I'd like to double check the full code
Here is the complete staging-deployment.yaml after all five edits. Compare it line by line against yours.
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloud-app
namespace: staging
labels:
app: cloud-app
environment: staging # Changed from dev
spec:
replicas: 3 # Changed from 1
selector:
matchLabels:
app: cloud-app
template:
metadata:
labels:
app: cloud-app
environment: staging # Changed from dev
spec:
containers:
- name: cloud-app
image: cloudappcr.azurecr.io/cloud-app:v2 # Changed from v1
ports:
- containerPort: 5000
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "250m"
memory: "256Mi"
Now apply the same process to the staging service file.
- In staging-service.yaml, find this line:
namespace: dev
- Change dev to staging so the line reads:
namespace: staging
- Save staging-service.yaml.
- Deploy both staging manifests by running:
kubectl apply -f staging-deployment.yaml -f staging-service.yaml
- Verify the staging pods are running:
kubectl get pods -n staging
You should see three pods with a Running status. If any show ContainerCreating, wait a moment and run the command again.
Seeing fewer than three staging pods?
Confirm that replicas: 3 is set in your staging-deployment.yaml. If you still see only one pod, the file still has the dev value. Open it, fix the replica count, save, and re-run kubectl apply -f staging-deployment.yaml.
Help me troubleshoot my staging deployment on AKS
Compare the deployment files and count the manual changes
Both environments are live. Take a step back and look at what you just did: you copied an entire file, then manually searched for five values to change. If you missed even one, staging would quietly run the wrong configuration.
Before you run the comparison, predict: how many lines do you think actually differ between the two deployment files?
- Compare the dev and staging deployment files by running:
fc.exe dev-deployment.yaml staging-deployment.yaml
The output highlights five changed lines scattered across 28 lines of YAML: namespace, two environment labels, the replica count, and the image tag. Every one of those is a value you had to find and edit by hand.
What is configuration drift?
When you copy manifests and manually edit values, each environment becomes its own source of truth. Over time, small mistakes accumulate. A wrong image tag here, a mismatched label there. This is configuration drift.
The payments team hit exactly this problem. Someone copied the dev manifest for staging, forgot to update the image tag, and deployed the wrong version. With only two environments and four files, you can already feel how fragile this process is.
Now confirm that both namespaces are serving running pods despite the manual process. Do you think both environments came up successfully?
- Check pods in both namespaces by running:
kubectl get pods -n dev
kubectl get pods -n staging
You should see one pod running in dev and three pods running in staging. Both environments are live, but you had to maintain two separate copies of nearly identical YAML to get here.
You just felt the exact pain that Helm was built to eliminate. Two environments, four files, five manual edits, and one misplaced value away from a production incident. In the next step, you will create a Helm chart that replaces all this duplication with a single reusable template.
Create a Helm Chart from Scratch
Step 3: Create a Helm Chart from Scratch
In the last step, you deployed the Flask app to two namespaces using raw kubectl manifests. The diff at the end made the problem visible: you had to copy an entire YAML file and manually edit several values just to target a different environment. That is exactly the kind of configuration drift that caused the payments team's failed staging deployment.
Helm solves this by replacing hardcoded YAML values with Go template variables. A single chart serves every environment, and environment-specific differences live in small override files instead of duplicated manifests.
In this step, get ready to:
- Scaffold a new Helm chart and explore its directory structure.
- Replace the default templates with your Flask app's Deployment and Service using Go template variables.
- Create environment-specific values files and verify correct rendering with a dry run.
Scaffold a new chart with helm create
Helm provides a scaffolding command that generates the standard directory structure every chart needs. This gives you a working starting point with all the required files in place.
- Create the chart scaffold by running this command:
helm create cloud-app-chart
- Explore the generated directory structure by running:
ls cloud-app-chart
You should see folders and files including Chart.yaml, values.yaml, charts/, and templates/.
What is a Helm chart?
A Helm chart is a package of pre-configured Kubernetes resources. The key files are:
- Chart.yaml holds metadata like the chart name and version.
- values.yaml defines default configuration values that templates reference.
- templates/ contains Kubernetes manifest files with Go template placeholders instead of hardcoded values.
- _helpers.tpl holds reusable template snippets like name and label generators.
The scaffold comes pre-configured for an Nginx deployment. You will replace its contents with your Flask app templates. First, clean up the scaffold files you do not need.
- Remove the unnecessary scaffold templates by running:
Remove-Item cloud-app-chart/templates/hpa.yaml, cloud-app-chart/templates/ingress.yaml, cloud-app-chart/templates/serviceaccount.yaml, cloud-app-chart/templates/NOTES.txt, cloud-app-chart/templates/tests -Recurse -Force
- Now replace Chart.yaml with your Flask app's metadata. Open cloud-app-chart/Chart.yaml in your editor and replace its entire contents with:
apiVersion: v2
name: cloud-app-chart
description: Helm chart for the Flask payments microservice
type: application
version: 0.1.0
appVersion: "1.0.0"
What does Chart.yaml define?
apiVersion: v2 tells Helm this is a v2 chart (compatible with Helm 3 and 4). version tracks the chart's own version, while appVersion tracks the version of the application it deploys.
- Confirm the cleanup worked by listing the remaining template files:
ls cloud-app-chart/templates
You should see only deployment.yaml, service.yaml, and _helpers.tpl.
Seeing extra files?
Make sure you included the -Recurse flag in the Remove-Item command. The tests folder is a directory and requires -Recurse to be deleted.
Ask NextWork AI for help: Why are there extra files in my cloud-app-chart/templates folder after cleanup?
Replace the default templates with Go template variables
Now you will replace the scaffold's Nginx defaults with templates that reference .Values for every environment-specific field. This is the core of what makes a Helm chart reusable.
- Open cloud-app-chart/values.yaml and replace its entire contents with the following default values:
# Default values for cloud-app-chart
replicaCount: 1
image:
repository: cloudappcr.azurecr.io/cloud-app
tag: v1
environment: dev
service:
type: LoadBalancer
port: 80
resources:
limits:
cpu: "250m"
memory: "128Mi"
requests:
cpu: "125m"
memory: "64Mi"
What does values.yaml do?
This file defines the default configuration for your chart. Every {{ .Values.xxx }} reference in your templates resolves to a value from this file. When you install the chart, you can override any of these defaults with a separate values file or --set flags.
The resources block sets CPU and memory limits so your pods cannot consume unbounded cluster resources. These are Kubernetes best practices for production workloads.
- Save values.yaml.
- Open cloud-app-chart/templates/_helpers.tpl and replace its entire contents with:
{{- define "cloud-app-chart.name" -}}
{{- .Chart.Name -}}
{{- end -}}
{{- define "cloud-app-chart.fullname" -}}
{{- printf "%s" .Release.Name -}}
{{- end -}}
{{- define "cloud-app-chart.labels" -}}
app: cloud-app
chart: {{ include "cloud-app-chart.name" . }}
release: {{ .Release.Name }}
{{- end -}}
What are helper templates?
The _helpers.tpl file defines reusable template snippets. cloud-app-chart.fullname resolves to the Helm release name, so each deployment gets a unique Kubernetes resource name. cloud-app-chart.labels generates consistent labels across all resources.
- Save _helpers.tpl. Now open cloud-app-chart/templates/deployment.yaml and replace its entire contents with:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "cloud-app-chart.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
app: cloud-app
environment: {{ .Values.environment }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: cloud-app
template:
metadata:
labels:
app: cloud-app
environment: {{ .Values.environment }}
spec:
containers:
- name: cloud-app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: 5000
resources:
limits:
cpu: {{ .Values.resources.limits.cpu }}
memory: {{ .Values.resources.limits.memory }}
requests:
cpu: {{ .Values.resources.requests.cpu }}
memory: {{ .Values.resources.requests.memory }}
What changed from the raw YAML?
Compare this to your dev-deployment.yaml from Step 2. Every value you had to manually edit when copying for staging is now a {{ .Values.xxx }} variable. The image tag, replica count, environment label, and resource limits all pull from values.yaml instead of being hardcoded.
The name uses {{ include "cloud-app-chart.fullname" . }} so each release gets its own unique resource name. The namespace comes from {{ .Release.Namespace }}, which Helm sets automatically at install time.
- Save deployment.yaml.
Before continuing, verify that the deployment template renders correctly with your default values.
- Render only the deployment template by running:
helm template cloud-app ./cloud-app-chart --show-only templates/deployment.yaml
You should see a fully rendered Kubernetes Deployment manifest with your default values substituted in. The image should show cloudappcr.azurecr.io/cloud-app:v1, replicas should be 1, and environment should be dev.
Seeing a template rendering error?
If you see an error mentioning a missing template or undefined variable, double-check that your _helpers.tpl file is saved and contains the cloud-app-chart.fullname definition. Also confirm your values.yaml has the image section with repository and tag fields.
Ask NextWork AI for help: Why is my helm template command failing with a rendering error?
Now add the last template file.
- Open cloud-app-chart/templates/service.yaml and replace its entire contents with:
apiVersion: v1
kind: Service
metadata:
name: {{ include "cloud-app-chart.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
app: cloud-app
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 5000
protocol: TCP
selector:
app: cloud-app
What does this service template do?
The service type and port are now configurable through .Values.service.type and .Values.service.port. This means you could switch from LoadBalancer to ClusterIP for internal-only environments just by changing a values file.
- Save service.yaml.
- Verify the full chart renders by running:
helm template cloud-app ./cloud-app-chart
You should see both the Deployment and Service manifests rendered with your default values. No errors means your chart is structurally complete.
Still seeing rendering errors?
Check that the service section in values.yaml includes both type and port fields. A missing field causes a nil pointer error during rendering.
Ask NextWork AI for help: Why is my full helm template command failing after I added service.yaml?
✔️ Awesome, I've got everything!
Great. Make sure all four files are saved: Chart.yaml, values.yaml, templates/deployment.yaml, templates/service.yaml, and templates/_helpers.tpl.
ⓧ I'd like to double check the full code
Scroll up and confirm each file matches. Here is the checklist in order:
- Chart.yaml has apiVersion: v2, name: cloud-app-chart, version: 0.1.0, and appVersion: "1.0.0".
- values.yaml has sections for replicaCount, image, environment, service, and resources.
- _helpers.tpl defines cloud-app-chart.name, cloud-app-chart.fullname, and cloud-app-chart.labels.
- templates/deployment.yaml uses {{ .Values.image.repository }}, {{ .Values.replicaCount }}, and {{ .Values.environment }}.
- templates/service.yaml uses {{ .Values.service.type }} and {{ .Values.service.port }}.
Create environment-specific values files and verify rendering
Your chart's default values.yaml targets the dev environment. To deploy to staging with different settings, you create a separate values file that overrides only the fields that differ. Helm merges the override file on top of the defaults at install time.
- Create a file called values-dev.yaml in your project root (next to the cloud-app-chart folder) with the following contents:
# Dev environment overrides
image:
tag: v1
replicaCount: 1
environment: dev
- Save values-dev.yaml.
- Create a second file called values-staging.yaml in the same location with:
# Staging environment overrides
image:
tag: v2
replicaCount: 3
environment: staging
Why separate values files?
Each values file captures the full set of differences for one environment. When you install the chart, you pass --values values-staging.yaml and Helm merges those overrides on top of the defaults in values.yaml. Everything you do not override keeps its default value.
Compare this to Step 2: instead of copying and editing an entire Deployment manifest, you now have a 7-line file with only the values that change. That is a much smaller surface for human error.
- Save values-staging.yaml.
Before you run the dry run, what do you think the rendered output will look like when you pass values-dev.yaml? Will the replica count be 1 or 3?
- Dry-run the chart with the dev values file by running:
helm template cloud-app ./cloud-app-chart --values values-dev.yaml
You should see rendered Kubernetes manifests with dev-specific values substituted in. The image tag should be v1, replicas should be 1, and the environment label should be dev.
- Now dry-run with the staging values file by running:
helm template cloud-app ./cloud-app-chart --values values-staging.yaml
This time the image tag should be v2, replicas should be 3, and the environment label should be staging. One chart, two completely different configurations, zero copy-paste.
Output does not match expected values?
Make sure your values-staging.yaml file is in the same directory where you are running the helm template command. Also confirm the YAML indentation is correct. A tag field must be nested under image: with two spaces of indentation.
Ask NextWork AI for help: Why does my helm template output not match my values file overrides?
Your chart is built, tested, and ready for a real cluster. Next up, you will deploy it to both the dev and staging namespaces with Helm and retire the raw kubectl workflow for good.
Deploy with Helm to Multiple Namespaces
In the last step, you built a Helm chart from scratch and confirmed it renders correctly with helm template. The chart is validated on paper.
Now it is time to test it against a live cluster. Deploying to both dev and staging with Helm proves that one chart replaces the duplicated YAML workflow that caused the payments team's configuration drift.
In this step, get ready to:
- Clean up raw kubectl deployments from both namespaces.
- Install the Helm chart to dev and staging with environment-specific values files.
- Verify both Helm releases are deployed and returning responses.
Clean up raw deployments and install to dev
Before Helm can manage these namespaces, the existing raw kubectl resources need to go. Helm tracks its own releases independently, so running both raw resources and Helm releases in the same namespace creates conflicts.
- Remove the raw deployments and services from both namespaces by running these commands:
kubectl delete -f dev-deployment.yaml -f dev-service.yaml -n dev
kubectl delete -f staging-deployment.yaml -f staging-service.yaml -n staging
- Confirm the resources are gone by running:
kubectl get pods -n dev
kubectl get pods -n staging
Both commands should return No resources found. The namespaces are clean and ready for Helm.
- Install the Helm chart to the dev namespace by running:
helm upgrade --install cloud-app-dev ./cloud-app-chart --namespace dev --values values-dev.yaml
What does helm upgrade --install do?
This is an idempotent command. It installs the chart if no release exists, or upgrades it if one does. This makes it safe to run repeatedly without side effects.
- cloud-app-dev is the release name. Helm uses this name to track the deployment's history, revisions, and rollback points.
- --namespace dev tells Helm which Kubernetes namespace to deploy into.
- --values values-dev.yaml passes your dev-specific overrides (1 replica, v1 image tag, dev environment label).
You should see output that includes STATUS: deployed and REVISION: 1.
- Confirm the dev pod is running:
kubectl get pods -n dev
You should see one pod with status Running. This pod was deployed by Helm using your values-dev.yaml overrides.
Pod stuck in ImagePullBackOff?
- Check that the image repository in values-dev.yaml matches your actual ACR. It should be cloudappcr.azurecr.io/cloud-app.
- Confirm the v1 tag exists in your registry by running az acr repository show-tags --name cloudappcr --repository cloud-app.
- Make sure your AKS cluster has the AcrPull role attached. If you used --attach-acr when creating the cluster, this should already be configured.
- Help me debug why my Helm-deployed pod is failing to pull the image from ACR.
Install the same chart to staging
The same chart, different values. This is the power of Helm's templating approach.
- Install the chart to the staging namespace by running:
helm upgrade --install cloud-app-staging ./cloud-app-chart --namespace staging --values values-staging.yaml
Same chart, different outcome
This is the exact same chart directory you deployed to dev. The only difference is the values file. Your values-staging.yaml sets the image tag to v2, the replica count to 3, and the environment label to staging. No YAML copying needed.
- Confirm the staging pods are running:
kubectl get pods -n staging
You should see three pods with status Running. Compare this to the one pod in dev. That difference came from a single line in your values file, not a duplicated manifest.
Seeing fewer than 3 pods?
- Check that replicaCount is set to 3 in your values-staging.yaml file.
- Run kubectl describe deployment -n staging to check for events that explain why pods are not scheduling.
- Help me figure out why my staging deployment has fewer replicas than expected.
Verify both releases are live
Both namespaces now have Helm-managed releases. Before you check, think about what you expect to see.
How many releases do you expect in the output, and what status should they both have?
- Check your Helm releases across all namespaces by running:
helm list --all-namespaces
You should see two releases: cloud-app-dev and cloud-app-staging. Both should show status deployed and revision 1.
- Get the LoadBalancer external IPs for both services by running:
kubectl get svc -n dev
kubectl get svc -n staging
Waiting for an external IP?
It can take a minute or two for Azure to assign a public IP to each LoadBalancer service. If the EXTERNAL-IP column shows <pending>, wait 30 seconds and run the command again.
- Once both external IPs are assigned, test the dev endpoint by running:
curl.exe http://[[DEV_EXTERNAL_IP="your dev service external IP"]]
- Test the staging endpoint by running:
curl.exe http://[[STAGING_EXTERNAL_IP="your staging service external IP"]]
Why curl.exe instead of curl?
In PowerShell, curl is an alias for Invoke-WebRequest, which returns different output formatting. Using curl.exe calls the real curl binary bundled with Windows 10 and later, giving you clean JSON output.
Both endpoints should return a JSON response from the Flask app. The responses confirm that each namespace is running with its own configuration from a single Helm chart.
Getting a connection refused error?
- Make sure you copied the correct EXTERNAL-IP from the kubectl get svc output. Each namespace has its own IP.
- Confirm the pods are still running with kubectl get pods -n dev and kubectl get pods -n staging.
- Check the service port by running kubectl describe svc -n dev and confirm it targets the correct container port.
- Help me troubleshoot why I cannot reach my Helm-deployed Flask app through the LoadBalancer.
Both environments are running from a single chart with zero duplicated YAML. Next up, you will push a new version to dev and see how Helm handles upgrades and rollbacks.
Upgrade and Rollback with Helm
In the previous step, you deployed the same Helm chart to both dev and staging namespaces. Both environments now run from a single source of truth.
Deploying is only half the story. You also need to push new versions safely and revert instantly when something goes wrong. Helm tracks every release as a numbered revision, so pushing a new version and rolling back are both single-command operations.
In this step, get ready to:
- Build and push a v2 image to ACR, then upgrade the dev release with Helm.
- Verify the upgrade and inspect the revision history.
- Roll back to v1 and compare the experience to manual kubectl workflows.
Build a v2 image and upgrade the dev release
To test an upgrade, you need a new version of the Flask app. A small change to the response makes v2 visually distinguishable when you curl the endpoint.
- Open app.py in your code editor.
- Find the version string in your Flask route's JSON response. It currently returns v1.
- Change v1 to v2 so the response reflects the new version.
- Save app.py.
- Build the updated image with a v2 tag by running this command:
docker build -t cloudappcr.azurecr.io/cloud-app:v2 .
You should see Docker step through each layer in your Dockerfile and finish with a success message.
Build failing?
Make sure your terminal is in the same directory as your Dockerfile and app.py. Also confirm Docker Desktop is running.
help me debug a Docker build failure for my Flask app
- Log in to ACR and push the v2 image by running these commands:
az acr login --name cloudappcr
docker push cloudappcr.azurecr.io/cloud-app:v2
You should see each layer being pushed, finishing with a digest and size summary.
Now that your v2 image is in ACR, you can upgrade the dev release. The --set flag lets you override a single value from the command line without editing your values file.
- Upgrade the dev release to use the v2 image by running this command:
helm upgrade cloud-app-dev ./cloud-app-chart --namespace dev --values values-dev.yaml --set image.tag=v2
What does --set do here?
Your values-dev.yaml file sets image.tag to v1. The --set flag has higher precedence than values files, so it overrides that single value to v2 without changing the file on disk.
This is useful in CI/CD pipelines where a build system passes the image tag dynamically. The values file stays as the baseline, and --set applies the one-off override.
Helm prints a summary showing the release name, namespace, status, and revision number. You should see revision 2 and a status of deployed.
Upgrade failed?
Make sure you are in the directory that contains your cloud-app-chart folder and values-dev.yaml file. Both paths in the command are relative to your current directory.
help me fix a failed Helm upgrade
Verify the upgrade and check revision history
The upgrade command succeeded, but the real proof is the running application. Time to confirm the v2 image is actually serving traffic.
- Get the dev service's external IP by running:
kubectl get svc -n dev
- Find the EXTERNAL-IP value in the output. Curl the endpoint to see the v2 response by running:
curl http://[[DEV_IP="your dev service EXTERNAL-IP"]]
The JSON response should now include v2 where it previously showed v1. That confirms the upgrade deployed your new image.
- Check the release's revision history by running:
helm history cloud-app-dev -n dev
What does the history show?
You should see two rows. Revision 1 has a status of superseded and revision 2 has a status of deployed. Each row also shows the timestamp, chart name, app version, and a description.
Helm stores every revision so you can inspect what changed and when. This history is the foundation of the rollback you will do next.
Roll back to v1 and compare workflows
Imagine the v2 deployment introduced a bug. In production, you need to revert immediately. With raw kubectl, you would have to locate the old YAML, verify the correct image tag, and reapply it manually.
With Helm, you point at the revision number you want and it handles the rest.
- Roll back the dev release to revision 1 by running:
helm rollback cloud-app-dev 1 -n dev
Helm confirms the rollback succeeded. Now verify the application is actually serving the old version.
Before you curl the endpoint, what version do you expect the response to show?
- Curl the dev endpoint again to confirm the rollback by running:
curl http://[[DEV_IP="your dev service EXTERNAL-IP"]]
The response should show v1 again. One command reverted the entire deployment to its previous state.
- Check the full revision history after the rollback by running:
helm history cloud-app-dev -n dev
Why does rollback create revision 3?
You now see three rows. Revision 1 (superseded) was the original install. Revision 2 (superseded) was the v2 upgrade. Revision 3 (deployed) is the rollback. Its description reads something like Rolled back to 1.
Helm never overwrites history. A rollback is recorded as a new revision that restores the configuration of the target revision. This means you always have a complete audit trail of every change.
How does this compare to kubectl?
In Project 2, rolling back with kubectl meant running kubectl rollout undo and hoping the previous ReplicaSet still existed. That only tracked Deployment revisions, not the full picture (Services, ConfigMaps, environment labels).
Helm rollback restores the entire release. Every resource managed by the chart reverts to its state at the target revision. One command, complete restoration.
Still seeing v2 after the rollback?
The pods may still be cycling. Wait 15-30 seconds and curl again. You can check pod status with kubectl get pods -n dev to see if old pods are terminating.
help me troubleshoot a Helm rollback that didn't take effect
Secret mission
Push Your Helm Chart to ACR as an OCI Artifact
Your Helm chart works from your local machine, but your CI/CD pipeline needs to pull it from a central registry. Package your chart and push it to Azure Container Registry as an OCI artifact so any pipeline or team member can pull and deploy it.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses paid Azure resources that accumulate charges while running.
Cost warning
Your AKS cluster and ACR together cost roughly $1.50 per day while running. The AKS node (Standard_B2s) accounts for ~$1.13/day and the ACR Basic SKU adds ~$0.17/day.
If you are not starting the CI/CD pipeline project within the next day or two, pause or delete your resources to avoid unnecessary charges.
Resources you used:
- Azure Container Registry (cloudappcr, Basic SKU): ~$0.17/day.
- AKS cluster (1x Standard_B2s node in cloud-app-rg): ~$1.13/day.
- Two Kubernetes LoadBalancer Services (each creates a public IP with a small hourly charge).
- Local Helm chart files (cloud-app-chart/, values-dev.yaml, values-staging.yaml, cloud-app-chart-0.1.0.tgz).
- Local Docker images (cloud-app:v1 and cloud-app:v2).
- Helm (installed via winget).
Keep everything running
No action needed. Choose this if you plan to start the CI/CD pipeline project within the next day or two.
- Your AKS cluster, ACR, and both Helm releases remain active and ready.
- Keep Helm installed on your machine. The next project in this series uses it.
- Total running cost is roughly $1.50 per day.
Pause - I'll come back to this later
Stop the AKS cluster to eliminate VM charges while keeping your ACR and cluster configuration intact. ACR remains active at ~$0.17/day.
- Stop your AKS cluster by running this command:
az aks stop --resource-group cloud-app-rg --name [[AKS_CLUSTER_NAME="your AKS cluster name"]]
- Your Helm releases and namespaces are preserved inside the stopped cluster. When you are ready to resume, restart the cluster by running:
az aks start --resource-group cloud-app-rg --name [[AKS_CLUSTER_NAME="your AKS cluster name"]]
- Your local Helm chart files and Docker images can stay on your machine at no cost.
Delete - I don't want to use this again
Remove all Azure resources and clean up local files. This is permanent.
- Delete the Azure resource group containing your ACR and AKS cluster by running this command:
az group delete --name cloud-app-rg --yes
What does this command do?
This single command deletes the entire cloud-app-rg resource group, which includes your ACR, AKS cluster, all Kubernetes resources (namespaces, Helm releases, LoadBalancer public IPs), and the container images stored in ACR.
- Remove the local Docker images by running this command:
docker image rm cloudappcr.azurecr.io/cloud-app:v1 cloudappcr.azurecr.io/cloud-app:v2
- Delete your local project files. Navigate to the folder containing your Helm chart on the Desktop and remove it:
macOS
cd ~/Desktop
rm -rf cloud-app-chart/
rm -f values-dev.yaml values-staging.yaml cloud-app-chart-0.1.0.tgz
Windows
cd ~\Desktop
Remove-Item -Recurse -Force cloud-app-chart\
Remove-Item -Force values-dev.yaml, values-staging.yaml, cloud-app-chart-0.1.0.tgz
- Uninstall Helm if you no longer need it by running this command:
winget uninstall Helm.Helm
Nice Work!
Nice Work!
You just standardized a multi-environment deployment workflow using Helm on AKS. The payments team's fragile copy-paste YAML process is now a single reusable chart with versioned, rollback-capable releases.
You've learned how to:
- Build a Helm chart from scratch with Go template variables and environment-specific values files, replacing duplicated kubectl YAML with a single reusable template.
- Deploy the same chart to dev and staging namespaces using helm upgrade --install, eliminating the configuration drift that manual YAML management causes.
- Perform a Helm upgrade and rollback with single commands, proving that Helm's revision history makes version management safer than raw kubectl workflows.
- (Secret Mission) Packaged the Helm chart and pushed it to ACR as an OCI artifact, preparing it for automated CI/CD consumption in the next project.
Ready to quiz yourself?