Deploy ACR and AKS with Terraform

Provision Azure Container Registry and Kubernetes Service as Terraform modules.

Introduction

30 Second Summary

Every application you deploy at work needs two things: a place to store its packaged version, and a place to run it. Getting those two pieces to trust each other without hardcoded passwords is where most teams get tripped up.

In this project, you will provision Azure Container Registry to store Docker images and Azure Kubernetes Service to run them. Everything deploys as Terraform modules that build on your existing networking infrastructure from Project 1.

What You'll Build

You will run curl against a public IP and get a live JSON response from a containerized Python app running on Kubernetes, pulled from your own private registry, all deployed through Terraform.

By the end of this project, you'll have:

  • A production-quality container image stored in your private registry, built with a multi-stage Dockerfile that keeps the final image lean and fast to pull.
  • A running pod inside your existing VNet, pulling your image from ACR using Managed Identity. No stored passwords anywhere.
  • A live endpoint you can curl from your terminal to see a JSON response, proving the full container lifecycle works: build, push, deploy, serve.
  • Secret Mission: Deploy a second application version and perform a rolling update with zero downtime.

Are there any prerequisites?

This project builds on your infrastructure from Project 1 (Azure Networking with Terraform). You should have your VNet, subnets, and NSGs deployed in East US with Terraform remote state before starting.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Install kubectl and Verify Your Foundation

Your AKS cluster will need a way for you to talk to it once it exists. That tool is kubectl, the command-line interface for Kubernetes. Without it, you cannot deploy pods, check node status, or validate anything running on your cluster.

In this step, you will install kubectl, confirm your existing tools are working, and verify that your Project 1 networking infrastructure is still intact. These outputs become inputs for the AKS module you build later.

In this step, get ready to:
  • Install kubectl and kubelogin using Azure CLI.
  • Verify all four tools are operational: Azure CLI, Terraform, Docker, and kubectl.
  • Confirm your Project 1 networking outputs are accessible for use in later modules.
Sign in to Azure and install kubectl

Before installing anything, you need an active Azure session. This ensures you can interact with your subscription throughout the project.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) and typing Terminal or PowerShell.
  • Sign in to Azure by running this command:
az login

A browser window opens for you to authenticate. Once you sign in, the terminal displays your subscription details.

  • Check if kubectl is already on your machine by running:
kubectl version --client

✔️ I see a version number

You already have kubectl installed. Skip ahead to the next substep where you verify all four tools together.

ⓧ Command not found

This is expected. The az aks install-cli command downloads and installs both kubectl and kubelogin (the Azure authentication plugin for Kubernetes).

macOS

  • Install kubectl by running this command with sudo (it installs to /usr/local/bin/ which requires admin access):
sudo az aks install-cli

Enter your Mac password when prompted. You will see confirmation that both kubectl and kubelogin were downloaded.

Windows

  • Close your current terminal.
  • Press the Windows key and type PowerShell.
  • Right-click Windows PowerShell and select Run as administrator.
  • Install kubectl by running:
az aks install-cli

The command installs both kubectl and kubelogin. It attempts to add them to your PATH automatically. If you see a message about adding a directory to your PATH, close and reopen your terminal for the change to take effect.

What are kubectl and kubelogin?

kubectl is how you send commands to a Kubernetes cluster: deploy apps, inspect pods, read logs.

kubelogin handles Azure-specific authentication so kubectl can prove your identity to AKS without storing any credentials locally.

Verify all existing tools

You now have four CLI tools that work together throughout this project. Confirming they all respond to a version check proves your environment is ready.

  • Verify Azure CLI by running:
az version

You should see a JSON object with your Azure CLI version (2.89.1 or newer).

  • Verify Terraform by running:
terraform version

You should see a Terraform version string printed.

  • Verify Docker by running:
docker version

You should see both a Client and Server section. If you see an error about the Docker daemon, make sure Docker Desktop is running.

  • Verify kubectl by running:
kubectl version --client

You should see a version string confirming kubectl is installed and reachable from your PATH.

kubectl not found after installing?

If kubectl version --client still says command not found, your terminal session does not have the install directory in its PATH. Close the terminal completely and open a new one. On Windows, you may need to restart your machine for PATH changes to take effect.

help me fix kubectl not being found in my PATH after running az aks install-cli

Confirm your Project 1 infrastructure is intact

Your AKS cluster will deploy into the VNet and subnet you created in Project 1. You need to confirm those resources still exist and their IDs are accessible via Terraform outputs.

  • Navigate to your Project 1 Terraform networking environment directory by running:
cd ~/Desktop/azure-terraform-project/terraform/environments/dev

Your directory path may differ

If you used a different folder name or structure in Project 1, navigate to whichever directory contains your networking Terraform configuration with the terraform.tfstate or remote backend setup.

Before you run the next command, what do you expect to see? Think about what outputs your networking module exports.

  • View your infrastructure outputs by running:
terraform output

You should see your VNet ID and subnet IDs printed in the terminal. These are the exact values you will pass into the AKS module to deploy nodes into your existing network.

Seeing 'No outputs found' or an error?

If Terraform reports no outputs, you may be in the wrong directory. Run ls and confirm you see main.tf and .terraform in the current folder. If you see them but still get no outputs, run terraform init first to reinitialize the backend.

help me find my Project 1 Terraform outputs for the VNet and subnets

Your toolkit is locked in and your networking foundation is confirmed. Next up, you will create the ACR Terraform module and build a production-quality Docker image to store in it.

Create the ACR Terraform Module and Push a Production-Quality Image

Your tools are verified and your networking foundation is confirmed intact. Now you need somewhere to store container images before AKS can run them.

In this step, you will provision Azure Container Registry as a Terraform module and build a production-quality Docker image using a multi-stage Dockerfile. Multi-stage builds separate the dependency installation stage from the lean runtime stage, producing smaller images that pull faster on your cluster.

In this step, get ready to:
  • Create and apply a Terraform module for Azure Container Registry.
  • Write a Flask application with a multi-stage Dockerfile and .dockerignore.
  • Build and push a container image to ACR using Managed Identity authentication.
Create the ACR Terraform module

ACR is Azure's private container registry. It stores your Docker images so that only authorized services (like your AKS cluster) can pull them.

  • Create the module directory by running this command:
mkdir -p terraform/modules/acr
  • Create main.tf inside terraform/modules/acr/ with the following code:
resource "azurerm_resource_group" "acr" {
  name     = var.resource_group_name
  location = var.location
  tags     = var.tags
}

resource "azurerm_container_registry" "acr" {
  name                = var.acr_name
  resource_group_name = azurerm_resource_group.acr.name
  location            = azurerm_resource_group.acr.location
  sku                 = "Basic"
  admin_enabled       = false
  tags                = var.tags
}

What does this code do?

This file defines two Azure resources:

  • azurerm_resource_group creates a dedicated resource group to hold your registry.
  • azurerm_container_registry creates the registry itself with the Basic SKU and admin access disabled. Disabling admin forces authentication through Managed Identity, which is the secure production pattern.
  • Save main.tf.
  • Confirm the file exists by running:
ls terraform/modules/acr/

You should see main.tf listed in the output.

  • Create variables.tf in the same directory with the following code:
variable "resource_group_name" {
  description = "Name of the resource group for ACR"
  type        = string
}

variable "location" {
  description = "Azure region"
  type        = string
  default     = "eastus"
}

variable "acr_name" {
  description = "Name of the Azure Container Registry (must be globally unique, alphanumeric)"
  type        = string
}

variable "tags" {
  description = "Tags to apply to resources"
  type        = map(string)
  default     = {}
}

What does this code do?

This declares the inputs your module needs. The acr_name must be globally unique across all of Azure and contain only letters and numbers.

The location defaults to eastus to match your existing networking infrastructure from Project 1.

  • Save variables.tf.
  • Create outputs.tf in the same directory with the following code:
output "acr_id" {
  description = "The resource ID of the container registry"
  value       = azurerm_container_registry.acr.id
}

output "acr_login_server" {
  description = "The login server URL of the container registry"
  value       = azurerm_container_registry.acr.login_server
}

output "acr_name" {
  description = "The name of the container registry"
  value       = azurerm_container_registry.acr.name
}

Why output these values?

The acr_id output is needed later when you grant your AKS cluster permission to pull images. The acr_login_server output gives you the URL you tag and push Docker images to.

  • Save outputs.tf.
  • Initialize Terraform in the ACR module directory by running:
cd terraform/modules/acr
terraform init

You should see a message that Terraform has been successfully initialized.

  • Apply the module to create your ACR by running:
terraform apply \
  -var="resource_group_name=rg-acr-dev" \
  -var="acr_name=[[ACRNAME="yourname123acr (alphanumeric, globally unique)"]]" \
  -var='tags={project="cloud-platform",environment="dev"}'

What is happening here?

You are passing variable values directly on the command line with -var flags. Terraform will show you a plan of the resources it will create.

When prompted, type yes and press Enter to confirm the apply.

  • Type yes at the confirmation prompt.

After about 30 seconds, Terraform reports that 2 resources were created. You should see acr_login_server in the outputs, showing a URL like yourname123acr.azurecr.io.

Seeing a name-already-exists error?

ACR names must be globally unique. If you see a conflict error, choose a different acr_name value with more unique characters and run terraform apply again.

Help me fix my Terraform apply error.

  • Navigate back to your project root by running:
cd ../../..

✔️ Awesome, I've got everything!

Great. Confirm you have saved all three files in terraform/modules/acr/.

ⓧ I'd like to double check the full code

Here are the three files that should exist in terraform/modules/acr/:

resource "azurerm_resource_group" "acr" {
  name     = var.resource_group_name
  location = var.location
  tags     = var.tags
}

resource "azurerm_container_registry" "acr" {
  name                = var.acr_name
  resource_group_name = azurerm_resource_group.acr.name
  location            = azurerm_resource_group.acr.location
  sku                 = "Basic"
  admin_enabled       = false
  tags                = var.tags
}
variable "resource_group_name" {
  description = "Name of the resource group for ACR"
  type        = string
}

variable "location" {
  description = "Azure region"
  type        = string
  default     = "eastus"
}

variable "acr_name" {
  description = "Name of the Azure Container Registry (must be globally unique, alphanumeric)"
  type        = string
}

variable "tags" {
  description = "Tags to apply to resources"
  type        = map(string)
  default     = {}
}
output "acr_id" {
  description = "The resource ID of the container registry"
  value       = azurerm_container_registry.acr.id
}

output "acr_login_server" {
  description = "The login server URL of the container registry"
  value       = azurerm_container_registry.acr.login_server
}

output "acr_name" {
  description = "The name of the container registry"
  value       = azurerm_container_registry.acr.name
}
Write the Flask app and build a production-quality Docker image

Your registry is live. Now you need an application image to store in it. You will write a small Flask app and package it with a multi-stage Dockerfile that keeps the final image lean.

What is a multi-stage Docker build?

A multi-stage build uses two or more FROM statements in one Dockerfile. The first stage installs build tools and dependencies. The second stage copies only the finished artifacts into a clean base image.

This means your final image does not carry compilers, package managers, or cache files. The result is a smaller image that pulls faster and has a reduced attack surface.

  • Create the application directory by running:
mkdir app
  • Create app/app.py with the following code:
from flask import Flask, jsonify
import os

app = Flask(__name__)

VERSION = os.environ.get("APP_VERSION", "1.0.0")

@app.route("/")
def home():
    return jsonify({"message": "Hello from the Cloud Platform!", "version": VERSION})

@app.route("/health")
def health():
    return jsonify({"status": "healthy"})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

What does this app do?

This is a minimal Flask web server with two routes. The / route returns a JSON greeting with the app version. The /health route returns a health check that Kubernetes will use later to monitor your pod.

  • Save app.py.
  • Create app/requirements.txt with the single dependency:
flask==3.1.1
  • Save requirements.txt.
  • Confirm both files exist by running:
ls app/

You should see app.py and requirements.txt listed.

  • Create the multi-stage app/Dockerfile with the following code:
# Stage 1: Builder - install dependencies into a virtual environment
FROM python:3.12-slim AS builder

WORKDIR /app

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Stage 2: Runtime - copy only the venv and app code into a clean image
FROM python:3.12-slim

RUN useradd --create-home appuser
WORKDIR /app

COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY app.py .

USER appuser

EXPOSE 5000

CMD ["python", "app.py"]

What does this Dockerfile do?

  • Stage 1 (builder): creates a virtual environment and installs Flask into it. This stage includes pip and all build tools, but those never make it into the final image.
  • Stage 2 (runtime): starts from a fresh python:3.12-slim base, copies only the virtual environment from Stage 1, adds your app code, and runs as a non-root user (appuser).
  • The result is a final image that contains only what is needed to run. No compilers, no pip cache, no build artifacts.
  • Save the Dockerfile.
  • Create app/.dockerignore to prevent unnecessary files from entering the build context:
.git
__pycache__
*.pyc
*.pyo
.env
terraform/
k8s/
*.md
.gitignore

Why use a .dockerignore?

Docker sends the entire build context directory to the daemon before building. Excluding irrelevant files (like your Terraform code and git history) speeds up the build and prevents accidental inclusion of sensitive data.

  • Save .dockerignore.
  • Build the Docker image by running this command from your project root:
docker build -t [[ACRNAME="yourname123acr"]].azurecr.io/cloud-app:v1 ./app

Before you check the output, what do you think the final image size will be? A full Python image is over 900MB. With the multi-stage slim base, expect something much smaller.

  • Check the image size by running:
docker images

You should see your cloud-app:v1 image listed at under 60MB. That is the power of multi-stage builds with a slim base.

Image larger than expected?

Make sure your Dockerfile uses python:3.12-slim (not python:3.12) in Stage 2. The non-slim base includes build tools that inflate the image.

Also confirm your .dockerignore is in the app/ directory (same level as the Dockerfile).

Help me figure out why my image is too large.

  • Inspect the layer structure by running:
docker history [[ACRNAME="yourname123acr"]].azurecr.io/cloud-app:v1

You can see each layer and its size. The largest layers are the base OS and the copied virtual environment. Your application code adds almost nothing.

✔️ Awesome, I've got everything!

Great. Confirm you have saved all four files in the app/ directory.

ⓧ I'd like to double check the full code

Here are the four files that should exist in app/:

from flask import Flask, jsonify
import os

app = Flask(__name__)

VERSION = os.environ.get("APP_VERSION", "1.0.0")

@app.route("/")
def home():
    return jsonify({"message": "Hello from the Cloud Platform!", "version": VERSION})

@app.route("/health")
def health():
    return jsonify({"status": "healthy"})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
flask==3.1.1
# Stage 1: Builder - install dependencies into a virtual environment
FROM python:3.12-slim AS builder

WORKDIR /app

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Stage 2: Runtime - copy only the venv and app code into a clean image
FROM python:3.12-slim

RUN useradd --create-home appuser
WORKDIR /app

COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY app.py .

USER appuser

EXPOSE 5000

CMD ["python", "app.py"]
.git
__pycache__
*.pyc
*.pyo
.env
terraform/
k8s/
*.md
.gitignore
Push the image to ACR

Your image exists locally. Now push it to your private registry so AKS can pull it later.

  • Authenticate Docker to your ACR by running:
az acr login --name [[ACRNAME="yourname123acr"]]

You should see Login Succeeded in the output.

How does this authentication work?

The az acr login command uses your existing Azure CLI session to generate a short-lived token for Docker. No passwords are stored anywhere. This is Managed Identity authentication in action.

  • Push the image to ACR by running:
docker push [[ACRNAME="yourname123acr"]].azurecr.io/cloud-app:v1

You should see progress bars as each layer uploads. Once complete, your image is stored in ACR.

Seeing an unauthorized error?

Make sure az acr login succeeded before pushing. If the login expired, run it again.

Also confirm the image tag matches your ACR login server exactly. Run docker images and check the repository column.

Help me fix my Docker push authentication error.

Before you verify, do you think the image will appear in ACR? You tagged it with the ACR login server prefix and pushed it after authenticating.

  • Verify the image appears in your registry by running:
az acr repository list --name [[ACRNAME="yourname123acr"]] --output table

You should see cloud-app listed as a repository. Your image is now stored in ACR and ready for AKS to pull.

Your container image is stored and secured. Next up, you will create the AKS cluster that pulls this image and runs it as a live service.

Create the AKS Terraform Module

Your container image is sitting in ACR, ready to be pulled and run. Now you need a compute layer that can actually run containers.

In this step, you will create a Terraform module for Azure Kubernetes Service (AKS) and deploy a single-node cluster into your existing VNet subnet from Project 1. By the end, you will have a running Kubernetes node that you can interact with using kubectl.

In this step, get ready to:
  • Create the AKS Terraform module with cluster configuration, variables, and outputs.
  • Wire the module into your dev environment and apply it.
  • Connect kubectl to your cluster and confirm the node is ready.
Create the AKS module directory

The AKS module follows the same structure as your ACR module: a main.tf for resources, a variables.tf for inputs, and an outputs.tf for values other modules can reference.

  • Create the AKS module directory by running this command from your project root:
mkdir terraform/modules/aks
  • Confirm the directory was created by running:
ls terraform/modules/

You should see both acr and aks listed.

Write the AKS module main.tf

The main configuration file declares a resource group and the AKS cluster itself. You will deploy the cluster into your existing subnet from Project 1, using Azure CNI networking.

  • Create terraform/modules/aks/main.tf and add the resource group resource:
resource "azurerm_resource_group" "aks" {
  name     = var.resource_group_name
  location = var.location
  tags     = var.tags
}

What does this code do?

This creates a dedicated resource group for your AKS resources. Keeping AKS in its own resource group (separate from ACR) makes cleanup straightforward.

  • Below the resource group, add the Kubernetes cluster resource:
resource "azurerm_kubernetes_cluster" "aks" {
  name                = var.cluster_name
  location            = azurerm_resource_group.aks.location
  resource_group_name = azurerm_resource_group.aks.name
  dns_prefix          = var.dns_prefix
  kubernetes_version  = var.kubernetes_version

  default_node_pool {
    name           = "system"
    node_count     = 1
    vm_size        = "Standard_B2s"
    vnet_subnet_id = var.subnet_id
  }

  identity {
    type = "SystemAssigned"
  }

  network_profile {
    network_plugin = "azure"
    service_cidr   = "10.1.0.0/16"
    dns_service_ip = "10.1.0.10"
  }

  tags = var.tags
}

What does this cluster configuration do?

  • default_node_pool creates one Standard_B2s VM (2 vCPUs, 4GB RAM) and places it into your existing subnet via vnet_subnet_id.
  • identity { type = "SystemAssigned" } gives the cluster a Managed Identity so it can authenticate to other Azure services without storing secrets.
  • network_profile uses Azure CNI (network_plugin = "azure") which assigns pods real VNet IPs. The service_cidr of 10.1.0.0/16 avoids overlapping with your Project 1 VNet (10.0.0.0/16).
  • kubernetes_version is set to 1.36, the latest GA version on AKS. AKS auto-selects the latest patch release within that minor version.
  • Save main.tf.
  • Verify the syntax is valid by running:
terraform fmt terraform/modules/aks/main.tf

If the file is well-formatted, the command prints the filename. If there are syntax errors, it will tell you which line to fix.

Seeing a formatting error?

  • Check that every opening { has a matching closing }. The nested blocks (default_node_pool, identity, network_profile) each need their own pair.
  • Make sure string values are wrapped in double quotes, not single quotes.

Help me fix a Terraform formatting error in my AKS main.tf

Write the variables and outputs files

The variables file declares every input the module expects. The outputs file exposes values that other modules or your root configuration can reference.

  • Create terraform/modules/aks/variables.tf and add the first set of variables:
variable "resource_group_name" {
  description = "Name of the resource group for AKS"
  type        = string
}

variable "location" {
  description = "Azure region"
  type        = string
  default     = "eastus"
}

variable "cluster_name" {
  description = "Name of the AKS cluster"
  type        = string
}

variable "dns_prefix" {
  description = "DNS prefix for the AKS cluster"
  type        = string
}
  • Below the dns_prefix variable, add the remaining variables:
variable "kubernetes_version" {
  description = "Kubernetes version for AKS"
  type        = string
  default     = "1.36"
}

variable "subnet_id" {
  description = "The ID of the subnet to deploy AKS nodes into"
  type        = string
}

variable "acr_id" {
  description = "The resource ID of the ACR to grant pull access"
  type        = string
}

variable "tags" {
  description = "Tags to apply to resources"
  type        = map(string)
  default     = {}
}

What are these variables for?

  • subnet_id receives the AKS subnet ID from your Project 1 networking module, placing nodes inside your existing VNet.
  • acr_id receives your ACR's resource ID. You will use this in the next step to grant AKS permission to pull images.
  • kubernetes_version defaults to 1.36, the latest GA version on AKS as of mid-2026.
  • Save variables.tf.
  • Now create terraform/modules/aks/outputs.tf and add these outputs:
output "cluster_name" {
  description = "The name of the AKS cluster"
  value       = azurerm_kubernetes_cluster.aks.name
}

output "cluster_id" {
  description = "The resource ID of the AKS cluster"
  value       = azurerm_kubernetes_cluster.aks.id
}

output "kube_config" {
  description = "The kubeconfig for connecting to the cluster"
  value       = azurerm_kubernetes_cluster.aks.kube_config_raw
  sensitive   = true
}

output "kubelet_identity_object_id" {
  description = "The object ID of the kubelet managed identity"
  value       = azurerm_kubernetes_cluster.aks.kubelet_identity[0].object_id
}

Why output the kubelet identity?

The kubelet_identity_object_id is the identity AKS uses when pulling container images. You will need this in the next step to grant image-pull permissions.

  • Save outputs.tf.
  • Run terraform validate to confirm all three files are syntactically correct:
terraform validate

You should see Success! The configuration is valid. printed.

Seeing validation errors?

  • If Terraform reports a missing variable reference, check that every var. reference in main.tf has a matching declaration in variables.tf.
  • If it reports an unexpected attribute, double-check block names match the AzureRM provider's resource schema (default_node_pool, not node_pool).

Help me fix Terraform validation errors in my AKS module

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: main.tf, variables.tf, and outputs.tf inside terraform/modules/aks/.

ⓧ I'd like to double check the full code

Your main.tf should contain two resources: the azurerm_resource_group block (resource group for AKS) followed by the azurerm_kubernetes_cluster block (with default_node_pool, identity, and network_profile nested inside). Scroll up and confirm both chunks are present in order.

Your variables.tf should declare 7 variables: resource_group_name, location, cluster_name, dns_prefix, kubernetes_version, subnet_id, acr_id, and tags. Scroll up and confirm both chunks are present in order.

Your outputs.tf should have 4 outputs. Here it is in full:

output "cluster_name" {
  description = "The name of the AKS cluster"
  value       = azurerm_kubernetes_cluster.aks.name
}

output "cluster_id" {
  description = "The resource ID of the AKS cluster"
  value       = azurerm_kubernetes_cluster.aks.id
}

output "kube_config" {
  description = "The kubeconfig for connecting to the cluster"
  value       = azurerm_kubernetes_cluster.aks.kube_config_raw
  sensitive   = true
}

output "kubelet_identity_object_id" {
  description = "The object ID of the kubelet managed identity"
  value       = azurerm_kubernetes_cluster.aks.kubelet_identity[0].object_id
}
Wire the module and deploy the cluster

With the module files in place, you need to call the AKS module from your root configuration, passing in the subnet ID from your networking module and the ACR ID from your registry module.

  • In your root Terraform configuration (your dev environment file where you call other modules), add the AKS module block:
module "aks" {
  source = "./modules/aks"

  resource_group_name = "rg-aks-dev-eastus"
  cluster_name        = "aks-dev-eastus"
  dns_prefix          = "aks-dev"
  subnet_id           = module.networking.aks_subnet_id
  acr_id              = module.acr.acr_id
  kubernetes_version  = "1.36"

  tags = {
    Environment = "dev"
    Project     = "cloud-platform"
  }
}

What does this wiring do?

  • subnet_id = module.networking.aks_subnet_id pulls the AKS subnet from your Project 1 networking module. This places the cluster nodes inside your existing VNet.
  • acr_id = module.acr.acr_id passes your ACR's resource ID. You will use this to set up permissions in the next step.
  • Save the file and preview what Terraform will create by running:
terraform plan

You should see Terraform planning to create a resource group and a Kubernetes cluster (2 resources to add). Review the plan to confirm the node pool size, identity type, and network settings match what you configured.

  • Deploy the cluster by running:
terraform apply
  • Type yes when prompted to confirm.

How long does this take?

AKS cluster creation typically takes 5-8 minutes. Terraform will show progress indicators while it waits for Azure to provision the control plane and node.

When the apply completes, you will see Apply complete! Resources: 2 added in the output.

Apply failed with a quota or capacity error?

  • If you see a message about insufficient quota for Standard_B2s, your subscription may have hit a regional VM core limit. Request a quota increase in the Azure portal under Subscriptions > Usage + quotas.
  • If you see a subnet conflict error, confirm your service_cidr (10.1.0.0/16) does not overlap with your VNet CIDR (10.0.0.0/16).

Help me troubleshoot my AKS Terraform apply failure

Connect kubectl to your cluster

Your cluster is running, but kubectl on your machine does not know about it yet. You need to download the cluster credentials so kubectl can authenticate.

  • Download the cluster credentials by running:
az aks get-credentials --resource-group rg-aks-dev-eastus --name aks-dev-eastus

What does this command do?

This downloads a kubeconfig file and merges it into your local ~/.kube/config. It also installs the kubelogin credential plugin entry so kubectl authenticates to AKS using your Azure CLI session.

Before you run the next command, what do you think kubectl get nodes will show? You deployed a single-node pool. How many nodes should appear, and what status do you expect?

  • Confirm your node is running by executing:
kubectl get nodes

You should see one node listed with a Ready status. The node name will be an auto-generated Azure VM name.

Node not showing Ready?

  • If you see NotReady, the node may still be initializing. Wait 1-2 minutes and run kubectl get nodes again.
  • If you see error: no configuration has been provided, the az aks get-credentials command did not succeed. Re-run it and check the resource group and cluster names match what you used in Terraform.

Help me connect kubectl to my AKS cluster

Your AKS cluster is live and your node is ready to run containers. Next up, you will try to deploy your container image and discover firsthand why the cluster needs explicit permission to pull from your private registry.

Experience the ImagePullBackOff and Fix It

Your AKS cluster is running with a healthy node, and your container image is sitting in ACR ready to go. There is one problem: your cluster has no permission to pull from your private registry.

In this step, you will deploy your application and watch it fail. Kubernetes will report ImagePullBackOff because the AKS kubelet identity cannot authenticate to ACR. You will fix this by granting the AcrPull role via Terraform, then redeploy and confirm the pod is running.

In this step, get ready to:
  • Deploy your application and observe the ImagePullBackOff failure.
  • Grant AKS the AcrPull role on ACR using a Terraform role assignment.
  • Redeploy and verify your pod reaches Running status.
Create the Kubernetes Deployment manifest

A Deployment is the Kubernetes resource that tells the cluster what container image to run, how many replicas to maintain, and what resources each pod gets.

  • Create a k8s directory at the root of your project by running this command:
mkdir k8s
  • Create a new file called deployment.yaml inside the k8s folder. Paste the following Deployment structure into it:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloud-app
  labels:
    app: cloud-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: cloud-app
  template:
    metadata:
      labels:
        app: cloud-app
    spec:
      containers:
        - name: cloud-app
          image: [[ACR_LOGIN_SERVER="your-acr-name.azurecr.io"]]/cloud-app:v1
          ports:
            - containerPort: 5000
          env:
            - name: APP_VERSION
              value: "1.0.0"

What does this manifest do?

The Deployment tells Kubernetes to run 2 replicas of your cloud-app container. The image field points at your ACR login server so the kubelet knows where to pull from.

The selector and template labels must match so the Deployment knows which pods belong to it.

  • Now add the resource limits and health probes below the env section (still inside the containers block, at the same indentation level as env):
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "250m"
              memory: "256Mi"
          livenessProbe:
            httpGet:
              path: /health
              port: 5000
            initialDelaySeconds: 10
            periodSeconds: 15
          readinessProbe:
            httpGet:
              path: /health
              port: 5000
            initialDelaySeconds: 5
            periodSeconds: 10

What do resources and probes do?

The resources block sets CPU and memory guardrails. Kubernetes uses requests for scheduling decisions and limits as hard ceilings.

The livenessProbe restarts the container if /health stops responding. The readinessProbe removes the pod from traffic until it passes. Both hit your Flask health endpoint.

  • Save k8s/deployment.yaml.
  • Confirm the file exists by running:
ls k8s/

You should see deployment.yaml listed.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file and the indentation looks clean.

ⓧ I'd like to double check the full code

Your k8s/deployment.yaml should combine both chunks above into one continuous file. Scroll up and confirm the resources and probe blocks sit directly below the env block, at the same indentation level (10 spaces from the left margin).

Deploy and observe the failure

Your manifest is ready. Time to send it to the cluster and see what happens.

  • Apply the Deployment to your cluster by running:
kubectl apply -f k8s/deployment.yaml

Before you check the pods, predict: do you think the pods will start successfully, or will they fail? Why?

  • Check the pod status by running:
kubectl get pods

You will see both pods stuck in ImagePullBackOff or ErrImagePull status. This is the designed obstacle. Your cluster cannot authenticate to your private ACR.

  • Inspect the error details by describing one of the pods:
kubectl describe pod $(kubectl get pods -o jsonpath='{.items[0].metadata.name}')

In the Events section at the bottom, you will see a message indicating a failure to fetch an oauth token with a 401 Unauthorized status. This confirms the kubelet identity has no permission to pull from ACR.

Why did this happen?

Your AKS cluster uses a Managed Identity (specifically a kubelet identity) to authenticate when pulling container images. Your ACR is a private registry. Without an explicit role assignment granting pull access, the identity's token request is rejected.

This is exactly how production environments work. No stored passwords, no shared secrets. Access is granted through Azure RBAC role assignments on the specific resource.

Grant AKS the AcrPull role via Terraform

The fix is a single Terraform resource: an azurerm_role_assignment that grants the AKS kubelet identity the AcrPull role, scoped to your ACR.

  • Open terraform/modules/aks/main.tf in your editor.
  • Add the following resource block at the bottom of the file, after the azurerm_kubernetes_cluster resource:
resource "azurerm_role_assignment" "aks_acr_pull" {
  # Grant the AKS kubelet identity permission to pull images from ACR
  scope                            = var.acr_id
  role_definition_name             = "AcrPull"
  principal_id                     = azurerm_kubernetes_cluster.aks.kubelet_identity[0].object_id
  skip_service_principal_aad_check = true
}

What does this role assignment do?

The scope targets your ACR resource specifically. The principal_id is the object ID of the AKS kubelet's managed identity, which is what nodes use when pulling images.

The AcrPull role grants read-only pull access. No push, no delete. This follows the principle of least privilege.

  • Save terraform/modules/aks/main.tf.
  • Navigate to your Terraform environment directory and apply the change by running:
terraform apply

Terraform will show 1 resource to add (the role assignment). Type yes to confirm.

  • Wait for the apply to complete. You should see a success message indicating 1 resource added.

Terraform showing more than 1 change?

If Terraform wants to modify or recreate resources beyond the single role assignment, check that you have not accidentally edited the cluster or resource group blocks. Run terraform plan first to review. Only the azurerm_role_assignment.aks_acr_pull resource should appear as a new addition.

Help me debug unexpected Terraform changes

Redeploy and verify the pod is running

The role assignment is now active. Your AKS kubelet identity can authenticate to ACR. Time to redeploy and prove it works.

  • Restart the Deployment to trigger a fresh image pull by running:
kubectl rollout restart deployment cloud-app
  • Watch the pods transition to Running status by running:
kubectl get pods -w

Within 30-60 seconds, you should see pods move through ContainerCreating to Running. Press Ctrl+C to stop watching once both pods show Running.

  • Verify the application started correctly by checking the logs:
kubectl logs $(kubectl get pods -o jsonpath='{.items[0].metadata.name}')

You should see the Flask startup message indicating the server is running on port 5000.

Pods still in ImagePullBackOff?

Azure role assignments can take 1-2 minutes to propagate. If the pods are still failing after the rollout restart, wait a minute and try again:

kubectl delete pods --all
kubectl get pods -w

If you still see the error after 3 minutes, verify the role assignment was created by running terraform state list and looking for azurerm_role_assignment.aks_acr_pull.

Help me debug why my pods still cannot pull from ACR

Your pods are running, pulling directly from your private registry using Managed Identity. No passwords stored anywhere. Next up, you will expose this application to the internet and prove the full pipeline works end-to-end.

Expose and Validate the Application

Your pods are running and pulling images from ACR. That is a major milestone. But right now, nobody outside the cluster can reach your application.

A running pod without a network path to it is like a server locked in a room with no door. In this step, you expose your application using a Kubernetes Service of type LoadBalancer, which provisions a public IP address and routes external traffic to your pods. Then you hit the health endpoint to prove the entire pipeline works end-to-end.

In this step, get ready to:
  • Create a Kubernetes Service manifest of type LoadBalancer.
  • Hit the health endpoint from outside the cluster to prove the pipeline works.
  • Validate the complete container lifecycle from ACR to running pod.
Create the LoadBalancer Service manifest

A Kubernetes Service is the resource that gives your pods a stable network endpoint. The LoadBalancer type tells Azure to provision a public IP and forward traffic from port 80 to your containers on port 5000.

  • Create a new file called service.yaml inside your k8s/ directory and paste the following code:
apiVersion: v1
kind: Service
metadata:
  name: cloud-app-service
  labels:
    app: cloud-app
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 5000
      protocol: TCP
  selector:
    app: cloud-app

What does this manifest do?

  • type: LoadBalancer tells Azure to provision a public IP address and an Azure Load Balancer resource.
  • port: 80 is the external port users hit. targetPort: 5000 is the port your Flask container listens on.
  • selector: app: cloud-app routes traffic only to pods with the matching label from your Deployment.
  • Apply the Service to your cluster by running:
kubectl apply -f k8s/service.yaml
  • Confirm the Service was created by running:
kubectl get svc cloud-app-service

You should see a row showing cloud-app-service with TYPE LoadBalancer. The EXTERNAL-IP column might still show <pending>. That is normal.

Service not appearing?

  • Make sure you ran the command from the directory containing your k8s/ folder. If not, use the full path: kubectl apply -f k8s/service.yaml.
  • Check the selector matches your Deployment labels. Both must have app: cloud-app.

Why isn't my Kubernetes Service being created?

Wait for the external IP and test the endpoints

Azure needs 1-3 minutes to provision the public IP and attach it to the load balancer. You can watch the Service in real time until the IP appears.

  • Watch for the external IP to be assigned by running:
kubectl get svc cloud-app-service -w

Wait until the EXTERNAL-IP column changes from <pending> to an actual IP address. Then press Ctrl+C to stop watching.

Still seeing pending after 5 minutes?

  • Run kubectl describe svc cloud-app-service and look at the Events section for errors.
  • Your subscription may have a quota on public IPs. Check the Azure portal under your subscription's Usage + Quotas.

Why is my LoadBalancer external IP stuck on pending?

Before you run the next command, what do you expect to get back from the health endpoint?

  • Hit the health endpoint by running:
curl http://[[EXTERNAL_IP="your external IP address"]]/health

You should see:

{"status": "healthy"}
  • Now hit the root endpoint by running:
curl http://[[EXTERNAL_IP="your external IP address"]]/

You should see:

{"message": "Hello from the Cloud Platform!", "version": "1.0.0"}

That response came from a container you built, pushed to your private registry, and deployed to your cluster. The full pipeline is working.

Validate the full container lifecycle

Now that traffic is flowing, take a step back and see all the Kubernetes resources working together.

  • View all resources in your cluster by running:
kubectl get all

You should see your Deployment, ReplicaSet, two Pods, and the Service all listed together. This is the full set of resources delivering your application.

  • Confirm your image tag exists in ACR by running:
az acr repository show-tags --name [[ACR_NAME="your ACR name"]] --repository cloud-app

You should see v1 listed in the output. This confirms the image your pods are running is the exact image stored in your private registry.

What did you just prove?

You traced the complete container lifecycle from start to finish. Code became a Docker image, the image was pushed to your private ACR, AKS pulled it using Managed Identity, and a LoadBalancer Service made it reachable over the internet.

This is the same delivery pipeline production Azure environments use every day.

✔️ Awesome, I've got everything!

Great. Double check you have saved k8s/service.yaml and that both curl commands returned the expected JSON.

ⓧ I'd like to double check the full code

Here is the complete k8s/service.yaml file:

apiVersion: v1
kind: Service
metadata:
  name: cloud-app-service
  labels:
    app: cloud-app
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 5000
      protocol: TCP
  selector:
    app: cloud-app

Your container platform is fully operational. You can build, store, deploy, and serve applications on Azure. Next up, you will tackle an optional Secret Mission where you deploy a second version and perform a rolling update with zero downtime.

Secret mission

Rolling Update with Zero Downtime

Your app is live, but what happens when you need to ship a new version? In production, you never stop the old version before the new one is healthy. Deploy a v2 of your application and perform a rolling update that transitions traffic without dropping a single request.

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 generate ongoing charges while they exist.

Cost warning

Your AKS cluster node costs approximately $1.13/day and your ACR costs approximately $0.17/day. The LoadBalancer Service also creates a public IP that incurs a small hourly charge.

If you are not planning to continue to the next project within a day or two, choose Pause or Delete to avoid unnecessary charges.

Resources you used:

  • Azure Container Registry (Basic SKU) in its own resource group.
  • AKS Cluster (1x Standard_B2s node) in its own resource group.
  • Kubernetes LoadBalancer Service (creates an Azure public IP).
  • Local project files (app/, terraform/, k8s/ directories).
  • Local Docker images (cloud-app:v1 and cloud-app:v2).

Keep everything running

No action needed. Choose this if you plan to continue to the next project (Helm, Security, CI/CD) within the next day or two. Your total cost is roughly $1.30/day.

  • Your AKS cluster remains running and accessible via kubectl.
  • Your ACR retains both image tags (cloud-app:v1 and cloud-app:v2).
  • Your networking infrastructure from Project 1 remains untouched.

Pause - I'll come back to this later

Stop the AKS cluster to deallocate the control plane and all agent nodes. This eliminates VM charges while keeping your cluster configuration and ACR intact for quick resumption.

  • Stop the AKS cluster by running:
az aks stop --name [[AKS_CLUSTER_NAME="your AKS cluster name"]] --resource-group [[AKS_RESOURCE_GROUP="your AKS resource group name"]]
  • Verify the cluster has stopped by running:
az aks show --name [[AKS_CLUSTER_NAME="your AKS cluster name"]] --resource-group [[AKS_RESOURCE_GROUP="your AKS resource group name"]] --query powerState

You should see Stopped in the output.

  • ACR remains active (approximately $0.17/day) so your images are ready when you resume.
  • When you are ready to resume, start the cluster by running:
az aks start --name [[AKS_CLUSTER_NAME="your AKS cluster name"]] --resource-group [[AKS_RESOURCE_GROUP="your AKS resource group name"]]

Delete - I don't want to use this again

Remove all project resources and stop all charges. Your networking infrastructure from Project 1 remains untouched unless you destroy that separately.

  • Delete the Kubernetes resources first by running:
kubectl delete -f k8s/service.yaml
kubectl delete -f k8s/deployment.yaml
  • Navigate to the directory where you applied your AKS Terraform configuration and destroy the AKS resources by running:
terraform destroy
  • Navigate to the directory where you applied your ACR Terraform configuration and destroy the ACR resources by running:
terraform destroy
  • Remove local Docker images by running:
docker rmi $(docker images --filter=reference='*cloud-app*' -q)
  • Remove local project files by running:
rm -rf app/ k8s/ terraform/modules/acr/ terraform/modules/aks/

All Azure resources and local files are now removed. No further charges will accrue from this project.

Nice Work!

Nice Work!

You just built a complete container delivery platform on Azure. From a private registry to a running cluster, you now own the pipeline that takes code from your local machine to a live, reachable application.

You've learned how to:

  • Build Terraform modules for Azure Container Registry and Azure Kubernetes Service, deploying AKS into your existing VNet and authenticating image pulls with Managed Identity instead of stored credentials.
  • Create a production-quality Docker image using a multi-stage Dockerfile with optimized layers, a non-root user, and a .dockerignore file to keep the final image lean.
  • Diagnose and fix an ImagePullBackOff error by granting the AcrPull role to the AKS kubelet identity via Terraform, then expose the application through a LoadBalancer Service and validate the health endpoint.
  • Perform a rolling update from v1 to v2 with zero downtime, proving you can safely deploy application changes to a live cluster without dropping requests.

Ready to quiz yourself?