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

    Terraform builds
    the theatre.

    Kubernetes conducts the show.

    Two tools, two jobs, one idea behind both. This is the story of how a .NET monolith becomes a set of microservices on Azure, told in five acts, with diagrams you can play with.

    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 is glowing. On it, a graph climbs like a heartbeat that will not come down. Contoso's online store, one big Contoso.Web.dll on one big IIS server, is drowning in traffic that wants only one thing: the shoe on sale.

    The catalogue page is the part that is melting. But checkout, cart, invoicing and the emails all live inside the same binary, on the same machine. To give the catalogue 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 opens the deploy script. It is 400 lines of PowerShell. At the top there is a comment: "do not run twice."

    Two different things are failing in that room, and it is worth being clear about which is which, because each has its own hero, and most articles introduce only one.

    The first failure is the venue. One server, built by hand, that nobody can rebuild. Four people put it together over eighteen months of clicking in the portal, and two of them have left. That machine is not infrastructure; it is a museum piece. Nobody dares to touch it and nobody can recreate 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 swap instruments in the middle of the performance without the audience hearing a gap. At 2 a.m., that someone is a person holding a laptop.

    THE TWO HEROES

    Terraform fixes the first one. It builds the venue from a written plan, exactly the same way every time, and it will build you a second one on Tuesday if you ask.

    Kubernetes fixes the second. It reads the music sheet, watches every section, and keeps the show going whether or not anyone is awake.

    Neither is enough on its own. A perfect cluster full of hand-deployed chaos is still chaos. Beautiful Terraform pointing at nothing is just a very well-documented empty room.

    We will follow one company, Contoso Commerce, as it breaks its monolith into four .NET microservices and gets them running properly. Four services is enough to be real, and small enough to keep in your head:

    ServiceJobWhy it's separate
    catalog-apiProducts, search, imagesMostly reads. Traffic jumps 40× on sale days. Needs to scale on its own.
    cart-apiBasket state per shopperTalks a lot, uses Redis. Fails in a completely different way.
    orders-apiCheckout, payment, fulfilmentMust never lose a write. Deployed carefully and rarely.
    identity-apiSign-in, tokensA security boundary. Its own team, its own release schedule.
    How to read this

    The main text is the story, in plain English, with no background needed. Wherever you see an Under the hood panel, click it for the real Terraform, YAML and pipeline code. Skip all of them and the story still makes sense.

    Setup · the shared secret

    Both heroes have the same superpower

    Before the acts begin, one idea makes the rest of this article easy. Terraform and Kubernetes look like completely different tools. Really, they are the same tool, running at two different speeds, in two different worlds.

    Neither of them takes orders. You cannot tell either one to "create a VM" or "start a container". You can only tell them what should be true. Then they go and make it true, and, this is the important part, they keep making it true afterwards.

    This behaviour has a name: a reconciliation loop. Four steps, again and again, forever. Switch between the two heroes below and see the same loop in 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 manages what is 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, like drift, Pending pods, "no changes" or surprise re-creations, is just the loop doing exactly what it promised, using information you did not know it had.

    Act I · The theatre

    Terraform, or: how to build the same thing twice

    Our first hero walks in with a simple but bold idea: your environment is a text file in your repository. If it is not in the file, it does not 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 get forgotten. Contracts get reviewed, versioned and signed, and someone who was not in the room can run them again next year.

    Remember the loop. Terraform's "observe" step is unusually careful, because it compares three things, not two, and knowing which three explains almost every surprising plan you will 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 Terraform runs
    Azure itselfWhat actually exists right nowRead live through the Azure API on every plan

    When all three agree, the plan is empty and Terraform says the most comforting 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 switched on, and let Terraform use the blob lease as its lock. That lock is the only thing stopping two pipelines from applying changes to the same cluster at the same time, which damages the state file in ways that take a very bad afternoon to fix.

    Curtain up

    Below is what a real terraform apply looks like from the outside. Press play. Watch the steps sort themselves out: you never told Terraform that the subnet must exist before the cluster. It worked that out because one refers to 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 worked out the steps. That flip is the whole value of Terraform, and it is why the same file works on an empty subscription and on one where nine of the ten resources already exist.

    The theatre is now standing: 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 all 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

    Fix your provider versions. A provider without a fixed version is a time bomb that goes off on a Friday. The azurerm provider is on version 4.x, and several setting names changed from 3.x, which is the most common reason a copied snippet does not 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 discussing in code review: network_plugin_mode = "overlay" (stops you running out of VNet IP addresses), local_account_disabled = true (removes the shared admin kubeconfig that ends up pasted in Teams), and ignore_changes on node_count. Without that last one, Terraform and the cluster autoscaler will fight 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 sooner or later someone commits it. The new way: the pod proves who it is with a short-lived token, and Azure gives it access. Nothing to leak, nothing to rotate, nothing to find in 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 is 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 only by a variables file, never by a separate copy of the code. The moment they drift apart, "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 is built. Our second hero walks on, and here is the most useful thing to know about AKS, which almost nobody says clearly: a cluster has two halves, and Microsoft runs only one of them.

    The half Microsoft runs is the control plane, the brain. It holds the desired state of your world ("there should always be six catalogue pods"), decides which machine each container runs on, and keeps comparing what is with what should be. You do not patch it, you do not log in to it, and on the Free tier you do not 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: normal Azure virtual machines that do the actual work. Your containers run here. You pay for them by the second, like any other VM.

    THE METAPHOR, PAID OFF

    The control plane is the conductor. It reads the music sheet, watches every section and points. It plays no notes. The nodes are the musicians. They make all the sound and take 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. Terraform created every box you just saw, and each one is something you will 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 at the start. Here it is doing the job that makes Kubernetes feel alive:

    THE LOOP · KUBERNETES CUT

    You: "There should be six catalogue 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 what was declared and what exists, being closed again and again. Terraform does this when asked, in minutes, against Azure. Kubernetes does it all the time, 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 setting up its DI container and 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. Keep that line sharp. Do not let Terraform apply your application manifests (it will fight the controllers, and its state file will go out of date every time a pod moves), and do not let Kubernetes create Azure infrastructure it cannot understand. Each hero in 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 checking at the right moments. That is the pipeline.

    Start from the idea, not the tools. Both our heroes accept only one kind of input: a written description of what should be true. So the question "how do we deploy?" becomes a much better question: how does a change to that description get proposed, reviewed and accepted?

    Git already answers that. A branch is a proposal. A pull request is a review. A merge is acceptance. CI/CD is the machinery that makes a merge mean something in the real world.

    THE REFRAME

    You are not "pushing code to servers". You are proposing a change to the description of your production system, getting it reviewed, and once it is accepted, letting two reconciliation loops make the real world match the new description.

    Two pipelines, not one

    Here is the difference that unlocks everything, and mixing it up is the most common mistake teams make. One hero's description 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 person always reads the plan differenceSeveral layers of checks, mostly automatic, with a person only where judgement is needed
    If it goes wrong, it affectsEverythingOne service
    Repocontoso-platformcontoso-catalog, -cart, …
    WHY IT MATTERS

    If deploying a one-line text fix to the catalogue page means running Terraform, then every deploy carries the risk of accidentally re-creating 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 is a myth here worth killing: that continuous deployment means removing human approval. It does not, and teams that chase that goal end up either reckless or, more often, bringing back a Thursday-afternoon change board and feeling bad about it.

    Mature pipelines have more checks than manual processes had, not fewer. What changes is who does them and when. The old model had one check at the end, done by a person reading a change ticket about software they had never seen. The new model has six or seven checks, most of them automatic, and the human ones sit exactly where a person adds judgement 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 security hole (CVE)?The pipelineThe push to ACR
    Staging smoke testsDoes it run when assembled?The pipelinePromotion
    Canary analysisDoes real traffic behave? Response time, server errors, how close we are to limitsAutomated 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 person is worse at that than the machine, and asking them turns the approval into a rubber stamp. They are asked the one thing the pipeline cannot know: is this a good time, given everything happening outside this repository? A sale starting in an hour. A payment provider already having a bad morning. An audit window. That is judgement, and it belongs to a person.

    Not every service earns the same gates

    Group your services by risk and let the pipeline behave differently for each group. Giving catalog-api the same heavy process as orders-api slows everyone down for nothing; giving orders-api the speed of catalog-api is how you refund people by mistake.

    catalog-api
    Tier 3 · auto-promote
    • Reversible in seconds
    • Read-mostly, no money moves
    • Canary health numbers decide
    • Deploys itself, several times a day
    cart-api
    Tier 2 · gated by data
    • Session state at risk
    • Longer canary window
    • A person approves only when the database 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 approval is a queue. It adds hours of waiting, it bundles unrelated changes together (which makes the eventual failure harder to find), and if it fires on every deploy, within about three weeks people approve it without thinking. So add approvals carefully: an approval that is always given is not a control, just a delay with paperwork. If your approver has never once said no, that approval is telling you something.

    Run the pipeline below. The first button deploys catalog-api, which promotes itself based on canary health numbers. The second deploys orders-api, which stops and waits for you. The third pushes a commit with a vulnerable library, so you can watch an automatic check 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 is a hand-over worth naming, where our two heroes swap 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 submits a change. Kubernetes then does the real work: pulling images, starting pods, waiting until they are ready, moving traffic and removing the old ones.

    So 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 become ready. Without that line, a broken image gets a green tick 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 other way 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. Then CI 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 easier to start with. Pull works better beyond a handful of clusters, because the cluster's real state is always a commit you can compare, which is the reconciliation loop applied to the delivery process itself.

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

    See that there is no password anywhere. OIDC federation lets the workflow prove it is this repo, this branch and get 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, not running as root, with a chiselled runtime. Chiselled images have no shell and no package manager, which removes most of what a security 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 configured

    In GitHub Actions, the approval is an environment with protection rules. The job names the environment, and GitHub handles the pause, the notification, the audit trail and the "you cannot approve your own deploy" rule. Azure DevOps environments have the same idea as Approvals and Checks.

      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 approval real, not just for show. --atomic means a rollout that does not become healthy within the timeout is rolled back automatically, so the approver is allowing an attempt, not a one-way door. And prevent self-review means the person who wrote the change is not the person who approves it, which is the whole point of having an approval.

    The infrastructure pipeline is deliberately slower

    Run plan on the pull request so reviewers see the difference. Apply only after a person clicks approve.

    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 plan again at apply time, what runs is not what 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" 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" usually just means "some Kubernetes stuff happens". It is more specific than that: four jobs, running all the time, that nobody has to ask for.

    Job one · Placement

    You said catalog-api needs 150 millicores of CPU and 192 MB of memory. The scheduler finds a node with space, follows your rules ("spread these across three availability zones", "keep batch jobs off this pool"), and places the pod there. If no node has space, the cluster autoscaler asks Azure for another VM, and about three minutes later there is one. Note that Terraform set 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 points, inside the cluster, to whichever pods are healthy right now. Pods die and come back with new IP addresses 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 whenever 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 your own signal like "messages waiting in the Service Bus queue", and adds pods. If the pods do not fit, the cluster autoscaler adds nodes. Two loops, one inside the other, both closing gaps.

    Job four · Change

    This is the one you will care about most, because it runs during every deploy. Kubernetes replaces old pods with new ones gradually, and will not send traffic to a new pod until that pod says it is ready. Two numbers control the whole thing; play with them and see what happens to your capacity in the middle of a 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 drops below full capacity; it costs a few extra pods' worth of compute for a couple of minutes. Set it to 4 and the deploy is faster, but you run at half capacity in the middle of it, which is fine at 3 a.m. and a disaster during a sale. This is the trade-off, and you decide it for each service: the same risk grouping 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 predicts load

    CPU tells you about load only after it has arrived. For orders-api, the queue length warns you before it arrives. KEDA scales on the queue length directly, and can even scale down 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 planned disruptions, like node upgrades and autoscaler clean-up, from taking down more pods than you can afford. Without it, an AKS node image upgrade can drain three nodes and leave you with just one pod for a while:

    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 immediately, you lose whatever requests were in progress. The .NET 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 is the part that justifies all the effort: 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. No warning, no draining. It just stops responding.

    Forty seconds pass, four missed heartbeats, and the control plane marks the node NotReady. Those pods are removed from every service's routing list, so within a minute traffic stops going to pods that no longer exist.

    Then nothing happens for five more minutes. That is the default wait for an unreachable node (the node.kubernetes.io/unreachable taint), and the wait is on purpose: Kubernetes would rather sit out a short network problem than move your whole workload because a network switch had a hiccup. When the wait ends, the pods are removed for real. The deployment controller does its maths: seven pods, should be eight. The scheduler finds space on the healthy nodes. New pods start. Readiness probes pass. Capacity is back to full.

    Meanwhile, the autoscaler has already asked Azure for a replacement VM, using the node pool definition Terraform wrote months ago and has not thought about since.

    Traffic protected in under a minute, capacity back in about five, people involved: zero. If five minutes of running short is too long for you, lower tolerationSeconds in the pod spec, and accept that an unstable network will now make pods move around more. The monitor still glows, but nobody is watching it, because there is nothing to watch.

    Try it yourself. Kill a node, or turn up the traffic 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 limit, and it is fair to say so. 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 copies of a broken build. It will not notice that your responses became twice as slow, only that the process is still alive. And Terraform will rebuild a badly designed network exactly as badly, every time you ask.

    That is why the last piece of the picture is seeing. Container Insights and Managed Prometheus collect metrics; Managed Grafana draws them; Application Insights follows a single order across all four services and shows you which step 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 problems your customers feel, not on their causes. High CPU is not an incident. A 12% error rate is.

    SignalThresholdWhy
    CrashLoopBackOffany pod, 5 minA restart loop is a failed deploy that nobody noticed.
    HTTP 5xx rate> 2% over 5 minThe only number a customer experiences.
    Response time of the slowest 5%> 2× the usual value over the last 7 daysCatches "slow" before it becomes "down".
    Node pool at maxat max_count > 10 minYou have hit your limit; the autoscaler has no more room.

    That last one is where the two heroes talk to each other: the alert fires because Kubernetes has used up the space Terraform gave it. The fix is a pull request that changes max_count, reviewed, planned and applied, not a click in the portal.

    Credits

    The whole thing, in one breath

    Terraform builds the theatre: it writes down what the world should look like and makes Azure match, carefully, with a person reading the difference. 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 and replacing the dead. Git is the music sheet both of them read from, and the pipeline is how a new page gets added.

    That is it. Everything else is detail, and details can be learnt. The idea is what matters.

    THE THROUGH-LINE

    Both heroes have the same superpower, and neither can do the other's job. Terraform: declared vs. Azure, minutes, when asked. 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 effort. Terraform is still worth every minute, because the first hero's argument has nothing to do with Kubernetes. AKS is worth its complexity when you have many services, many teams, traffic that changes a lot, and a platform team that owns the cluster. Choosing it too early is a common and costly 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 examples; the mechanics are real. Code samples are for azurerm 4.x, Kubernetes 1.34+ and .NET 9. AKS supports three generally available minor versions at a time, and several provider setting names changed between azurerm 3.x and 4.x, so check the docs for your pinned versions before copying anything into production.

    ← Back to all stories
    ––

    Discussion