Deploy a Containerized App to AKS

Provision AKS with Terraform and deploy a containerized app via Azure DevOps CI/CD.

Introduction

30 Second Summary

Every time a company ships a software update, someone (or something) needs to build the code, package it, and push it to a server. When that process is manual, things break. Teams that automate the entire flow from infrastructure provisioning to application deployment can ship changes reliably and repeatedly.

In this project, you will write Terraform to provision cloud infrastructure, containerize a Node.js app with Docker, and author a multi-stage Azure DevOps YAML pipeline that automatically builds, pushes, and deploys your application to Azure Kubernetes Service.

What You'll Build

A complete infrastructure-to-deployment pipeline that provisions an AKS cluster and Azure Container Registry with Terraform, then uses a multi-stage Azure DevOps YAML pipeline to build, push, and deploy a containerized application to Kubernetes.

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

  • A working AKS cluster and Azure Container Registry provisioned entirely through Terraform configuration files.
  • A multi-stage YAML pipeline in Azure DevOps that builds a Docker image, pushes it to your registry, and deploys it to your cluster.
  • A running application accessible via a public IP through a Kubernetes LoadBalancer service, deployed automatically by your pipeline.
  • Secret Mission: Add remote Terraform state management and a Validate stage to your pipeline for production-grade practices.

Are there any prerequisites?

This project assumes you already have an Azure sandbox subscription, an Azure DevOps organization, and the following tools installed locally: Terraform, Azure CLI, Docker, and kubectl. Step 1 will confirm everything is ready before you start building.

Set Up Your Azure Environment

Before writing any Terraform or pipeline code, you need a working connection between your local machine and Azure. This step gets your Azure CLI authenticated and your Azure DevOps project ready to host the pipeline.

You will also create a service connection. This lets your Azure DevOps pipeline authenticate to Azure on its own, without needing your personal credentials.

In this step, get ready to:
  • Log in to Azure CLI and register the required resource providers.
  • Create an Azure DevOps project with a Git repository cloned locally.
  • Create a service connection for pipeline-to-Azure authentication.
Log in to Azure and set your subscription

Terraform and the Azure DevOps pipeline both need access to your Azure subscription. The Azure CLI is the bridge that authenticates everything.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Log in to Azure by running this command:
az login

What does this command do?

This opens a browser window where you sign in with your Azure credentials. Once you authenticate, the CLI stores a token so all subsequent commands run against your subscription.

After signing in, the CLI prints a list of your available subscriptions. Confirm you are on the correct sandbox subscription by running:

  • Check which subscription is active by running this command:
az account show --query "{name:name, id:id}" -o table

You should see your sandbox subscription name and ID displayed in a table.

Wrong subscription showing?

If the output shows a different subscription, set it explicitly by running:

az account set --subscription "your-subscription-id"

Replace the ID with the one for your sandbox subscription, then re-run az account show to confirm.

help me switch to the correct Azure subscription

  • Register the resource providers that Azure Kubernetes Service and Azure Container Registry need by running these commands:
az provider register --namespace Microsoft.ContainerService
az provider register --namespace Microsoft.ContainerRegistry

What are resource providers?

Azure organizes services into resource providers. Before you can create an AKS cluster or a container registry, the corresponding providers must be registered on your subscription.

Registration is a one-time action. It can take a minute or two to complete in the background. If a provider is already registered, the command does nothing.

Seeing an authorization error?

Your account needs at least Contributor-level access on the subscription to register providers. If you see an authorization failure, check that you are signed into the correct sandbox subscription.

help me fix an authorization error when registering Azure resource providers

Create an Azure DevOps project and repository

Your pipeline code, Terraform files, and application source all live in a Git repository hosted in Azure DevOps. You will create a new project and clone its repo locally.

  • Open your browser and navigate to https://dev.azure.com.
  • Sign in with the same account you used for Azure CLI.
  • Click + New project in the top-right corner.
  • Enter aks-pipeline-demo as the project name.
  • Leave visibility set to Private.
  • Click Create.

Azure DevOps creates the project and initializes an empty Git repository.

  • In the left sidebar, click Repos.
  • Under Initialize main branch with:, select README.
  • Click Initialize.
  • Click the Clone button in the top-right area of the repo page.
  • Copy the HTTPS clone URL.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone the repository by running this command (paste your own URL):
git clone [[REPO_URL="your Azure DevOps repo HTTPS clone URL"]]
  • Move into the cloned folder by running:
cd aks-pipeline-demo
  • Confirm the files exist by running:
ls

You should see a README.md file in the output.

Clone failing with authentication errors?

Azure DevOps may prompt for credentials during the clone. If you see an authentication error, Azure DevOps might ask you to generate a Git credential or use a personal access token. Follow the on-screen prompts to authenticate.

help me authenticate when cloning from Azure DevOps

Create an Azure DevOps service connection

A service connection is how your Azure DevOps pipeline authenticates to Azure without your personal login. You will create one using workload identity federation, which eliminates the need for expiring secrets.

  • In your Azure DevOps project, click Project settings in the bottom-left corner.
  • Under the Pipelines section, click Service connections.
  • Click New service connection.
  • Select Azure Resource Manager, then click Next.
  • Select App registration (automatic) with the credential Workload identity federation.
  • For Scope level, select Subscription.
  • Select your sandbox subscription from the Subscription dropdown.
  • Leave Resource group empty (the pipeline needs subscription-wide access).
  • In the Service connection name field, enter azure-service-connection.
  • Leave Grant access permission to all pipelines unchecked (you will authorize it per-pipeline later).
  • Click Save.

Why workload identity federation?

Traditional service connections use a client secret that expires every few months. When a secret expires, pipelines break until someone rotates it.

Workload identity federation uses an industry-standard OIDC token exchange instead. Azure DevOps presents a short-lived token to Azure, which validates it against a pre-configured trust relationship. No secrets to store, no expiry to manage.

After saving, Azure DevOps validates the connection. You should see azure-service-connection listed on the Service connections page with a green checkmark.

Verification failed?

Make sure you selected the correct subscription (the same sandbox subscription you verified with az account show earlier). If the subscription dropdown is empty, your Azure DevOps account may not have Owner or Contributor access to the subscription.

help me fix a failed service connection verification in Azure DevOps

Your Azure environment is fully configured. Next up, you will write the Terraform configuration that provisions your AKS cluster and container registry.

Write the Terraform Configuration

Your Azure environment is ready and your DevOps pipeline can authenticate to Azure. Now you need to define what infrastructure to build.

Terraform lets you describe infrastructure as code in .tf files. You will create four files that together define an AKS cluster, an Azure Container Registry, and the permissions connecting them.

In this step, get ready to:
  • Create the provider and variables files that pin your Terraform and Azure versions.
  • Write the main infrastructure file that provisions a resource group, container registry, and AKS cluster.
  • Create the outputs file that exports key values for later steps.
Create the provider and variables files

Every Terraform project starts with a provider configuration. This tells Terraform which cloud provider plugin to download and which version constraints to enforce.

  • In your code editor, create a new file called providers.tf in the root of your cloned repository.
  • Paste the following code into providers.tf:
# Pin Terraform and the AzureRM provider to specific versions
terraform {
  required_version = ">= 1.9.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.76"
    }
  }
}

# Configure the Azure provider
provider "azurerm" {
  features {}
}

What does this code do?

The terraform block sets two constraints. required_version = ">= 1.9.0" ensures anyone running this config uses Terraform 1.9.0 or newer.

The required_providers block pins the AzureRM provider to version ~> 4.76. The ~> operator allows patch updates (4.76.x) but not minor version jumps, keeping your builds reproducible.

The provider "azurerm" block activates the plugin. The features {} argument is required even if empty.

  • Save providers.tf.

You won't see a change yet. The next file defines the variables that your infrastructure resources will use.

  • Create a new file called variables.tf in the same directory as providers.tf.
  • Paste the following code into variables.tf:
# Parameterize values so you can reuse this config across environments
variable "location" {
  description = "Azure region for all resources"
  type        = string
  default     = "uksouth"
}

variable "resource_group_name" {
  description = "Name of the resource group"
  type        = string
  default     = "rg-aks-pipeline-demo"
}

variable "cluster_name" {
  description = "Name of the AKS cluster"
  type        = string
  default     = "aks-pipeline-demo"
}

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

What does this code do?

Each variable block declares a named input with a type constraint and a default value. By parameterizing these values instead of hardcoding them, you can reuse the same Terraform files across different environments by overriding defaults.

The acr_name must be globally unique across all of Azure. If acrpipelinedemo is taken, you will need to change this default later.

  • Save variables.tf.
  • Confirm both files exist by running:
ls *.tf

You should see providers.tf and variables.tf listed in the output.

Don't see both files?

Make sure your terminal is in the root of your cloned repository. Run pwd to check your current directory.

If you saved the files in the wrong location, move them into your repo root folder.

Help me find where my Terraform files ended up.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is providers.tf in full:

terraform {
  required_version = ">= 1.9.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.76"
    }
  }
}

provider "azurerm" {
  features {}
}

Here is variables.tf in full:

variable "location" {
  description = "Azure region for all resources"
  type        = string
  default     = "uksouth"
}

variable "resource_group_name" {
  description = "Name of the resource group"
  type        = string
  default     = "rg-aks-pipeline-demo"
}

variable "cluster_name" {
  description = "Name of the AKS cluster"
  type        = string
  default     = "aks-pipeline-demo"
}

variable "acr_name" {
  description = "Name of the Azure Container Registry (must be globally unique)"
  type        = string
  default     = "acrpipelinedemo"
}
Create the main infrastructure file

This file is the heart of your Terraform configuration. It defines every Azure resource your project needs and the dependencies between them.

  • Create a new file called main.tf in your repo root.
  • Start by pasting the resource group and container registry definitions:
# Create a resource group to hold all project resources
resource "azurerm_resource_group" "main" {
  name     = var.resource_group_name
  location = var.location
}

# Create a container registry to store Docker images
resource "azurerm_container_registry" "main" {
  name                = var.acr_name
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  sku                 = "Basic"
  admin_enabled       = false
}

What does this code do?

The azurerm_resource_group is a logical container that groups all your Azure resources together for easy management and cleanup.

The azurerm_container_registry creates a private Docker registry where your pipeline will push images. The Basic SKU is sufficient for this project. Setting admin_enabled = false means your pipeline authenticates via a service principal (the recommended approach) instead of a shared admin password.

  • Save main.tf.

You won't see a change yet. The next chunk adds the AKS cluster and connects it to your registry.

  • In main.tf, add the AKS cluster and role assignment below the container registry block:
# Create the AKS cluster with a single node pool
resource "azurerm_kubernetes_cluster" "main" {
  name                = var.cluster_name
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  dns_prefix          = var.cluster_name

  default_node_pool {
    name       = "default"
    node_count = 1
    vm_size    = "Standard_B2s"
  }

  identity {
    type = "SystemAssigned"
  }
}

# Grant AKS permission to pull images from ACR
resource "azurerm_role_assignment" "aks_acr_pull" {
  principal_id                     = azurerm_kubernetes_cluster.main.kubelet_identity[0].object_id
  role_definition_name             = "AcrPull"
  scope                            = azurerm_container_registry.main.id
  skip_service_principal_aad_check = true
}

What does this code do?

The azurerm_kubernetes_cluster provisions a managed Kubernetes cluster with one Standard_B2s node. The identity block gives the cluster a system-assigned managed identity so it can authenticate to other Azure services without storing credentials.

The azurerm_role_assignment grants the cluster's kubelet identity the AcrPull role on your container registry. This allows AKS nodes to pull Docker images from ACR without needing a separate image pull secret.

Why a role assignment instead of admin credentials?

Azure RBAC role assignments follow the principle of least privilege. The AKS nodes only get permission to pull images, nothing else. Admin credentials would give full read/write access to the registry, which is more access than needed and harder to audit.

  • Save main.tf.
  • Confirm the file exists by running:
ls main.tf

You should see main.tf in the output.

Seeing a syntax error in your editor?

HCL syntax requires matching braces for every block. Double-check that each opening { has a corresponding }. Pay attention to the nested default_node_pool and identity blocks inside the cluster resource.

Help me fix my main.tf syntax.

✔️ Awesome, I've got everything!

Great. Make sure main.tf is saved before continuing.

ⓧ I'd like to double check the full code

Here is main.tf in full:

resource "azurerm_resource_group" "main" {
  name     = var.resource_group_name
  location = var.location
}

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

resource "azurerm_kubernetes_cluster" "main" {
  name                = var.cluster_name
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  dns_prefix          = var.cluster_name

  default_node_pool {
    name       = "default"
    node_count = 1
    vm_size    = "Standard_B2s"
  }

  identity {
    type = "SystemAssigned"
  }
}

resource "azurerm_role_assignment" "aks_acr_pull" {
  principal_id                     = azurerm_kubernetes_cluster.main.kubelet_identity[0].object_id
  role_definition_name             = "AcrPull"
  scope                            = azurerm_container_registry.main.id
  skip_service_principal_aad_check = true
}
Create the outputs file

Outputs let you extract key values from your infrastructure after Terraform applies it. You will use these values in later steps to connect kubectl to your cluster and reference your ACR login server.

  • Create a new file called outputs.tf in your repo root.
  • Paste the following code into outputs.tf:
# Export values for use in later steps and the pipeline
output "resource_group_name" {
  value = azurerm_resource_group.main.name
}

output "cluster_name" {
  value = azurerm_kubernetes_cluster.main.name
}

output "acr_login_server" {
  value = azurerm_container_registry.main.login_server
}

output "kube_config" {
  value     = azurerm_kubernetes_cluster.main.kube_config_raw
  sensitive = true
}

What does this code do?

Each output block exports a value that Terraform displays after apply completes. You will use cluster_name and resource_group_name to connect kubectl to your cluster. The acr_login_server tells your pipeline where to push images.

The kube_config output is marked sensitive = true because it contains cluster credentials. Terraform will hide this value in terminal output but still store it in state.

  • Save outputs.tf.
  • Confirm all four Terraform files exist by running:
ls *.tf

You should see main.tf, outputs.tf, providers.tf, and variables.tf in the output.

Missing a file?

Verify you created all files in the same directory. All four .tf files must be in the root of your cloned repository, not in a subfolder.

Help me find my missing Terraform files.

✔️ Awesome, I've got everything!

Great. Double check that all files are saved.

ⓧ I'd like to double check the full code

Here is outputs.tf in full:

output "resource_group_name" {
  value = azurerm_resource_group.main.name
}

output "cluster_name" {
  value = azurerm_kubernetes_cluster.main.name
}

output "acr_login_server" {
  value = azurerm_container_registry.main.login_server
}

output "kube_config" {
  value     = azurerm_kubernetes_cluster.main.kube_config_raw
  sensitive = true
}

Your Terraform configuration is complete. Next up, you will initialize Terraform, validate the syntax, and apply these files to provision real infrastructure in Azure.

Deploy Infrastructure with Terraform

Your Terraform configuration files define the desired state of your Azure infrastructure. Now it is time to turn those definitions into real cloud resources.

In this step, you will run Terraform's core workflow to provision your AKS cluster, container registry, and resource group. You will also connect kubectl to your new cluster so you can manage it directly from your terminal.

In this step, get ready to:
  • Initialize and validate the Terraform configuration.
  • Provision the AKS cluster, container registry, and resource group.
  • Connect kubectl to the new AKS cluster.
Initialize and validate your configuration

Before Terraform can create resources, it needs to download the provider plugins specified in your configuration. The terraform init command handles this setup.

  • Download the AzureRM provider by running this command:
terraform init
  • You should see output ending with Terraform has been successfully initialized!

What does terraform init do?

This command reads your providers.tf file, downloads the hashicorp/azurerm provider plugin matching version ~> 4.76, and stores it in a hidden .terraform directory.

It also creates a .terraform.lock.hcl file that locks the exact provider version for reproducible builds.

  • Validate your configuration syntax by running:
terraform validate
  • You should see: Success! The configuration is valid.

Seeing a validation error?

  • Check that all four files (providers.tf, variables.tf, main.tf, outputs.tf) are saved in the same directory.
  • Make sure you ran terraform init first. Validation requires the provider to be downloaded.
  • Look for typos in resource names or variable references. Terraform will point to the exact line with the issue.

Help me debug my Terraform validation error.

Plan and apply the infrastructure

Terraform's plan command lets you preview exactly what will be created before committing any real changes to your Azure subscription. This is a safety mechanism that prevents accidental resource creation.

  • Generate an execution plan by running:
terraform plan -out=tfplan

What should I see in the plan output?

Terraform displays a summary showing 4 resources to add: azurerm_resource_group, azurerm_container_registry, azurerm_kubernetes_cluster, and azurerm_role_assignment.

The -out=tfplan flag saves the plan to a file. This ensures the exact plan you reviewed is what gets applied, with no surprises.

  • Apply the saved plan to provision your infrastructure by running:
terraform apply tfplan

How long does this take?

The AKS cluster takes approximately 5-10 minutes to provision. Terraform displays progress updates as each resource is created. Wait for the command to complete before moving on.

When finished, you will see Apply complete! Resources: 4 added, 0 changed, 0 destroyed. followed by your output values.

Apply failed with an error?

  • If you see a naming conflict on the container registry, the name acrpipelinedemo may already be taken globally. Update the acr_name default in variables.tf to something unique (add your initials or a number), then re-run terraform plan -out=tfplan and terraform apply tfplan.
  • If you see an authorization error, confirm you are logged in with az account show and that your subscription is active.
  • If you see a quota error for Standard_B2s, your subscription may have hit its vCPU limit in uksouth. Try changing the location default in variables.tf to another region like eastus.

Help me fix my Terraform apply error.

Connect to your AKS cluster

Your AKS cluster is running in Azure, but your local kubectl tool does not know about it yet. You need to download the cluster credentials so kubectl can communicate with the Kubernetes API server.

  • Configure kubectl with your AKS cluster credentials by running:
az aks get-credentials --resource-group rg-aks-pipeline-demo --name aks-pipeline-demo

What does this command do?

This downloads the cluster's certificate and connection details into your local ~/.kube/config file. Once configured, all kubectl commands will target your AKS cluster automatically.

  • Verify your cluster is running by checking the node status:
kubectl get nodes
  • You should see one node listed with a Ready status. The node name will contain aks-default followed by a generated ID.

Node not showing Ready?

  • If the command hangs or times out, the cluster may still be initializing. Wait a minute and try again.
  • If you see a connection refused error, re-run the az aks get-credentials command to refresh your credentials.
  • If the node shows NotReady, wait 2-3 minutes for the node to finish bootstrapping.

My kubectl get nodes command is not working. Help me troubleshoot.

Your Azure infrastructure is live. You have an AKS cluster, a container registry, and they are connected via the AcrPull role assignment. Next up, you will build the containerized application that will run on this cluster.

Containerize the Application

Your infrastructure is live. You have an AKS cluster and a container registry ready to receive images. Now you need an application to deploy.

In this step, you will create a simple Node.js web server and package it into a Docker container image. Containerizing the app ensures it runs identically on your machine and inside the AKS cluster.

In this step, get ready to:
  • Create a Node.js application that serves a JSON response on port 3000.
  • Write a Dockerfile that packages the app into a lean Alpine-based image.
  • Build and test the container locally before pushing it through the pipeline.
Create the Node.js application

The app itself is intentionally simple. A minimal HTTP server lets you focus on the containerization and pipeline mechanics rather than application logic.

  • Create a file called server.js in the root of your repository by running:
touch server.js
  • Open server.js in your editor and paste the following code:
const http = require("http");

const port = 3000;

// Create an HTTP server that responds with JSON
const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({
    message: "Hello from AKS!",
    version: "1.0.0",
    timestamp: new Date().toISOString()
  }));
});

server.listen(port, () => {
  console.log(`Server running on port ${port}`);
});

What does this code do?

  • It creates an HTTP server using Node.js's built-in http module.
  • Every request receives a JSON response containing a message, a version string, and the current timestamp.
  • The server listens on port 3000, which your Kubernetes Service will route traffic to later.
  • Save server.js.

File not appearing in your editor?

Make sure you ran touch server.js from the root of your cloned repository folder. If you are in a different directory, the file will be created somewhere unexpected.

help me find where my server.js file was created

Next you need a package.json to define the project metadata and start script.

  • Create package.json in the same directory by running:
touch package.json
  • Open package.json and paste the following:
{
  "name": "aks-pipeline-demo",
  "version": "1.0.0",
  "description": "Simple app for AKS pipeline demo",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  }
}

What does this file do?

The package.json tells Node.js how to run the project. The start script maps to node server.js, which is what the Docker container will execute at runtime.

  • Save package.json.
  • Confirm both files exist by running:
ls server.js package.json

You should see both server.js and package.json listed without errors.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Here is server.js:

const http = require("http");

const port = 3000;

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({
    message: "Hello from AKS!",
    version: "1.0.0",
    timestamp: new Date().toISOString()
  }));
});

server.listen(port, () => {
  console.log(`Server running on port ${port}`);
});

Here is package.json:

{
  "name": "aks-pipeline-demo",
  "version": "1.0.0",
  "description": "Simple app for AKS pipeline demo",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  }
}
Write the Dockerfile

A Dockerfile defines the exact environment your app runs in. By using a small base image and copying only what the server needs, you keep the final image lean and fast to pull.

  • Create the Dockerfile by running:
touch Dockerfile
  • Open Dockerfile and paste the following:
# Use a lean Alpine-based Node.js image
FROM node:20-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy package.json first for layer caching
COPY package.json ./
RUN npm install --production

# Copy the application code
COPY server.js ./

# Document the port the server listens on
EXPOSE 3000

# Run as a non-root user for security
USER node

# Start the server
CMD ["npm", "start"]

What does this Dockerfile do?

  • FROM node:20-alpine starts from a minimal 50 MB base image with Node.js 20 pre-installed.
  • Copying package.json before server.js means Docker can cache the npm install layer. If only application code changes, Docker skips re-installing dependencies on the next build.
  • USER node switches to a non-root user for security. The container process cannot modify system files inside the image.
  • Save Dockerfile.
  • Confirm the file exists by running:
ls Dockerfile

You should see Dockerfile listed in the output.

Seeing 'No such file or directory'?

The filename must be exactly Dockerfile with a capital D and no file extension. If you named it dockerfile or Dockerfile.txt, rename it now.

help me fix my Dockerfile naming issue

✔️ Awesome, I've got everything!

Great. Double check you have saved the file.

ⓧ I'd like to double check the full code

FROM node:20-alpine

WORKDIR /app

COPY package.json ./
RUN npm install --production

COPY server.js ./

EXPOSE 3000

USER node

CMD ["npm", "start"]
Build and test the container locally

Before handing this image off to your pipeline, verify it works on your own machine. This catches errors early and saves debugging time in the CI/CD stage.

  • Build the Docker image by running:
docker build -t aks-pipeline-demo .

What does this command do?

docker build reads the Dockerfile and creates an image. The -t aks-pipeline-demo flag tags the image with a name so you can reference it easily. The . tells Docker to use the current directory as the build context.

  • Once the build finishes, you should see a line showing the image was tagged successfully.
  • Run the container in detached mode by running:
docker run -d -p 3000:3000 --name aks-demo aks-pipeline-demo

What do these flags mean?

  • -d runs the container in the background so you can continue using the same terminal.
  • -p 3000:3000 maps port 3000 on your machine to port 3000 inside the container.
  • --name aks-demo gives the running container a name you can reference when stopping it.
  • Test the running container by sending a request:
curl http://localhost:3000

You should see a JSON response similar to this:

{"message":"Hello from AKS!","version":"1.0.0","timestamp":"2026-06-06T12:00:00.000Z"}

Seeing 'connection refused' or no output?

  • Run docker ps to check if the container is running. If it is not listed, run docker logs aks-demo to see what went wrong.
  • If port 3000 is already in use, stop the conflicting process or change the first 3000 in -p 3000:3000 to another port (e.g. -p 3001:3000) and curl that port instead.
  • Make sure Docker Desktop is running if you are on macOS or Windows.

help me debug why my Docker container is not responding

  • Stop and remove the test container by running:
docker stop aks-demo && docker rm aks-demo

This cleans up the local container. The image remains on your machine for reference.

Your application is containerized and tested. Next up, you will write the Kubernetes manifests that tell AKS how to run and expose this container.

Create Kubernetes Manifests

Your container is built and tested locally. Now you need to tell Kubernetes how to run it on your cluster.

Kubernetes manifests are YAML files that describe the desired state of your application. You will write two manifests: a Deployment (how many copies of your container to run and how to health-check them) and a Service (how to expose them to the internet).

In this step, get ready to:
  • Write a Deployment manifest with replicas, resource limits, and a readiness probe.
  • Write a LoadBalancer Service manifest that exposes your app on port 80.
Write the Deployment manifest

A Deployment tells Kubernetes how many pod replicas to run, which container image to use, and how to determine when a pod is healthy and ready for traffic.

  • Create the manifests directory in your project root by running this command:
mkdir manifests
  • Create a new file called deployment.yml inside the manifests folder, then paste the following code:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: aks-pipeline-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: aks-pipeline-demo
  template:
    metadata:
      labels:
        app: aks-pipeline-demo
    spec:
      containers:
        - name: aks-pipeline-demo
          image: IMAGE_PLACEHOLDER
          ports:
            - containerPort: 3000
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 250m
              memory: 256Mi
          readinessProbe:
            httpGet:
              path: /
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10

What does this manifest do?

This Deployment defines the desired state of your application on the cluster:

  • replicas: 2 runs two copies of your container for availability.
  • image: IMAGE_PLACEHOLDER is a temporary value. The Azure DevOps pipeline will override this with the real ACR image reference at deploy time.
  • The resources block sets CPU and memory requests (guaranteed minimum) and limits (hard ceiling) for each pod.
  • The readinessProbe sends an HTTP GET to / on port 3000 every 10 seconds. Kubernetes only routes traffic to a pod once this probe succeeds.
  • Save manifests/deployment.yml.
  • Confirm the file exists by running:
ls manifests/

You should see deployment.yml listed in the output.

Don't see the file?

  • Make sure you created the manifests directory first. If it does not exist, the file creation will fail.
  • Check that you saved the file inside the manifests/ folder, not in the project root.

Why isn't my deployment.yml showing up in the manifests folder?

Write the Service manifest

A Deployment runs your pods, but they are not reachable from outside the cluster by default. A Service of type LoadBalancer provisions an Azure load balancer that assigns a public IP and forwards external traffic to your pods.

  • Create a new file called service.yml inside the manifests folder, then paste the following code:
apiVersion: v1
kind: Service
metadata:
  name: aks-pipeline-demo
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 3000
      protocol: TCP
  selector:
    app: aks-pipeline-demo

What does this Service do?

This Service routes incoming traffic from port 80 on the public load balancer to port 3000 inside your pods. The selector field matches the app: aks-pipeline-demo label defined in your Deployment template. That label is how Kubernetes knows which pods belong to this Service.

  • Save manifests/service.yml.
  • Confirm both files exist by running:
ls manifests/

You should see both deployment.yml and service.yml listed.

Only see one file?

  • Double-check the filename matches exactly: service.yml (not service.yaml or Service.yml).
  • Verify you saved the file in the manifests/ directory and not the project root.

Help me find why my service.yml is missing from the manifests directory.

Why IMAGE_PLACEHOLDER instead of a real image?

Your Azure DevOps pipeline will build a new image on every commit and tag it with the build ID. At deploy time, the KubernetesManifest@1 task replaces IMAGE_PLACEHOLDER with the actual ACR image reference. This keeps your manifest reusable across builds without manual edits.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved in the manifests/ folder before moving on.

ⓧ I'd like to double check the full code

Here is the complete manifests/deployment.yml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: aks-pipeline-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: aks-pipeline-demo
  template:
    metadata:
      labels:
        app: aks-pipeline-demo
    spec:
      containers:
        - name: aks-pipeline-demo
          image: IMAGE_PLACEHOLDER
          ports:
            - containerPort: 3000
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 250m
              memory: 256Mi
          readinessProbe:
            httpGet:
              path: /
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10

And the complete manifests/service.yml:

apiVersion: v1
kind: Service
metadata:
  name: aks-pipeline-demo
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 3000
      protocol: TCP
  selector:
    app: aks-pipeline-demo

Your manifests are ready. Next up, you will author the Azure DevOps YAML pipeline that builds your Docker image, pushes it to ACR, and deploys these manifests to your AKS cluster automatically.

Author the Azure DevOps YAML Pipeline

Your infrastructure is running and your Kubernetes manifests are ready. The missing piece is the automation that ties everything together.

In this step, you will write an Azure DevOps YAML pipeline that builds your Docker image, pushes it to your container registry, and deploys it to AKS. You will also create the service connections that allow your pipeline to authenticate with Azure resources.

In this step, get ready to:
  • Define the pipeline trigger and variables.
  • Write a Build stage that pushes your container image to ACR.
  • Write a Deploy stage that deploys your app to AKS.
Define the pipeline structure and variables

A multi-stage YAML pipeline lives alongside your code. It declares the trigger, shared variables, and stages that run in sequence.

  • Create a new file called azure-pipelines.yml in the root of your repository by running:
touch azure-pipelines.yml
  • Open azure-pipelines.yml in your editor and paste in the trigger and variables block:
trigger:
  - main

variables:
  dockerRegistryServiceConnection: "acr-service-connection"
  imageRepository: "aks-pipeline-demo"
  dockerfilePath: "$(Build.SourcesDirectory)/Dockerfile"
  tag: "$(Build.BuildId)"
  imagePullSecret: "acr-auth-secret"
  vmImageName: "ubuntu-latest"

What does this code do?

The trigger block tells Azure DevOps to run this pipeline every time code is pushed to the main branch.

The variables block defines reusable values. dockerRegistryServiceConnection is the name of the Docker Registry service connection you will create shortly. tag: "$(Build.BuildId)" stamps each image with a unique build number so you can trace which pipeline run produced which image.

Before writing the Build stage, you need to create the ACR service connection that the pipeline references. This connection allows Azure Pipelines to push images to your container registry.

  • In your browser, navigate to your Azure DevOps project.
  • Select Project settings from the bottom-left corner of the page.
  • Select Service connections under the Pipelines section.
  • Select New service connection, then select Docker Registry, then select Next.
  • Near the top of the dialog, select Azure Container Registry.
  • Select Service Principal for the authentication type.
  • Select your Azure subscription from the Subscription dropdown.
  • Select acrpipelinedemo from the Azure container registry dropdown.
  • Enter acr-service-connection in the Service connection name field.
  • Check the Grant access permission to all pipelines checkbox.
  • Select Save.

Why a service connection?

Service connections store credentials securely inside Azure DevOps. Your pipeline references the connection by name instead of embedding secrets in your YAML file. The Docker Registry type lets the Docker@2 task authenticate and push images to ACR without any manual login.

Don't see your registry in the dropdown?

Make sure you are signed into Azure DevOps with the same account tied to your sandbox subscription. If the subscription does not appear, select Authorize next to the subscription field and follow the prompts.

help me troubleshoot why my Azure subscription is not showing up in the service connection dialog

Write the Build stage

The Build stage compiles your Docker image and pushes it to ACR. It also publishes your Kubernetes manifests as a pipeline artifact so the Deploy stage can pick them up.

  • In azure-pipelines.yml, add the following stages block below the variables section:
stages:
  - stage: Build
    displayName: "Build and Push"
    jobs:
      - job: Build
        displayName: "Build and push image"
        pool:
          vmImage: $(vmImageName)
        steps:
          - task: Docker@2
            displayName: "Build and push image to ACR"
            inputs:
              command: buildAndPush
              repository: $(imageRepository)
              dockerfile: $(dockerfilePath)
              containerRegistry: $(dockerRegistryServiceConnection)
              tags: |
                $(tag)

          - task: PublishPipelineArtifact@1
            displayName: "Publish manifests"
            inputs:
              artifactName: "manifests"
              path: "manifests"

What does this code do?

  • Docker@2 with buildAndPush builds the image from your Dockerfile and pushes it to ACR in one step. The containerRegistry input references the acr-service-connection you just created.
  • PublishPipelineArtifact@1 uploads your manifests/ folder as a named artifact. The Deploy stage in the next section will download this artifact to apply your Kubernetes manifests.
  • Save azure-pipelines.yml.

You will not see a visible change yet. The Deploy stage in the next substep completes the pipeline.

Write the Deploy stage

The Deploy stage pulls the published manifests, creates a Kubernetes image pull secret, and deploys your app to the cluster. Before writing this stage, you need a Kubernetes service connection so the pipeline can talk to your AKS cluster.

  • Return to Project settings > Service connections in Azure DevOps.
  • Select New service connection, then select Kubernetes, then select Next.
  • Select Azure subscription as the authentication method, then select Next.
  • Select your Azure subscription from the dropdown.
  • Select aks-pipeline-demo from the Cluster dropdown.
  • Select default from the Namespace dropdown.
  • Enter aks-service-connection in the Service connection name field.
  • Check the Grant access permission to all pipelines checkbox.
  • Select Save.

Stuck on Loading namespaces?

If the Namespace dropdown shows a spinner and never loads, Azure DevOps cannot reach your cluster at configuration time. Close the dialog and try selecting Kubeconfig as the authentication method instead. Paste the contents of your local kubeconfig (the file at ~/.kube/config) and check Accept untrusted certificates. Then continue with the same service connection name.

help me fix the loading namespaces issue in my Azure DevOps Kubernetes service connection

Now add the Deploy stage to your pipeline file.

  • In azure-pipelines.yml, add the following Deploy stage below the Build stage (at the same indentation level as - stage: Build):
  - stage: Deploy
    displayName: "Deploy to AKS"
    dependsOn: Build
    jobs:
      - deployment: Deploy
        displayName: "Deploy to AKS"
        pool:
          vmImage: $(vmImageName)
        environment: "aks-pipeline-demo.default"
        strategy:
          runOnce:
            deploy:
              steps:
                - task: DownloadPipelineArtifact@2
                  displayName: "Download manifests"
                  inputs:
                    artifactName: "manifests"
                    downloadPath: "$(System.ArtifactsDirectory)/manifests"

                - task: KubernetesManifest@1
                  displayName: "Create imagePullSecret"
                  inputs:
                    action: "createSecret"
                    connectionType: "kubernetesServiceConnection"
                    kubernetesServiceConnection: "aks-service-connection"
                    secretType: "dockerRegistry"
                    secretName: "$(imagePullSecret)"
                    dockerRegistryEndpoint: "$(dockerRegistryServiceConnection)"

                - task: KubernetesManifest@1
                  displayName: "Deploy to AKS"
                  inputs:
                    action: "deploy"
                    connectionType: "kubernetesServiceConnection"
                    kubernetesServiceConnection: "aks-service-connection"
                    manifests: |
                      $(System.ArtifactsDirectory)/manifests/deployment.yml
                      $(System.ArtifactsDirectory)/manifests/service.yml
                    containers: "$(containerRegistry)/$(imageRepository):$(tag)"
                    imagePullSecrets: "$(imagePullSecret)"

What does this code do?

  • dependsOn: Build ensures this stage only runs after the Build stage succeeds.
  • The deployment job type targets an Azure DevOps environment named aks-pipeline-demo.default. Azure DevOps creates this environment automatically on first run.
  • The first KubernetesManifest@1 task creates an imagePullSecret in the cluster. This secret lets your AKS nodes authenticate with ACR to pull private images.
  • The second KubernetesManifest@1 task deploys your manifests and injects the correct container image reference (replacing IMAGE_PLACEHOLDER with the actual ACR image path and tag).
  • Save azure-pipelines.yml.

Confirm the file exists by running:

ls azure-pipelines.yml

You should see azure-pipelines.yml in the output.

YAML indentation errors?

YAML is whitespace-sensitive. The - stage: Deploy line must be at the same indent level as - stage: Build (two spaces in from stages:). If your editor auto-indents differently, check for mixed tabs and spaces.

help me fix YAML indentation in my azure-pipelines.yml file

✔️ Awesome, I've got everything!

Great. Double check you have saved azure-pipelines.yml and that both service connections appear in your Azure DevOps project settings.

ⓧ I'd like to double check the full code

Here is the complete azure-pipelines.yml file:

trigger:
  - main

variables:
  dockerRegistryServiceConnection: "acr-service-connection"
  imageRepository: "aks-pipeline-demo"
  dockerfilePath: "$(Build.SourcesDirectory)/Dockerfile"
  tag: "$(Build.BuildId)"
  imagePullSecret: "acr-auth-secret"
  vmImageName: "ubuntu-latest"

stages:
  - stage: Build
    displayName: "Build and Push"
    jobs:
      - job: Build
        displayName: "Build and push image"
        pool:
          vmImage: $(vmImageName)
        steps:
          - task: Docker@2
            displayName: "Build and push image to ACR"
            inputs:
              command: buildAndPush
              repository: $(imageRepository)
              dockerfile: $(dockerfilePath)
              containerRegistry: $(dockerRegistryServiceConnection)
              tags: |
                $(tag)

          - task: PublishPipelineArtifact@1
            displayName: "Publish manifests"
            inputs:
              artifactName: "manifests"
              path: "manifests"

  - stage: Deploy
    displayName: "Deploy to AKS"
    dependsOn: Build
    jobs:
      - deployment: Deploy
        displayName: "Deploy to AKS"
        pool:
          vmImage: $(vmImageName)
        environment: "aks-pipeline-demo.default"
        strategy:
          runOnce:
            deploy:
              steps:
                - task: DownloadPipelineArtifact@2
                  displayName: "Download manifests"
                  inputs:
                    artifactName: "manifests"
                    downloadPath: "$(System.ArtifactsDirectory)/manifests"

                - task: KubernetesManifest@1
                  displayName: "Create imagePullSecret"
                  inputs:
                    action: "createSecret"
                    connectionType: "kubernetesServiceConnection"
                    kubernetesServiceConnection: "aks-service-connection"
                    secretType: "dockerRegistry"
                    secretName: "$(imagePullSecret)"
                    dockerRegistryEndpoint: "$(dockerRegistryServiceConnection)"

                - task: KubernetesManifest@1
                  displayName: "Deploy to AKS"
                  inputs:
                    action: "deploy"
                    connectionType: "kubernetesServiceConnection"
                    kubernetesServiceConnection: "aks-service-connection"
                    manifests: |
                      $(System.ArtifactsDirectory)/manifests/deployment.yml
                      $(System.ArtifactsDirectory)/manifests/service.yml
                    containers: "$(containerRegistry)/$(imageRepository):$(tag)"
                    imagePullSecrets: "$(imagePullSecret)"

Your pipeline is fully authored and your service connections are wired up. In the next step, you will push this code to trigger the pipeline and watch it deploy your app live.

Run the Pipeline and Verify

Your infrastructure is live, your app is containerized, and your pipeline YAML is ready. The only thing left is to connect all the pieces by pushing your code and watching the automation take over.

In this step, you will trigger the full CI/CD loop. A single git push will kick off the pipeline, build your Docker image, push it to ACR, and deploy it to your AKS cluster.

In this step, get ready to:
  • Push all project files and trigger the pipeline.
  • Verify your app is running on AKS with a public IP.
  • Make a code change and watch the pipeline automatically redeploy.
Push code and trigger the pipeline

Your Azure DevOps pipeline is configured to trigger on any push to the main branch. Once you commit and push all your files, the pipeline starts automatically.

  • Stage all project files for commit by running this command:
git add .
  • Commit all files with a descriptive message by running:
git commit -m "Add Terraform, app, manifests, and pipeline"
  • Push the commit to the remote main branch by running:
git push origin main
  • Open your Azure DevOps project in your browser.
  • Click Pipelines in the left sidebar.
  • Click New pipeline.
  • Select Azure Repos Git as the code source.
  • Select your repository.
  • Azure DevOps will detect the existing azure-pipelines.yml file. Select Existing Azure Pipelines YAML file and choose /azure-pipelines.yml from the branch.
  • Click Run to start the pipeline.

What happens when the pipeline runs?

The pipeline runs through two stages sequentially. The Build stage uses the Docker@2 task to build your image from the Dockerfile and push it to your ACR. It also publishes the manifests folder as a pipeline artifact.

The Deploy stage downloads those manifests, creates an imagePullSecret so AKS can authenticate with ACR, then applies your Kubernetes Deployment and Service with the newly built image tag injected.

  • Watch the pipeline progress by clicking into the run. You should see both the Build and Push and Deploy to AKS stages complete with green checkmarks.

First run environment approval

On the first run, the Deploy stage may pause and ask you to approve access to the aks-pipeline-demo.default environment. Click Permit to allow the pipeline to deploy. This only happens once.

Pipeline failing at the Build stage?

Check that your acr-service-connection service connection name in Azure DevOps matches exactly what is in your azure-pipelines.yml under dockerRegistryServiceConnection.

Also confirm the Dockerfile is in the repository root, not inside a subfolder.

help me debug my Azure DevOps pipeline Build stage failure

Verify the deployment

Once both pipeline stages show green, your app is live on AKS. You can confirm this using kubectl to inspect the cluster state.

  • Check that your pods are running by executing:
kubectl get pods
  • You should see 2 pods with a status of Running. The names will start with aks-pipeline-demo- followed by a random suffix.
  • Check the Service and get the external IP by running:
kubectl get svc
  • Look for the aks-pipeline-demo service with type LoadBalancer. The EXTERNAL-IP column shows the public IP address.

External IP still pending?

If the EXTERNAL-IP shows <pending>, wait 1-2 minutes and run kubectl get svc again. Azure needs time to provision the public IP for the LoadBalancer.

  • Copy the external IP address and open it in your browser.
  • You should see a JSON response containing a message, version, and timestamp field.

Pods not running or stuck in ImagePullBackOff?

This means AKS cannot pull the image from ACR. Verify the AcrPull role assignment was created by running kubectl describe pod <pod-name> to check the error details.

Confirm your aks-service-connection in Azure DevOps points to the correct cluster and namespace (default).

help me debug ImagePullBackOff on my AKS pods

Make a change and watch the pipeline

The real power of CI/CD is the feedback loop. A code change, a push, and your updated app is live within minutes. Let's prove it works end-to-end.

  • In server.js, find this line:
message: "Hello from AKS!",
  • Replace it with a new message so you can tell the redeployment worked:
message: "Hello from AKS - updated via CI/CD!",
  • Save server.js.
  • Commit and push the change by running these commands:
git add server.js
git commit -m "Update response message"
git push origin main
  • Switch back to Azure DevOps Pipelines in your browser.
  • You should see a new pipeline run triggered automatically by your push.
  • Wait for both stages to complete with green checkmarks.
  • Refresh your browser tab with the external IP. You should now see the updated message in the JSON response.

Why does the pipeline know to use the new code?

Each pipeline run gets a unique $(Build.BuildId) value, which becomes the Docker image tag. The Deploy stage injects this new tag into the Kubernetes manifest, replacing the IMAGE_PLACEHOLDER with the freshly built image reference.

Kubernetes sees a new image tag on the Deployment, rolls out new pods with the updated code, and terminates the old ones. This is a zero-downtime rolling update.

Still seeing the old message?

Make sure the second pipeline run completed successfully. Check the Deploy stage logs for any errors.

Try a hard refresh in your browser (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows) to bypass the browser cache.

help me figure out why my AKS app still shows the old response after a pipeline redeploy

Secret mission

Add Remote State and a Validate Stage

Your pipeline works, but the Terraform state is sitting on your local machine. If your laptop dies, you lose track of everything Terraform manages. Production teams store state remotely and validate infrastructure before every deployment. Can you add an Azure Storage backend for Terraform state and a pipeline Validate stage that runs terraform plan before any code ships?

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. The AKS cluster node costs approximately $0.04/hour while running, so resources left active will accumulate charges.

Cost warning

Your AKS cluster runs a Standard_B2s node that costs real money while active. If you are done with this project, delete your resources to avoid unexpected charges on your Azure subscription.

Resources you used:

  • Azure Resource Group rg-aks-pipeline-demo (AKS cluster, Azure Container Registry, role assignment).
  • Terraform State Resource Group rg-terraform-state (storage account stterraformstatedemo with state file). Created if you completed the Secret Mission.
  • Azure DevOps project with pipeline, service connections, and environment.
  • Local project files on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the pipeline.

  • Your AKS cluster node will continue to incur charges (approximately $0.04/hour).
  • Your Azure Container Registry (Basic SKU) will continue to incur its monthly charge.
  • Your Azure DevOps project and pipeline remain available at no additional cost.

Pause - I'll come back to this later

Stop the AKS cluster to halt compute charges while preserving your cluster configuration and Kubernetes objects.

  • Stop the AKS cluster by running:
az aks stop --name aks-pipeline-demo --resource-group rg-aks-pipeline-demo
  • When you are ready to resume, start the cluster by running:
az aks start --name aks-pipeline-demo --resource-group rg-aks-pipeline-demo

What stays running while paused?

Your Azure Container Registry remains active (low monthly cost). Your Azure DevOps project and pipeline remain intact and ready to trigger when you start the cluster back up.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is the recommended approach when you are done with the project.

  • Destroy all Terraform-managed infrastructure (AKS cluster, ACR, resource group) by running:
terraform destroy

What does this remove?

This removes the AKS cluster, Azure Container Registry, role assignment, and the rg-aks-pipeline-demo resource group. Terraform prompts you to confirm before proceeding.

  • If you completed the Secret Mission, delete the Terraform state resource group by running:
az group delete --name rg-terraform-state --yes --no-wait
  • If Terraform state is lost and terraform destroy fails, delete the main resource group manually by running:
az group delete --name rg-aks-pipeline-demo --yes --no-wait
  • Delete your Azure DevOps project by signing in to your organization at https://dev.azure.com/{yourorganization}.
  • Select Organization settings.
  • Select Projects, check your project, and click Delete.
  • Confirm deletion by entering the project name and clicking Delete in the popup.
  • Remove your local project files by running:
cd ~/Desktop
rm -rf aks-pipeline-demo

That's a Wrap!

That's a Wrap!

Nice work! You just built a complete infrastructure-to-application pipeline that provisions cloud resources with Terraform, containerizes an app, and deploys it automatically to Azure Kubernetes Service through Azure DevOps.

You've learned how to:

  • Provisioned AKS and Azure Container Registry with Terraform, including resource dependencies, role assignments, and provider configuration.
  • Wrote a multi-stage YAML pipeline from scratch using Docker@2 and KubernetesManifest@1 tasks to build, push, and deploy on every commit to main.
  • Deployed a containerized application to Kubernetes with a Deployment, LoadBalancer Service, and imagePullSecret, experiencing the full CI/CD loop where a code change triggers automatic build and deployment.
  • Secret Mission: Added remote Terraform state with an Azure Storage Account backend and a Validate stage to the pipeline that runs terraform plan before deployment.

Ready to quiz yourself?