Kubernetes conducts the show.
Two tools, two jobs, one idea underneath both. This is the story of how a .NET monolith becomes a microservice ecosystem on Azure — told in five acts, with diagrams you can touch.
A single monitor glows. On it, a graph climbing like a heart rate that will not come back down. Contoso's online store — one big Contoso.Web.dll, one big IIS server — is drowning in traffic that only wants one thing: the shoe on sale.
The catalog page is what's melting. But checkout, cart, invoicing, the emails — they all live inside that same binary, on that same box. To give the catalog more power, you have to give everything more power. There is no volume knob for one instrument. There is only the whole orchestra, louder.
Someone reaches for the deploy script. It is 400 lines of PowerShell. It has a comment at the top that says "do not run twice."
There are two separate failures in that room, and it's worth being precise about which is which — because they have two different heroes, and most articles only introduce one.
The first failure is the venue. One server, built by hand, that nobody can rebuild. It was assembled over eighteen months of portal clicks by four people, two of whom have left. That machine is not infrastructure — it's an artifact. Nobody dares touch it and nobody can reproduce it.
The second failure is the show. Even with a perfect venue, someone still has to decide what runs where, notice when a process dies, add capacity when the queue grows, and replace instruments mid-performance without the audience hearing a gap. At 2 AM, that someone is a human holding a laptop.
Terraform solves the first one. It builds the venue from a written plan, identically, every time, and it will build you a second one on Tuesday if you ask.
Kubernetes solves the second. It reads the score, watches every section, and keeps the performance going whether or not anybody is awake.
Neither is enough alone. A perfect cluster full of hand-deployed chaos is still chaos. Beautiful Terraform pointing at nothing is a very well-documented empty room.
We'll follow one company, Contoso Commerce, as it breaks its monolith into four .NET microservices and gets them running properly. Four services — enough to be real, small enough to hold in your head:
| Service | Job | Why it's separate |
|---|---|---|
| catalog-api | Products, search, images | Read-heavy. Traffic spikes 40× on sale days. Needs to scale alone. |
| cart-api | Basket state per shopper | Chatty, backed by Redis. Different failure profile entirely. |
| orders-api | Checkout, payment, fulfilment | Must never lose a write. Deploys carefully, rarely. |
| identity-api | Sign-in, tokens | Security boundary. Own team, own release cadence. |
The main text is the film — plain English, no prerequisites. Anywhere you see an Under the hood panel, click it for the real Terraform, YAML and pipeline code. Skip every one of them and the story still works.
Before the acts begin, here's the thing that makes the rest of this article easy. Terraform and Kubernetes look like completely different tools. They are the same tool, running at two different speeds, on two different worlds.
Both of them refuse to take instructions. You cannot tell either one to "create a VM" or "start a container" — not really. You can only tell them what should be true. Then they go and make it true, and — this is the part that matters — they keep making it true afterwards.
That behaviour has a name: a reconciliation loop. Four steps, forever. Toggle between the two heroes below and watch the same loop wear two different costumes.
…
Same four boxes. What changes is the clock and the world. Terraform's loop runs when you run it, takes minutes, and its world is your Azure subscription. Kubernetes' loop runs a few times a second, forever, and its world is the inside of the cluster.
| Terraform | Kubernetes | |
|---|---|---|
| Builds | The theatre — clusters, networks, registries, identities | Nothing. It conducts what's already there. |
| Its world | Azure Resource Manager | The cluster's own state store (etcd) |
| Memory | A state file, in blob storage | etcd, managed for you |
| Loop speed | On demand — minutes | Continuous — seconds |
| Changes are | Reviewed by a human first | Applied instantly, then watched |
| Failure looks like | A plan that won't apply | A pod stuck in Pending |
Learn the loop once and both heroes stop being a pile of commands to memorise. Every
confusing thing either tool does — drift, Pending pods, "no changes",
surprise recreations — is the loop doing exactly what it promised with information you
didn't know it had.
Our first hero enters with an unglamorous but radical proposal: the environment is a text file in your repository. If it isn't in the file, it doesn't exist. If someone clicks something in the portal, the next Terraform run will notice and offer to undo it.
The Azure Portal is a conversation. Terraform is a contract. Conversations are forgotten. Contracts get reviewed, versioned, signed — and can be executed again next year by someone who wasn't in the room.
Remember the loop. Terraform's version of "observe" is unusually careful, because it's comparing three things, not two — and understanding which three explains almost every surprising plan you'll ever read.
| Source | What it is | Where it lives |
|---|---|---|
| Your .tf files | What you want | Git — reviewed in pull requests |
| The state file | What Terraform believes it built | An Azure Storage blob, locked while running |
| Azure itself | What actually exists right now | Read live via the ARM API on every plan |
When those three agree, the plan is empty and Terraform says the four most reassuring words in infrastructure: No changes. Your infrastructure matches the configuration. When they disagree, the difference is the plan.
Never keep the state file on a laptop. Put it in an Azure Storage account with blob versioning on, and let Terraform take the native blob lease as its lock. That lock is the only thing standing between you and two pipelines applying to the same cluster at the same moment — which corrupts state in ways that take a very bad afternoon to unpick.
Below is what a real terraform apply feels like from the outside. Press play.
Watch the dependency graph resolve itself — you never told Terraform that the subnet must exist
before the cluster. It worked that out from the fact that one references the other.
Ten resources, one command, in the right order, with the independent ones running in parallel. You described relationships; Terraform derived the steps. That inversion is the entire value proposition, and it's why the same file works on an empty subscription and on one where nine of the ten already exist.
The theatre now stands: a virtual network, a container registry, a Log Analytics workspace, an AKS cluster with two node pools, and the identities that let them talk to each other without a single password. What any of that means is Act II's job — this act only cares that it was built from a file you can read, review and run again.
Pin your provider versions. An unpinned provider is a time bomb that goes off on a Friday.
The azurerm provider is on the 4.x line — note that several argument names
changed from 3.x, which is the single most common reason a copied snippet won't apply.
terraform {
required_version = "~> 1.9"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.30" }
azuread = { source = "hashicorp/azuread", version = "~> 3.0" }
}
# remote state: shared, locked, versioned
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "stcontosotfstate"
container_name = "tfstate"
key = "prod/aks.tfstate"
use_azuread_auth = true # no storage keys anywhere
}
}
provider "azurerm" {
features {}
use_oidc = true # federated auth from CI — no client secret
}
resource "azurerm_virtual_network" "main" {
name = "vnet-contoso-prod"
address_space = ["10.40.0.0/16"]
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
}
resource "azurerm_subnet" "aks" {
name = "snet-aks-nodes"
resource_group_name = azurerm_resource_group.main.name
# ↓ THIS reference is what creates the dependency edge.
# No depends_on needed — Terraform reads the graph from your code.
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.40.0.0/20"]
}
resource "azurerm_kubernetes_cluster" "main" {
name = "aks-contoso-prod"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
dns_prefix = "contoso-prod"
# Standard tier buys a financially-backed control-plane SLA.
# Free tier gives you an SLO and no money back. Use Standard in prod.
sku_tier = "Standard"
kubernetes_version = "1.35" # AKS keeps 3 GA minors in support
automatic_upgrade_channel = "patch" # CVEs patch themselves
node_os_upgrade_channel = "NodeImage"
# passwordless everything
oidc_issuer_enabled = true
workload_identity_enabled = true
local_account_disabled = true # no static kubeconfig admin creds
identity { type = "SystemAssigned" }
azure_active_directory_role_based_access_control {
azure_rbac_enabled = true
admin_group_object_ids = [var.platform_admins_group_id]
}
default_node_pool {
name = "system"
vm_size = "Standard_D4as_v5"
vnet_subnet_id = azurerm_subnet.aks.id
auto_scaling_enabled = true # was enable_auto_scaling in azurerm 3.x
min_count = 2
max_count = 4
only_critical_addons_enabled = true # keep apps off the system pool
zones = ["1", "2", "3"]
upgrade_settings { max_surge = "33%" }
}
network_profile {
# Overlay: pods get IPs from a private space, not your VNet.
# You stop running out of subnet at 400 pods. Do this.
network_plugin = "azure"
network_plugin_mode = "overlay"
network_policy = "cilium"
network_data_plane = "cilium"
pod_cidr = "192.168.0.0/16"
load_balancer_sku = "standard"
}
web_app_routing { # managed NGINX ingress
dns_zone_ids = [azurerm_dns_zone.shop.id]
}
oms_agent {
log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id
msi_auth_for_monitoring_enabled = true
}
lifecycle {
# the cluster autoscaler owns node_count at runtime — don't fight it
ignore_changes = [default_node_pool[0].node_count]
}
}
# app workloads live in their own pool, scaled independently
resource "azurerm_kubernetes_cluster_node_pool" "apps" {
name = "apps"
kubernetes_cluster_id = azurerm_kubernetes_cluster.main.id
vm_size = "Standard_D4as_v5"
vnet_subnet_id = azurerm_subnet.aks.id
auto_scaling_enabled = true
min_count = 3
max_count = 30
zones = ["1", "2", "3"]
}
# let the cluster pull images without a single password
resource "azurerm_role_assignment" "acr_pull" {
scope = azurerm_container_registry.main.id
role_definition_name = "AcrPull"
principal_id = azurerm_kubernetes_cluster.main.kubelet_identity[0].object_id
}
Three lines worth arguing about in code review:
network_plugin_mode = "overlay" (stops you exhausting VNet IPs),
local_account_disabled = true (kills the shared admin kubeconfig that gets
pasted into Teams), and ignore_changes on node_count — without it, Terraform
and the cluster autoscaler will quietly wrestle each other forever, each one "fixing"
the other's work.
The old way: a connection string in a Kubernetes Secret, base64-encoded — which is encoding, not encryption — and which someone will eventually commit. The new way: the pod proves who it is with a short-lived token, and Azure hands it a credential. Nothing to leak, nothing to rotate, nothing to find in a git history two years from now.
# 1. the pod's Azure identity
resource "azurerm_user_assigned_identity" "catalog" {
name = "id-catalog-api"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
}
# 2. the trust: "the catalog-sa account in namespace 'shop' IS this identity"
resource "azurerm_federated_identity_credential" "catalog" {
name = "fic-catalog-api"
resource_group_name = azurerm_resource_group.main.name
parent_id = azurerm_user_assigned_identity.catalog.id
audience = ["api://AzureADTokenExchange"]
issuer = azurerm_kubernetes_cluster.main.oidc_issuer_url
subject = "system:serviceaccount:shop:catalog-sa"
}
# 3. and what it's allowed to touch — nothing more
resource "azurerm_role_assignment" "catalog_kv" {
scope = azurerm_key_vault.main.id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.catalog.principal_id
}
On the .NET side that's the whole change — one line, no secret:
builder.Configuration.AddAzureKeyVault(
new Uri("https://kv-contoso.vault.azure.net/"),
new DefaultAzureCredential()); // picks up workload identity automatically
One folder per environment, one shared module. Dev and prod differ by a variables file, never by a forked copy of the code — the moment they diverge, "it works in dev" stops meaning anything.
infra/
├── modules/
│ └── aks-platform/ # cluster + network + acr + identities
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── envs/
├── dev/
│ ├── main.tf # module "platform" { source = "../../modules/aks-platform" }
│ └── dev.tfvars # node_min=1 node_max=3 sku_tier="Free"
└── prod/
├── main.tf
└── prod.tfvars # node_min=3 node_max=30 sku_tier="Standard"
# envs/prod/main.tf
module "platform" {
source = "../../modules/aks-platform"
environment = "prod"
location = "westeurope"
node_min = 3
node_max = 30
sku_tier = "Standard"
}
The theatre stands. Our second hero walks on — and here is the single most useful thing to understand about AKS, which almost nobody says plainly: a cluster is two halves, and Microsoft only runs one of them.
The half Microsoft runs is the control plane — the brain. It holds the desired state of your world ("there should be six catalog pods, always"), decides which machine each container lands on, and never stops comparing what is against what should be. You don't patch it, don't SSH into it, and on the Free tier don't pay for it. Terraform created it in about three minutes and then had nothing more to do with it.
The half you own is the node pools — ordinary Azure virtual machines that do the actual work. Your containers run here. You pay for these by the second, exactly like any other VM.
The control plane is the conductor: it reads the score, watches every section, and points. It plays no notes. The nodes are the musicians: they make all the sound and none of the decisions. The moment a violinist walks off stage, the conductor notices — and a replacement is already sitting down.
Click through the diagram. Every box was created by the Terraform you just watched run, and every one is something you'll either configure in HCL or shout about during an incident.
You met this in the setup. Here it is doing the job that makes Kubernetes feel alive:
You: "There should be six catalog pods."
Kubernetes: "There are six. Fine."
A node catches fire. Two pods die.
Kubernetes: "There are four. That is not six."
Kubernetes: schedules two more, somewhere healthy, in about twenty seconds.
You: still asleep.
Nobody wrote "if a pod dies, start another one." There is no if. There is only the gap between declared and actual, being closed, constantly. Terraform does this on demand, in minutes, against Azure. Kubernetes does it continuously, in seconds, against the cluster. Same hero, different shift.
This is the declaration. replicas: 6 is the "what you want." Everything else
tells the scheduler how to keep that promise safely.
apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-api
spec:
replicas: 6
selector:
matchLabels: { app: catalog-api }
template:
metadata:
labels:
app: catalog-api
azure.workload.identity/use: "true"
spec:
serviceAccountName: catalog-sa # ← matches the federated credential
containers:
- name: api
image: contosoacr.azurecr.io/catalog-api:1.14.0
ports: [{ containerPort: 8080 }]
# requests = what the scheduler reserves. limits = the ceiling.
resources:
requests: { cpu: "150m", memory: "192Mi" }
limits: { cpu: "800m", memory: "512Mi" }
# "am I alive?" — fail and I get restarted
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
initialDelaySeconds: 10
# "am I ready for traffic?" — fail and I'm pulled from the load balancer
readinessProbe:
httpGet: { path: /readyz, port: 8080 }
periodSeconds: 5
The two probes are the most under-used feature in Kubernetes. Liveness answers "should I be killed?" Readiness answers "should I get traffic?" Get readiness wrong and every deploy drops requests, because traffic arrives before your .NET app has finished warming its DI container and building the EF Core model.
In ASP.NET Core the endpoints are three lines:
// Program.cs
builder.Services.AddHealthChecks()
.AddDbContextCheck<CatalogContext>("db", tags: ["ready"]);
app.MapHealthChecks("/healthz", new() { Predicate = _ => false }); // process is up
app.MapHealthChecks("/readyz", new() { Predicate = r => r.Tags.Contains("ready") });
Terraform owns the cluster. Kubernetes owns what runs inside it. The boundary is sharp and worth defending: don't let Terraform apply your application manifests (it will fight the controllers and its state file will drift every time a pod moves), and don't let Kubernetes create Azure infrastructure it can't reason about. Each hero, its own world.
Terraform built the theatre. Kubernetes is ready to conduct. But something has to carry the music from a developer's laptop to the stage — reliably, in minutes, many times a day, with the right people looking at the right moments. That's the pipeline.
Start from the idea rather than the tooling. Both our heroes only accept one kind of input: a written declaration of what should be true. So the question "how do we deploy?" becomes a much better question: how does a change to that declaration get proposed, reviewed, and adopted?
Git already answers that. A branch is a proposal. A pull request is a review. A merge is adoption. CI/CD is simply the machinery that makes a merge mean something in the real world.
You are not "pushing code to servers." You are proposing an amendment to the description of your production system, having it reviewed, and — once adopted — letting two reconciliation loops make the world match the new description.
Here's the distinction that unlocks everything, and confusing it is the most common mistake teams make. One hero's declaration changes weekly. The other's changes hourly. They should not share a pipeline, a repository, or a set of permissions.
| Infrastructure pipeline | Application pipeline | |
|---|---|---|
| Changes | Cluster, network, ACR, identities | The four .NET services |
| Tool | Terraform | Docker + Helm/kubectl |
| Cadence | Weekly, maybe | Several times a day |
| Approval | A human reads the plan diff, always | Layered gates — mostly automatic, human where judgement is needed |
| Blast radius | Everything | One service |
| Repo | contoso-platform | contoso-catalog, -cart, … |
If deploying a one-line copy fix to the catalog page requires running Terraform, then every deploy carries the risk of accidentally recreating a node pool. Keep the thing that changes hourly far away from the thing that can delete a database.
There's a myth in this space worth killing: that continuous deployment means removing human approval. It doesn't, and teams that chase that as the goal end up either reckless or, more often, quietly reintroducing a Thursday-afternoon change board and feeling bad about it.
Mature pipelines have more gates than manual processes did, not fewer. What changes is who staffs them and when they fire. The old model had one gate, at the end, staffed by a human reading a change ticket about software they'd never seen. The new model has six or seven gates, most of them automatic, and the human ones sit precisely where a human adds judgement that a machine cannot.
| Gate | The question it asks | Who answers | What it blocks |
|---|---|---|---|
| Pull request review | Is this change correct and well-designed? | A human teammate | The merge |
| Tests & coverage | Did we break anything we knew about? | The pipeline | The build |
| Image scan & signing | Are we shipping a known CVE? | The pipeline | The push to ACR |
| Staging smoke tests | Does it actually run when assembled? | The pipeline | Promotion |
| Canary analysis | Does real traffic behave? Latency, 5xx, saturation | Automated metric analysis | The full rollout |
| Environment approval | Should this ship now, given what else is happening? | Named humans — on high-risk services | Production deploy |
| Change freeze | Are we inside a protected window? | Policy | Everything |
Look at what the humans are being asked. Not "did 214 tests pass" — a human is strictly worse at that than the machine, and asking them makes the approval a rubber stamp. They're asked the one thing the pipeline genuinely cannot know: is now a good time, given everything happening outside this repository? A sale starting in an hour. A payments provider already having a bad morning. A regulator's audit window. That's judgement, and it belongs to a person.
Risk-tier your services and let the pipeline behave differently for each. Applying orders-api's ceremony to catalog-api slows everyone down for nothing; applying catalog-api's speed to orders-api is how you refund people by accident.
Every human gate is a queue. It adds hours of latency, it batches unrelated changes together (making the eventual failure harder to diagnose), and if it fires on every deploy it decays into a reflex click within about three weeks. So gate deliberately: a gate that is always approved is not a control, it's a delay with paperwork. If your approver has never once said no, the gate is telling you something.
Run the pipeline below. The first button deploys catalog-api, which promotes itself on canary metrics. The second deploys orders-api, which stops and waits for you. The third pushes a commit with a vulnerable dependency, so you can watch an automatic gate do its job.
There's a handoff worth naming, and it's where our two heroes trade places one last time. The pipeline's job ends at helm upgrade — it changes the desired state and walks away. It does not "deploy" in the old sense of copying files onto servers. It files an amendment. Kubernetes then does the actual work: pulling images, starting pods, waiting for readiness, shifting traffic, killing the old ones.
Which means the pipeline's last real task is waiting and judging:
kubectl rollout status watches the rollout and fails the build if the new pods
never go ready. Without that line, a broken image gets a green checkmark and you find out
from a customer.
Everything above is push: the pipeline holds credentials and reaches into the cluster. The alternative is pull — an agent inside the cluster (Argo CD, or the AKS GitOps add-on based on Flux) watches a Git repo and applies changes itself. CI then never touches the cluster at all; it just writes a new image tag into a manifests repo, and approving a deploy becomes approving a pull request. Push is simpler to start. Pull scales better past a handful of clusters, because the cluster's true state is always a commit you can diff — which is the reconciliation loop applied to your delivery process itself.
Note there is no password anywhere. OIDC federation lets the workflow prove it's this repo, this branch and receive a short-lived Azure token.
name: catalog-api
on:
push:
branches: [main]
paths: ['src/Catalog/**'] # only build what changed
permissions:
id-token: write # ← required for OIDC login
contents: read
env:
ACR: contosoacr.azurecr.io
IMAGE: catalog-api
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Test
run: dotnet test ./src/Catalog --logger trx
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Build & push image
run: |
az acr login --name contosoacr
# tag with the commit SHA — never 'latest' in production
TAG=${GITHUB_SHA::7}
docker build -f src/Catalog/Dockerfile \
-t $ACR/$IMAGE:$TAG .
docker push $ACR/$IMAGE:$TAG
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.ACR }}/${{ env.IMAGE }}:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: '1' # fail the build, don't just warn
And the Dockerfile — multi-stage, non-root, chiselled runtime. Chiselled images have no shell and no package manager, which removes most of what a scanner would complain about:
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY *.sln .
COPY src/Catalog/*.csproj src/Catalog/
RUN dotnet restore src/Catalog # cached layer: restore before copying code
COPY . .
RUN dotnet publish src/Catalog -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app .
USER $APP_UID # non-root by default
ENTRYPOINT ["dotnet", "Contoso.Catalog.dll"]
In GitHub Actions the gate is an environment with protection rules. The job simply names the environment; GitHub does the pausing, the notifying, the audit trail, and the "you can't approve your own deploy" rule. The same idea exists as Approvals and Checks on Azure DevOps environments.
deploy-canary:
needs: build
runs-on: ubuntu-latest
environment: production-canary # no reviewers — automatic
steps:
- run: az aks get-credentials -g rg-contoso-prod -n aks-contoso-prod
- run: |
helm upgrade --install orders-canary ./charts/orders \
--namespace shop --set image.tag=${GITHUB_SHA::7} \
--set replicaCount=1 --set canary=true --atomic
- name: Analyse canary for 10 minutes
run: ./scripts/canary-analysis.sh --window 10m \
--max-error-rate 0.5 --max-p95-regression 20
deploy-prod:
needs: deploy-canary
runs-on: ubuntu-latest
# ↓ THIS is the human gate. Configured in repo settings:
# Required reviewers: @contoso/payments-oncall (2)
# Wait timer: 0 · Prevent self-review: on
# Deployment branches: main only
environment:
name: production
url: https://shop.contoso.com
steps:
- run: |
helm upgrade --install orders ./charts/orders \
--namespace shop --set image.tag=${GITHUB_SHA::7} \
--atomic --timeout 5m
- run: kubectl rollout status deploy/orders-api -n shop --timeout=180s
Two details that make the gate real rather than decorative.
--atomic means a rollout that doesn't go healthy inside the timeout is rolled
back automatically — so the approver is authorising an attempt, not a one-way door. And
prevent self-review means the person who wrote the change isn't the person who
approves it, which is the entire point of having one.
Plan on the pull request so reviewers see the diff. Apply only after a human clicks.
jobs:
plan:
runs-on: ubuntu-latest
steps:
- run: terraform init
- run: terraform plan -out=tfplan -lock-timeout=5m
- run: terraform show -no-color tfplan > plan.txt
- uses: actions/github-script@v7 # post the plan as a PR comment
with: { script: '...comment plan.txt on the PR...' }
- uses: actions/upload-artifact@v4
with: { name: tfplan, path: tfplan }
apply:
needs: plan
if: github.ref == 'refs/heads/main'
environment: prod-infra # ← required reviewers live here too
steps:
- uses: actions/download-artifact@v4
with: { name: tfplan }
# apply the SAVED plan, not a fresh one — what was reviewed is what runs
- run: terraform apply -auto-approve tfplan
Applying the saved plan file matters more than it looks. If you re-plan at apply time, the thing that executes is not the thing anyone reviewed. Drift, a teammate's portal click, or a new provider version can all change it in between — and then your approval was for a document that no longer exists.
The pipeline has walked away. Kubernetes is alone on stage now, and this is where our second hero earns top billing. Orchestration is a word that usually means "some Kubernetes stuff happens." It's more specific than that: four jobs, running continuously, that nobody has to ask for.
You said catalog-api needs 150 millicores and 192MB. The scheduler finds a node with room, honours your rules ("spread these across three availability zones", "keep batch work off this pool"), and puts the pod there. If no node has room, the cluster autoscaler asks Azure for another VM — and about three minutes later there is one. Note that Terraform declared the range (3 to 30 nodes); Kubernetes picks the number, minute by minute.
orders-api needs to call catalog-api. It does not know an IP address. It calls
http://catalog-api.shop.svc.cluster.local — a name that resolves, inside the
cluster, to whichever pods are healthy right now. Pods die and are replaced with new
IPs every day. The name never changes.
Before: a config file with 10.2.14.7 in it, and a
ticket to update it when the server is rebuilt.
After: a name. The infrastructure keeps the promise behind it.
The Horizontal Pod Autoscaler watches CPU, memory, or a custom signal like "messages waiting in the Service Bus queue," and adds pods. If the pods won't fit, the cluster autoscaler adds nodes. Two loops, nested, both closing gaps.
This is the one you'll care about most, because it runs during every single deploy. Kubernetes replaces old pods with new ones gradually, and refuses to send traffic to a new pod until that pod says it's ready. Two numbers control the whole dance — play with them and watch what happens to your capacity mid-deploy.
Set maxUnavailable to 0 and the rollout never dips below full capacity — it costs
you a few extra pods' worth of compute for a couple of minutes. Set it to 4 and the deploy is
faster but you're running at half capacity in the middle of it, which is fine at 3am and a
disaster during a sale. This is the trade, and it's yours to make per service —
the same risk-tiering that decided who needs a human approval decides these two numbers too.
spec:
replicas: 8
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 2 # may temporarily run 10
maxUnavailable: 0 # never dip below 8 ready
minReadySeconds: 10 # "ready" must hold for 10s before we trust it
template:
spec:
terminationGracePeriodSeconds: 45 # let in-flight requests finish
topologySpreadConstraints: # don't put all 8 in one zone
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app: catalog-api }
CPU is a lagging indicator. For orders-api, queue depth is a leading one — KEDA scales on it directly, and can scale to zero when the queue is empty:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: orders-worker }
spec:
scaleTargetRef: { name: orders-worker }
minReplicaCount: 1
maxReplicaCount: 40
triggers:
- type: azure-servicebus
metadata:
queueName: orders-to-fulfil
messageCount: "20" # one pod per 20 queued messages
authenticationRef: { name: azure-workload-identity }
A PodDisruptionBudget stops voluntary disruption — node upgrades, autoscaler consolidation — from taking down more than you can afford. Without it, an AKS node-image upgrade can drain three nodes and briefly leave you with one pod:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: catalog-pdb }
spec:
minAvailable: "70%"
selector:
matchLabels: { app: catalog-api }
Kubernetes sends SIGTERM and waits. If your app exits instantly, you drop whatever was in flight. Generic Host handles this correctly if you let it:
builder.Services.Configure<HostOptions>(o =>
o.ShutdownTimeout = TimeSpan.FromSeconds(30)); // < terminationGracePeriod
Every architecture is a theory until something goes wrong. Here's the part that justifies all the ceremony: on the night an Azure host fails and takes a node with it, the difference between the old world and this one is whether your phone rings.
A node dies. Not gracefully — it simply stops answering.
Ten seconds pass. The control plane notices the missing heartbeat and marks the node NotReady. The endpoints controller pulls those pods out of every service's routing table, so traffic stops going to ghosts. The deployment controller does its arithmetic: seven pods, should be eight. The scheduler finds room on the survivors. New pods start. Readiness probes pass. Traffic returns.
The autoscaler, meanwhile, has already asked Azure for a replacement VM — using the node pool definition Terraform wrote months ago and hasn't thought about since.
Total elapsed time: under a minute. Total humans involved: zero. The monitor still glows, but nobody is watching it, because there is nothing to watch.
Try it yourself. Kill a node, or turn the traffic up until the cluster has to grow.
Self-healing has a hard boundary, and it's honest to name it. Kubernetes will restart a crashed pod forever. It cannot tell you the pod crashes because of a null reference on line 84. It will happily run eight replicas of a broken build. It will not notice that your latency doubled — only that the process is alive. And Terraform will faithfully rebuild a badly-designed network exactly as badly, every time, on demand.
Which is why the last piece of the picture is seeing. Container Insights and Managed Prometheus feed metrics; Managed Grafana draws them; Application Insights follows a single order across all four services and shows you which hop got slow. Without that, you have an orchestra playing perfectly in a room with no windows.
Alert on symptoms your customers feel, not on causes. High CPU is not an incident. A 12% error rate is.
| Signal | Threshold | Why |
|---|---|---|
| CrashLoopBackOff | any pod, 5 min | A restart loop is a deploy that failed silently. |
| HTTP 5xx rate | > 2% over 5 min | The only number a customer actually experiences. |
| p95 latency | > 2× the 7-day baseline | Catches slow before it becomes down. |
| Node pool at max | at max_count > 10 min | You've hit your ceiling; the autoscaler is out of moves. |
That last one is where the heroes talk to each other: the alert fires because Kubernetes
has run out of the room Terraform gave it. The fix is a pull request against
max_count, reviewed, planned and applied — not a portal click.
Terraform builds the theatre: it writes down what the world should look like and makes Azure match, deliberately, with a human reading the diff. Kubernetes conducts the show: it reads what should be running and spends the rest of its life closing the gap — placing pods, resolving names, adding capacity, replacing the dead. Git is the score both of them read from, and the pipeline is how a new page gets in.
That's it. Everything else is detail, and details are learnable. The idea is not.
Both heroes have the same superpower and neither can do the other's job. Terraform: declared vs. Azure, minutes, on demand. Kubernetes: declared vs. running, seconds, forever. GitOps: declared vs. cluster, applied to your delivery process itself. Learn the loop once and the whole ecosystem stops being a pile of tools and becomes one idea, repeated at three speeds.
| # | Do this | Not this |
|---|---|---|
| 1 | Terraform first — one cluster, one node pool, from a file, from day zero | Clicking a cluster together "to try it" and never rebuilding it |
| 2 | Remote state in Azure Storage from the very first init | Local state "just for now" |
| 3 | Workload identity and OIDC federation from day one | Retrofitting security after the secrets are everywhere |
| 4 | One service, deployed end to end by a pipeline, before adding a second | Designing a multi-region mesh before anything runs |
| 5 | Readiness probes and resource requests on every service | Defaults, then wondering why deploys drop traffic |
| 6 | Risk-tier your services and gate them differently | One approval policy for everything — or none at all |
| 7 | Observability wired up before your first real incident | Adding dashboards while production burns |
Do you need any of this? If you run three services and deploy weekly, Azure Container Apps or App Service will serve you better and cost far less in human attention — and Terraform is still worth every minute regardless, because the first hero's argument has nothing to do with Kubernetes. AKS earns its complexity when you have many services, many teams, real scaling variance, and a platform group who owns the cluster. Choosing it too early is a common and expensive mistake; choosing it too late is a painful one. Both are worth thinking about honestly.
Every diagram on this page is a live simulation, not a screenshot; the timings are
illustrative, the mechanics are real. Code samples target azurerm 4.x,
Kubernetes 1.34+ and .NET 9. AKS keeps three GA minor versions in support at a time, and
several provider argument names changed between azurerm 3.x and 4.x — check the docs for
your pinned versions before copying into production.