0% read
    ← All stories
    Terraform · Azure Kubernetes Service · CI/CD

    Terraform builds
    the theatre.

    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.

    15 August 2026 · 18 min read · Nilesh Mohite
    SCROLL
    Terraform Azure Kubernetes Service Kubernetes CI/CD .NET DevOps
    Cold open · 02:14 AM

    Black Friday, and one server is on fire

    INT. OPS ROOM — NIGHT

    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.

    THE TWO HEROES

    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:

    ServiceJobWhy it's separate
    catalog-apiProducts, search, imagesRead-heavy. Traffic spikes 40× on sale days. Needs to scale alone.
    cart-apiBasket state per shopperChatty, backed by Redis. Different failure profile entirely.
    orders-apiCheckout, payment, fulfilmentMust never lose a write. Deploys carefully, rarely.
    identity-apiSign-in, tokensSecurity boundary. Own team, own release cadence.
    How to read this

    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.

    Setup · the shared secret

    Both heroes have the same superpower

    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.

    Diagram 1 One loop, two heroes switch hero · click a step →
    1 · DECLARE what should be true 2 · OBSERVE what is true 3 · DIFF the gap 4 · ACT close the gap TERRAFORM
    Step 1 · Declare
    You write it down

    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.

    TerraformKubernetes
    BuildsThe theatre — clusters, networks, registries, identitiesNothing. It conducts what's already there.
    Its worldAzure Resource ManagerThe cluster's own state store (etcd)
    MemoryA state file, in blob storageetcd, managed for you
    Loop speedOn demand — minutesContinuous — seconds
    Changes areReviewed by a human firstApplied instantly, then watched
    Failure looks likeA plan that won't applyA pod stuck in Pending
    The payoff

    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.

    Act I · The theatre

    Terraform, or: how to build the same thing twice

    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 PITCH, IN ONE LINE

    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.

    SourceWhat it isWhere it lives
    Your .tf filesWhat you wantGit — reviewed in pull requests
    The state fileWhat Terraform believes it builtAn Azure Storage blob, locked while running
    Azure itselfWhat actually exists right nowRead 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.

    The rule that saves careers

    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.

    Curtain up

    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.

    Diagram 2 terraform plan → apply · building the theatre press play →
    $ _
    idle · waiting for input

    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.

    Under the hood · the actual theatre, in HCL

    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.

    versions.tf
    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
    }
    network.tf
    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"]
    }
    aks.tf
    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.

    Under the hood · workload identity — deleting your last connection string

    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
    Under the hood · modules, environments, and not copy-pasting prod

    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"
    }
    Act II · The stage

    What Terraform just built, and who's now in charge of it

    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 METAPHOR, PAID OFF

    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.

    Diagram 3 Anatomy of the cluster Terraform built click any box →
    MICROSOFT-MANAGED CONTROL PLANE — no VMs you can see API server the front door kubectl talks here etcd desired state source of truth Scheduler placement which node? Controllers the fixers the loop lives here instructions ↓ · heartbeats ↑ YOUR NODE POOLS — real Azure VMs, you pay per second System pool CoreDNS, metrics, CSI drivers 2–4 nodes · tainted no app workloads Apps pool catalog · cart orders · identity autoscales 3 → 30 D4as_v5 · 3 zones Spot pool batch, reports, image resize up to ~90% off can vanish anytime Ingress app routing add-on one public IP, routes by hostname Container Registry your image shelf contosoacr.azurecr.io pulled via managed identity Workload Identity no passwords pod → Entra ID → Key Vault, SQL

    The loop, wearing its Kubernetes costume

    You met this in the setup. Here it is doing the job that makes Kubernetes feel alive:

    THE LOOP · KUBERNETES CUT

    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.

    Under the hood · the smallest useful Deployment

    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") });
    Where the two heroes hand off

    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.

    Act III · The score

    Git is the score, and the pipeline is how it reaches the stage

    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.

    THE REFRAME

    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.

    Two pipelines, not one

    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 pipelineApplication pipeline
    ChangesCluster, network, ACR, identitiesThe four .NET services
    ToolTerraformDocker + Helm/kubectl
    CadenceWeekly, maybeSeveral times a day
    ApprovalA human reads the plan diff, alwaysLayered gates — mostly automatic, human where judgement is needed
    Blast radiusEverythingOne service
    Repocontoso-platformcontoso-catalog, -cart, …
    WHY IT MATTERS

    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.

    Who approves what — and why "no approvals" is the wrong goal

    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.

    GateThe question it asksWho answersWhat it blocks
    Pull request reviewIs this change correct and well-designed?A human teammateThe merge
    Tests & coverageDid we break anything we knew about?The pipelineThe build
    Image scan & signingAre we shipping a known CVE?The pipelineThe push to ACR
    Staging smoke testsDoes it actually run when assembled?The pipelinePromotion
    Canary analysisDoes real traffic behave? Latency, 5xx, saturationAutomated metric analysisThe full rollout
    Environment approvalShould this ship now, given what else is happening?Named humans — on high-risk servicesProduction deploy
    Change freezeAre we inside a protected window?PolicyEverything

    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.

    Not every service earns the same gates

    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.

    catalog-api
    Tier 3 · auto-promote
    • Reversible in seconds
    • Read-mostly, no money moves
    • Canary metrics are the gate
    • Deploys itself, several times a day
    cart-api
    Tier 2 · gated by data
    • Session state at risk
    • Longer canary window
    • Human gate only when the schema changes
    orders-api · identity-api
    Tier 1 · human required
    • Takes money; touches the ledger
    • Security boundary
    • Two named reviewers before prod
    • Blocked during freeze windows
    The honest cost of a human gate

    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.

    Diagram 4 The application pipeline · commit → canary → gate → production pick a scenario →
    // waiting for a commit…
    ⏸ Deployment paused · approval required

    The moment the pipeline hands over

    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.

    Push vs. pull — the GitOps fork in the road

    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.

    Under the hood · the build half (GitHub Actions → ACR)

    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.

    .github/workflows/catalog-api.yml
    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"]
    Under the hood · how the approval gate is actually configured

    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.

    The infrastructure pipeline is deliberately slower

    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.

    Act IV · The performance

    What "orchestration" actually means at 3 in the afternoon

    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.

    Job one · Placement

    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.

    Job two · Discovery

    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.

    THE OLD WAY / THE NEW WAY

    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.

    Job three · Scale

    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.

    Job four · Change

    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.

    Diagram 5 Rolling update simulator · catalog-api v1.14 → v1.15 drag the sliders, then deploy →
    serving traffic8
    capacity vs. target100%
    requests dropped0
    elapsed0s

    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.

    Under the hood · rollout strategy, autoscaling and disruption budgets
    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 }

    Scale on the thing that actually predicts load

    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 }

    Protect yourself from your own maintenance

    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 }

    And graceful shutdown on the .NET side

    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
    Act V · The fire

    Something breaks. Nobody wakes up.

    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.

    INT. OPS ROOM — NIGHT (ONE YEAR LATER)

    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.

    Diagram 6 Resilience sandbox · break it and watch it heal click a node to kill it →
    average pod utilisation 24%
    healthy nodes4
    running pods8
    service statusOK

    What neither hero can fix for you

    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.

    Under the hood · the four alerts worth having on day one

    Alert on symptoms your customers feel, not on causes. High CPU is not an incident. A 12% error rate is.

    SignalThresholdWhy
    CrashLoopBackOffany pod, 5 minA restart loop is a deploy that failed silently.
    HTTP 5xx rate> 2% over 5 minThe only number a customer actually experiences.
    p95 latency> 2× the 7-day baselineCatches slow before it becomes down.
    Node pool at maxat max_count > 10 minYou'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.

    Credits

    The whole thing, in one breath

    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.

    THE THROUGH-LINE

    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.

    If you're starting on Monday

    #Do thisNot this
    1Terraform first — one cluster, one node pool, from a file, from day zeroClicking a cluster together "to try it" and never rebuilding it
    2Remote state in Azure Storage from the very first initLocal state "just for now"
    3Workload identity and OIDC federation from day oneRetrofitting security after the secrets are everywhere
    4One service, deployed end to end by a pipeline, before adding a secondDesigning a multi-region mesh before anything runs
    5Readiness probes and resource requests on every serviceDefaults, then wondering why deploys drop traffic
    6Risk-tier your services and gate them differentlyOne approval policy for everything — or none at all
    7Observability wired up before your first real incidentAdding dashboards while production burns
    The uncomfortable question

    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.

    Colophon

    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.

    ← Back to all stories