0% read
    ← All stories
    ––
    Azure DevOps · Terraform · AKS

    Project Keystone

    Four services, six repositories, and one configuration that holds them all together. An architect's walk through the launch sequence, and an honest discussion about where configuration should live.

    16 August 2026 · 24 min read · Nilesh Mohite
    SCROLL
    Azure DevOps Terraform AKS Key Vault App Configuration Platform Engineering
    Cold open · T–00:10:00

    The go/no-go poll

    INT. RELEASE CHANNEL — THURSDAY, 14:50

    Four names in a chat thread. The flight director asks the only question that matters, one desk at a time.

    PILLAR — go. GATEHOUSE — go. FACADE — go. KEYSTONE — go.

    Nobody is nervous. Nobody has a rollback script open in another window. Nobody has said "as long as nothing else changed". At 15:00 the release goes out, and at 15:04 four services are running a version that did not exist this morning.

    A team that can say go without fear does not have better people. It is a team where someone, months earlier, took a series of boring but important decisions about repositories, build outputs, approval gates and configuration. Every one of those decisions was taken so that on release day there is less to be afraid of.

    This article builds that kind of team setup. We are designing Project Keystone: four services on AKS, delivered by Azure DevOps, created with Terraform, with Key Vault holding the secrets and Application Insights keeping watch. We will design it the way an architect works: the brief first, then the decisions the brief did not mention, then the one decision everybody gets wrong.

    The brief

    ServiceResponsibilityWhy it's separate
    KeystoneConfiguration for the whole ecosystemThe stone at the top of an arch. Every other stone leans on it; remove it and the arch falls.
    PillarDomain service — the business logicCarries the weight. Owns the model, the rules and the database. Changes carefully.
    GatehouseIntegration gateway — third partiesThe guarded entrance. Everything from outside comes in here and is made safe before it goes further.
    FacadeBackend-for-frontendThe face users see. Built entirely around what the UI needs, and changes as often as the UI does.

    Four services, four repositories. That is the brief as given, and it is a sensible brief. But, as we will see in Act III, it is two repositories short, and those two missing ones decide whether this system is still easy to maintain eighteen months from now.

    How to read this

    The main text is the design discussion, in plain English, with no background needed. Every Under the hood panel opens the real YAML, HCL and C#. The Decision blocks are architecture decision records: what we chose, what else we considered, and what it costs. Read only the decisions and you still get the design.

    Act I · The vehicle

    What we are launching

    Before you design a pipeline, you have to be honest about what it delivers. Four services is not one system deployed four times. It is four systems that change at four different speeds, cause four different sizes of damage when they break, and have four different answers to "what happens if this is wrong at 3 a.m."

    Those differences are the whole design. A pipeline that treats Facade and Pillar the same way is either too slow for Facade or too risky for Pillar. So we start by writing down how they differ.

    ServiceDeploysIf it's wrongGate
    FacadeSeveral times a dayA screen looks odd. Can be undone in minutes.Automated only
    GatehouseWeeklyA partner integration breaks. Can be fixed, but embarrassing.Automated + contract tests
    PillarWeekly, carefullyWrong data gets saved. Maybe impossible to undo.Human approval
    KeystoneHourly, in theoryAll three at once.Depends completely on Act II

    Look at that last row. Configuration changes the most often and causes the widest damage in the whole system. That combination is rare and dangerous, and it is why configuration, not the pipeline and not the cluster, is the real design problem here.

    Click through the system. Every box is something you will create in Terraform, deploy from a pipeline, or stare at during an incident.

    Diagram 1 The Keystone arch click any block →
    THE ARCH — WHAT USERS TOUCH KEYSTONE configuration source of truth FACADE backend-for-frontend the visible face ships daily GATEHOUSE integration gateway third parties in, made safe PILLAR domain service owns the model and the database THE PLATFORM — WHAT HOLDS IT UP AKS one cluster, namespace per env ACR immutable images, promoted by digest App Config runtime store, immutable snapshots GUARD RAILS Key Vault secrets, referenced never copied App Insights traces across all four and gates the deploy Azure DevOps repos, pipelines, environments, checks
    Why "Keystone"

    A keystone is the wedge at the top of an arch. It carries almost no weight itself, but every other stone passes its load through it, and until it is in place the arch cannot stand. It is the smallest piece and also the one you cannot remove. That is exactly what configuration is: a few kilobytes of text that the whole structure leans on.

    Act II · The fuel problem

    How does configuration reach a service?

    The brief says Keystone "serves configuration to the other three". That one sentence hides a lot of work, and how you read it will decide more about this system's reliability than any other choice you make. So let us not assume. Let us question it.

    There are three honest answers, and big companies run all three at scale. None of them is stupid. Each one gives up something different, and that trade-off is what you are really choosing.

    ModelWhat it meansConfig change takes
    A · Runtime serviceKeystone runs as a pod. The other three call it when they start and check again from time to time.Seconds
    B · Baked at deployThe Keystone repo publishes versioned files; pipelines turn them into ConfigMaps and deploy them along with the image.A full deploy
    C · Managed storeThe Keystone repo is the source of truth, copied into Azure App Configuration. Services read from there.Seconds

    Everyone compares these when everything is working, and then they look almost the same. The only useful comparison is on a bad night. Pick a model, then break it in four different ways.

    Diagram 2 Three models, four bad nights pick a model, then a scenario →
    Configuration model
    What goes wrong tonight
    What happens

    What it costs you

    time to recover—
    how far it spreads—

    The failure that decides it

    Work through the "whole cluster restarts" scenario on model A and you find the argument that decides it. It is a normal Tuesday on AKS. A node pool gets upgraded. A zone has a short outage. Someone drains a node. Suddenly a large number of your pods restart at the same time.

    THE COLD START STAMPEDE

    Every Pillar, Gatehouse and Facade pod comes up at the same moment and immediately asks Keystone for its configuration.

    Keystone is also restarting. It has one or two copies. Now it is getting every start-up request in the cluster at once, from services that cannot continue without an answer.

    You have made every service in the system depend on one service, exactly at the moment when everything is already struggling.

    Be fair to model A, because the careless version of this argument is wrong. A configuration call that blocks start-up should be controlled by a readiness probe, not a liveness probe, and a startupProbe exists exactly so that a slow-starting pod is not killed and restarted again and again. Set those up properly and the rush becomes "everything starts slowly" instead of "nothing starts at all". Anyone who says a runtime config service will always collapse is really describing badly configured probes.

    The real argument is simpler and harder to escape: you now own and run one more service. Save the last good configuration on disk. Spread out start-up times. Size the copies for the rush at cold start, not for normal reads. Add a PodDisruptionBudget. Make reads non-blocking with sensible defaults. Keep all of this working as the system grows. Each of these is real, permanent engineering work, spent copying what a managed configuration store gives you on day one, for a service whose only job is to return a few kilobytes of text.

    Decision 01

    Keystone is a repository and a pipeline. It is not a pod.

    Decision
    No Keystone service runs in the cluster. keystone-config is the Git source of truth. Its pipeline checks the configuration and publishes it to two places: versioned files that are built into deployments, and Azure App Configuration snapshots for values read while running.
    Alternatives rejected
    Model A, a runtime config service. Rejected because it turns a static file into a live dependency, and makes everything fail together at cold start. Model B alone, building everything into the deployment. Rejected because you could not change a rate limit during an incident without a full deploy.
    Cost of this decision
    Two mechanisms instead of one, so engineers must know which is which. We handle that with the classification rule below, and by making the wrong choice hard to write in code.
    When we'd revisit
    If configuration ever needs per-customer values with inheritance and calculated values (real logic, not just lookup), a real service starts to make sense. Four services and three environments are nowhere near that point.

    So which mechanism for which setting?

    Here is the rule that makes the two-mechanism design work. It is the single most useful idea in this article:

    THE CLASSIFICATION RULE

    Do not ask "where does config live?". Ask two questions about each setting: how quickly might I need to change this, and what happens if it is wrong?

    Anything you might need to change during an incident must not need a deploy. Anything that must be repeatable must be fixed and versioned with the code it configures. Anything that is a secret is neither: it is referenced, never stored.

    That gives three classes, and every setting belongs to exactly one. Try it; the lines are less obvious than they look.

    Diagram 3 Sort the configuration pick a home for each setting →
    scored 0 / 7

    The two that trip up almost everyone are the connection string and the log level. A connection string looks structural: it points at infrastructure Terraform created and changes only when that infrastructure changes. But the moment a password appears anywhere in the value, the whole value becomes a secret, because a string is only as safe as its most sensitive part. The server name is structural; the string with the password is not. The log level goes the other way. It feels small and permanent, but the whole reason it exists is so you can turn it up at 02:00 while looking at a problem. That makes it operational, and building it into the deployment makes it useless exactly when you need it.

    The one rule with no exceptions

    Secrets go in Key Vault and are referenced, never copied. Not into a ConfigMap, not into App Configuration as plain text, not into a pipeline variable marked secret and then printed into a manifest. App Configuration stores a reference to a Key Vault secret, and the pod reads it at runtime using its own workload identity. If a secret value ever shows up in a Git diff, a pipeline log or kubectl get configmap, the design has failed, however well everything else was built.

    Under the hood · the three mechanisms, wired

    Structural — baked into a ConfigMap by the pipeline

    The Keystone repo holds plain YAML for each service in each environment. The deploy pipeline fills it in, takes a hash of it, and names the ConfigMap after that hash. So a config change creates a new ConfigMap and forces a real rollout, instead of changing the old one in place with no rollout at all:

    # keystone-config/services/pillar/prod.yaml
    database:
      commandTimeoutSeconds: 30
      maxPoolSize: 200
    endpoints:
      gatehouse: "http://gatehouse.keystone-prod.svc.cluster.local"
    telemetry:
      cloudRoleName: "pillar"
      samplingPercentage: 25
    # rendered by the pipeline — note the content hash in the name
    apiVersion: v1
    kind: ConfigMap
    metadata:
      name: pillar-config-7f3a91c     # changes when content changes
      namespace: keystone-prod
    immutable: true                  # Kubernetes will refuse edits
    data:
      appsettings.Production.json: |
        { "Database": { "CommandTimeoutSeconds": 30, ... } }

    immutable: true makes Kubernetes refuse any in-place change to data. So "someone edited the ConfigMap at 3 a.m." now needs a delete and re-create, which shows up in the audit log, instead of a silent kubectl edit. It is not impossible, just very visible, and that is most of the value.

    The hash-naming trick needs two more things to work. The Deployment's pod spec must be updated to point to the new ConfigMap name; a new ConfigMap that nothing points to changes nothing, and this is the step teams forget. And since old ConfigMaps pile up forever, you need a clean-up job that skips any ConfigMap still used by a kept ReplicaSet, or one day you will delete exactly what a rollback needed.

    Operational — Azure App Configuration with refresh

    Read it using a sentinel key, so the app makes one cheap check instead of watching every setting, and reloads only when the sentinel changes:

    // Program.cs — Pillar, Gatehouse and Facade all do this identically
    var env = builder.Environment.EnvironmentName;
    
    builder.Configuration.AddAzureAppConfiguration(options =>
    {
        options.Connect(new Uri(appConfigEndpoint), new DefaultAzureCredential())
               .Select("Pillar:*", labelFilter: env)
               // Key Vault references resolve with the pod's own identity
               .ConfigureKeyVault(kv => kv.SetCredential(new DefaultAzureCredential()))
               .ConfigureRefresh(refresh =>
               {
                   // ← the label MUST match Select's. Register without it and you
                   //   watch a null-label sentinel nobody ever bumps: refresh
                   //   silently never fires. This is the classic mistake.
                   refresh.Register("Pillar:Sentinel", env, refreshAll: true)
                          .SetRefreshInterval(TimeSpan.FromSeconds(30));
               });
    },
    // ← without optional:true an unreachable store throws at Build()
    optional: true);
    
    // the middleware needs this registration or UseAzureAppConfiguration throws
    builder.Services.AddAzureAppConfiguration();
    var app = builder.Build();
    app.UseAzureAppConfiguration();   // drives the refresh check per request

    Change ten related values, then update Pillar:Sentinel once. Every pod picks up all ten together within the refresh interval. No half-applied changes, no deploy.

    Secret — Key Vault, referenced

    The pod signs in as itself using workload identity; no secret is ever written into the cluster. In App Configuration, the value is a reference, not the actual secret:

    # stored in App Configuration as a Key Vault reference
    key   : Pillar:ConnectionStrings:Sql
    label : Production
    value : {"uri":"https://kv-keystone-prod.vault.azure.net/secrets/pillar-sql-connection"}
    type  : application/vnd.microsoft.appconfig.keyvaultref+json

    The SDK sees the reference, calls Key Vault using the pod's managed identity, and gives the application the actual value without saving it anywhere. Change the secret in Key Vault and every user of it gets the new value, with no deploy and no config change at all.

    Under the hood · what makes App Configuration safe to depend on

    Model C still puts a network call in your start-up path, so it deserves the same questions we asked about model A. Three things make its risk very different:

    • Snapshots cannot be changed. A snapshot is a named, frozen copy of part of the store. It can be created, archived and recovered, but never edited or deleted. Link a release to a snapshot and you get the same repeatability as built-in config, with a managed service doing the serving.
    • The provider caches. Configuration is loaded once at start-up and then checked again only at the refresh interval. A short problem in the store does not bring down running pods; they just keep using the values they already have.
    • Keeping it running is not your job. The risk is a managed regional Azure service having a bad day, not a two-copy deployment you wrote being flooded by its own users. These are not the same risk, even if the uptime numbers look similar.

    The honest warning: a pod still needs the store at start-up unless you say otherwise. Stack the providers so structural values come from the built-in ConfigMap first and App Configuration adds to them when it is reachable, and mark it optional. That one setting turns "the store is down, so nothing starts" into "the store is down, so we run on the last deployed values":

    builder.Configuration
        .AddJsonFile("appsettings.json", optional: false)
        .AddJsonFile($"/config/appsettings.{env}.json", optional: true)   // baked ConfigMap
        .AddAzureAppConfiguration(o => { ... }, optional: true);       // wins if reachable

    Later providers win, so the cluster can always start. Note that WebApplicationBuilder has already added appsettings.json and environment variables before your code runs. Adding them again moves them to the end of the list and changes which value wins, which is worth knowing before you copy this into an app that depends on the defaults.

    One thing not to claim: an environment variable is a poor emergency switch here. Changing one means editing the Deployment, which is a rollout — the exact deploy we just criticised model B for needing.

    Act III · The launch pad

    Six repositories, not four

    The brief said four repositories. The brief was describing the product, not the whole system, and there are two more things here that change on their own schedule, can cause their own damage, and need their own reviewers. Giving them their own home is the difference between a platform and a pile of stuff.

    Terraform needs a repository. The cluster, the container registry, the Key Vault, the configuration store and the Application Insights workspace do not belong to any one service. They change weekly, not hourly. A mistake there takes down all four services, not one. And the person who should approve "resize the node pool" is not the person who should approve "change a button label". If infrastructure sits inside a service repo, every normal deploy carries a hidden risk of re-creating a node pool.

    Pipeline templates need a repository. Four services means four pipelines, and without a shared template that means four copies of the build steps, the security scan, the image naming rules and the deployment logic. Fix a security issue in the scan step and you have to fix it four times, badly, at four different times. Worse, a service team can remove the scan step from its own pipeline and nobody will notice.

    Decision 02

    Six repositories, with the platform and the templates separated from the services.

    Decision
    keystone-config, pillar-domain, gatehouse-integration, facade-bff, plus keystone-platform (Terraform) and keystone-pipelines (shared YAML templates, tagged and versioned).
    Alternatives rejected
    A monorepo — a fair choice, and if the four services were always released together as one unit, I would pick it. They are not: Facade releases daily and Pillar weekly, and a monorepo makes that difference harder to handle, not easier. Terraform inside each service repo — rejected outright; shared infrastructure with four owners really has no owner.
    Cost of this decision
    Changes across repos need coordination, and a developer changing config and code together opens two pull requests. We accept that; the alternative ties together release schedules that should stay separate.

    Which pipeline runs when you change a file?

    This is where the folder layout starts to matter. Inside keystone-config, configuration is layered, and the folder layout decides how far a change can spread. A change under services/facade/ can only ever affect Facade. A change under global/ affects everything, so it should be rare, obvious in a diff and harder to approve.

    Change a file and see what runs.

    Diagram 4 Change a file, see what builds click a path →
    Pipelines triggered
    how far it spreads—
    gate required—
    Pick a file to see the trigger cascade.

    Two things to notice. First, a change to global/ starts three pipelines and so three deploys, which is exactly why it needs an approval that a single-service change does not. Second, editing the README starts nothing at all. That sounds minor, but on a busy repo, path filters are the difference between a CI system people trust and one they have learnt to ignore.

    Everything shown here triggers on main — and that's only half a CI story

    Every trigger in this design runs after a merge, which is the moment a mistake becomes production's problem. The cheapest check in the whole system is the one that runs before the merge: a build validation policy on each repo, so pull requests must build and pass tests; JSON schema checks on every changed config file; and terraform plan posted as a comment on the platform repo's pull requests. For a change under global/ that would otherwise go to three production deploys, catching it during review costs minutes and stops most of the problems the later checks exist for.

    The trap in shared templates

    If you use a template repo without fixing a version, every service rebuilds on whatever main happens to be at that moment, without asking. One bad commit to the template repo breaks all four pipelines at once, including the pipeline you would use to fix it. Pin to a tag, ref: refs/tags/v3.2.0, and upgrade it on purpose, one service at a time.

    Under the hood · repository layout and the shared template contract
    keystone-config/
    config/
    ├── global/                      # affects all three services
    │   ├── telemetry.yaml
    │   └── resilience.yaml
    ├── environments/
    │   ├── dev.yaml
    │   ├── test.yaml
    │   └── prod.yaml                # stricter gate on this path
    └── services/
        ├── pillar/{dev,test,prod}.yaml
        ├── gatehouse/{dev,test,prod}.yaml
        └── facade/{dev,test,prod}.yaml
    schema/
    └── config.schema.json           # every file validated against this

    A service pipeline is kept very small on purpose. It says what it is and leaves everything else to the template. Note that it uses extends, not template:

    pillar-domain/azure-pipelines.yml
    trigger:
      branches: { include: [main] }
      # note the globs: 'docs/*' and '*.md' match one level only
      paths: { exclude: ['docs/**', '**/*.md'] }
    
    resources:
      repositories:
        - repository: templates
          type: git
          name: Keystone/keystone-pipelines
          ref: refs/tags/v3.2.0        # pinned, never a moving branch
      # REDEPLOY (not rebuild) when shared configuration changes. A config-only
      # change must not produce a new image: rebuilding from unchanged source
      # yields a different digest, which is the exact thing Act IV forbids.
      pipelines:
        - pipeline: config
          source: keystone-config-publish
          trigger: { branches: { include: [main] } }
    
    # extends, not template: the service CANNOT add arbitrary steps
    extends:
      template: service-pipeline.yml@templates
      parameters:
        serviceName: pillar
        projectPath: src/Pillar.Api/Pillar.Api.csproj
        # selects WHICH pre-configured environment this lands on.
        # The checks on that environment are owned by the platform team.
        riskTier: 1
        runContractTests: true

    And here is the template that defines what a pipeline is allowed to be:

    keystone-pipelines/service-pipeline.yml
    parameters:
      - { name: serviceName,  type: string }
      - { name: projectPath,  type: string }
      - { name: riskTier,     type: number, default: 3 }
      - { name: runContractTests, type: boolean, default: false }
    
    stages:
      # skip the build entirely when the run was started by the config pipeline —
      # we redeploy the digest already in production, with new config beside it
      - stage: build
        condition: ne(variables['Build.Reason'], 'ResourceTrigger')
        jobs:
          - job: build_scan_push
            steps:
              - template: steps/dotnet-build.yml
              - template: steps/unit-test.yml
              - ${{ if eq(parameters.runContractTests, true) }}:
                - template: steps/contract-test.yml
              - template: steps/container-scan.yml   # not optional, by construction
              - template: steps/push-acr.yml
    
      # one build, three deployments of the SAME digest
      - template: stages/deploy.yml
        parameters: { env: dev,  serviceName: ${{ parameters.serviceName }} }
      - template: stages/deploy.yml
        parameters: { env: test, serviceName: ${{ parameters.serviceName }} }
      - template: stages/deploy.yml
        parameters: { env: prod, serviceName: ${{ parameters.serviceName }} }

    Why extends and not a normal template include. An include is just a convenience: the calling pipeline can still do anything else it wants alongside it. An extends template defines the structure of the pipeline, and the service repo can only fill in the parameters the template accepts. Add a required template check on the production environment, and a pipeline that does not extend this template simply cannot deploy to production. The security scan stops being a step people are trusted to include, and becomes a rule of the system.

    Under the hood · Terraform, and the identity that has no password

    The platform repo creates everything shared. The detail worth copying is the service connection: workload identity federation, so Azure DevOps signs in to Azure with a short-lived token instead of a client secret someone has to change regularly.

    # keystone-platform — the pieces the pipelines depend on
    resource "azurerm_container_registry" "main" {
      name                = "acrkeystone"
      sku                 = "Premium"
      resource_group_name = azurerm_resource_group.platform.name
      location            = azurerm_resource_group.platform.location
    }
    
    # App Configuration data-plane RBAC has NO key or label scoping: Data Reader
    # on the store grants read on EVERY key-value in it. Labels are a client-side
    # convenience, not a security boundary — so one store PER ENVIRONMENT, or dev
    # pods can read production values and production Key Vault reference URIs.
    resource "azurerm_app_configuration" "main" {
      for_each            = toset(["dev", "test", "prod"])
      name                = "appcs-keystone-${each.key}"
      sku                 = "standard"   # snapshots + 30-day revision history
      resource_group_name = azurerm_resource_group.platform.name
      location            = azurerm_resource_group.platform.location
    }
    
    # the cluster must opt in to workload identity — both are off by default
    resource "azurerm_kubernetes_cluster" "main" {
      # ... sku_tier, node pools, network profile ...
      oidc_issuer_enabled       = true
      workload_identity_enabled = true
    }
    
    # one identity per (service, environment) — dev and prod must not share one
    locals {
      svc_env = {
        for pair in setproduct(["pillar", "gatehouse", "facade"], ["dev", "test", "prod"]) :
        "${pair[0]}-${pair[1]}" => { svc = pair[0], env = pair[1] }
      }
    }
    
    resource "azurerm_user_assigned_identity" "service" {
      for_each            = local.svc_env
      name                = "id-${each.key}"
      location            = azurerm_resource_group.platform.location
      resource_group_name = azurerm_resource_group.platform.name
    }
    
    # a federated credential carries exactly ONE subject — no wildcards.
    # One FIC per namespace, or dev pods fail with AADSTS70021.
    resource "azurerm_federated_identity_credential" "service" {
      for_each            = local.svc_env
      name                = "fic-${each.key}"
      resource_group_name = azurerm_resource_group.platform.name
      parent_id           = azurerm_user_assigned_identity.service[each.key].id
      audience            = ["api://AzureADTokenExchange"]
      issuer              = azurerm_kubernetes_cluster.main.oidc_issuer_url
      subject             = "system:serviceaccount:keystone-${each.value.env}:${each.value.svc}"
    }
    
    # read its own environment's store — the isolation is the store, not the label
    resource "azurerm_role_assignment" "appconfig_read" {
      for_each             = local.svc_env
      scope                = azurerm_app_configuration.main[each.value.env].id
      role_definition_name = "App Configuration Data Reader"
      principal_id         = azurerm_user_assigned_identity.service[each.key].principal_id
    }
    
    # without this the Key Vault references resolve to nothing at runtime
    resource "azurerm_role_assignment" "kv_read" {
      for_each             = local.svc_env
      scope                = azurerm_key_vault.main[each.value.env].id
      role_definition_name = "Key Vault Secrets User"
      principal_id         = azurerm_user_assigned_identity.service[each.key].principal_id
    }
    
    # and the cluster needs to be able to pull the images it is asked to run
    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
    }

    The Kubernetes part is easy to forget, and without it things fail with no clear error: the ServiceAccount must be annotated with the client ID, and the pod must be labelled to use it:

    apiVersion: v1
    kind: ServiceAccount
    metadata:
      name: pillar
      namespace: keystone-prod
      annotations:
        azure.workload.identity/client-id: "<id-pillar-prod client id>"
    ---
    # and on the pod template
    metadata:
      labels:
        azure.workload.identity/use: "true"

    Every credential in this system is federated or managed. There is no client secret in a variable group, no connection string in a pipeline, and nothing to change at 2 a.m. because someone left the company. This is more than a nice security touch; it removes a whole category of incidents.

    This snippet still leaves two things open, because they are decisions, not mistakes. Key Vault and App Configuration are shown with default public network access; in a regulated environment you would put private endpoints in front of both and accept the extra DNS work. And the platform pipeline itself needs the discipline the Terraform article talks about: remote state with locking, plan on the pull request, and apply the saved plan, not a fresh one.

    Act IV · The countdown

    Build once, fly three times

    Here is the rule that separates a real delivery pipeline from a build script, and it is broken all the time: what reaches production must be exactly what you tested. Not something similar. Not something rebuilt from the same commit with the same Dockerfile. The same bytes.

    Rebuilding for each environment feels harmless: same code, same steps, so surely the same result. It is not. Base images change. Indirect dependencies resolve differently an hour later. A scan that passed at 14:00 passed an image that no longer exists at 15:00. What you approved and what you shipped are two different things that just look alike.

    THE RULE

    The build creates one image that never changes, identified by its digest. Every environment after that is a deployment, not a build. The digest is like a flight number, and it does not change between the launch pad and orbit.

    Run the sequence. Watch the digest, and notice what stays the same.

    Diagram 5 Launch sequence · one artifact, three environments run it →
    no build yet
    ⏸ Hold — production approval required

    Two checks in that sequence need a closer look, because they are what make the go/no-go call at the start possible.

    The approval is not in the YAML. It is set on the environment by the platform team, and the pipeline author cannot change it — which is the whole point. A check that the person being checked can edit is not a check. The same feature gives you branch control, business-hours windows, and an exclusive lock so two releases cannot deploy to production at the same time.

    The Azure Monitor check is the one most teams never set up, and it turns Application Insights from a dashboard into an active part of the release. It looks at your alert rules and will not let a stage start while that service has alerts firing.

    There is a detail here that catches almost everyone, including many published examples. Every environment check in Azure Pipelines runs before deployment. YAML pipelines have no after-deployment check; that existed in classic release pipelines and did not survive the move. So an Azure Monitor check on production does not watch the deployment you just did. It stops you from deploying onto a service that is already unhealthy, which is useful, but different.

    To check health after a rollout, add a second stage that points to its own environment and depends on the deploy stage. That stage's before-deployment check then runs after the rollout has finished and had some time to settle. That is after-deploy checking, built from the only tool the platform gives you. It is a small trick, and it is the difference between "the pods started" and "the service is still healthy".

    Decision 03

    The deployable unit is a pair: image digest plus config snapshot.

    Decision
    Every release records the pair (image@sha256:…, appconfig-snapshot-id). The digest is what runs; the snapshot is a permanent record of the configuration that ran with it. Rolling back means restoring both: the digest by redeploying it, and the configuration by restoring the store to that snapshot's point in time.
    Why
    Rolling back the code but leaving the configuration on the new version (or the other way round) creates a combination that was never tested anywhere. Most stories of "the rollback made it worse" are exactly this.
    Important nuance
    Pods read the live values with sentinel refresh, not the pinned snapshot. If they read the snapshot directly, it would be frozen and refresh could never work, which would defeat the whole operational layer. The snapshot is the record, not the source. Choosing otherwise can be justified, but you cannot have both.
    Cost
    Snapshots pile up and use quota, so archive them on a schedule. And the 30-day revision history is a real limit: it is for rollback, not for long-term storage.

    The thing that does not roll back

    Everything above assumes rollback is possible. For Pillar it often is not, and any design for a service that owns its database has to say this clearly.

    Once a database migration has run, "restore the previous digest" brings back code that may not understand the new schema. The pair from Decision 03 becomes a triple: image, config and schema version. And the third one is the only one that will not go backwards. A dropped column does not come back just because you redeployed yesterday's container.

    THE ONLY SAFE SHAPE

    Expand, migrate, contract, and never in one release. Add the new column as nullable and deploy code that writes to both. Fill in the old data. Deploy code that reads the new column. Only in a later release, once you are sure you will not roll back before that point, drop the old column.

    Each step can be undone on its own. The release that removes your ability to roll back happens on a calm afternoon, on purpose, and not on the same day as the feature.

    In practice: migrations run as a separate step before the rollout, they only move forward and only add things within a release, and the pipeline records the schema version along with the digest and the snapshot. And Pillar's approval should be honest about what it approves. It is not "ship this code" but "accept that from now on, rollback means restoring from backup, not redeploying".

    Under the hood · the deploy stage, and the gates that live outside it
    # keystone-pipelines/stages/deploy.yml
    parameters:
      - { name: env, type: string }
      - { name: serviceName, type: string }
      # passed in explicitly — variables do NOT flow across stages implicitly.
      # $(imageDigest) set in the build stage renders as empty string here.
      - { name: imageDigest, type: string }
    
    stages:
    - stage: deploy_${{ parameters.env }}
      # sequential: two releases never touch the same environment at once
      lockBehavior: sequential
      jobs:
      - deployment: deploy
        # ↓ PER SERVICE, not one shared environment. Environment checks apply to
        #   every pipeline targeting that environment — one shared keystone-prod
        #   would force Facade's daily deploys through Pillar's 2-approver gate.
        #   The ".keystone-${env}" suffix binds the Kubernetes resource.
        environment: keystone-${{ parameters.env }}-${{ parameters.serviceName }}.keystone-${{ parameters.env }}
        strategy:
          runOnce:
            deploy:
              steps:
              # the rendered ConfigMap comes from the CONFIG pipeline, not this one
              - download: config
                artifact: rendered-config
    
              - task: KubernetesManifest@1
                inputs:
                  action: deploy
                  namespace: keystone-${{ parameters.env }}
                  manifests: |
                    $(Pipeline.Workspace)/config/rendered-config/${{ parameters.serviceName }}/*.yaml
                    $(Pipeline.Workspace)/manifests/*.yaml
                  # digest, never a tag — a tag can be moved, a digest cannot
                  containers: acrkeystone.azurecr.io/${{ parameters.serviceName }}@${{ parameters.imageDigest }}
    
              - task: AzureCLI@2
                displayName: Annotate the release in App Insights
                inputs:
                  azureSubscription: sc-keystone-wif   # workload identity federation
                  scriptType: bash
                  inlineScript: |
                    # puts a marker on every latency chart at this exact moment
                    az monitor app-insights component update ...
    
    # a SEPARATE stage on the same environment, so its pre-deployment
    # Azure Monitor check evaluates AFTER the rollout above has landed.
    # This is the only way to get post-deploy verification from a check.
    - stage: verify_${{ parameters.env }}
      dependsOn: deploy_${{ parameters.env }}
      jobs:
      - deployment: verify
        environment: keystone-${{ parameters.env }}-${{ parameters.serviceName }}-verify
        strategy:
          runOnce:
            deploy:
              steps:
              - script: echo "Alert rules were quiet for the soak window."

    Checks set on the keystone-prod environment, in the UI, by the platform team:

    CheckConfigurationStops
    Approval2 reviewers from Platform; the person who asked cannot approveShipping unreviewed
    Branch controlrefs/heads/main only, protection requiredShipping from a feature branch
    Business hours09:00–16:00 Mon–ThuFriday-evening heroics
    Required templateMust extend service-pipeline.ymlSkipping the security scan
    Exclusive lockOn; ordering set by lockBehavior in YAMLTwo releases colliding
    Azure MonitorNo firing alerts on the service's App Insights rulesDeploying onto an already-unhealthy service

    Notice that none of these appear in any YAML file a service developer can edit. That separation — pipeline logic in code, deployment permission in the platform — is what turns automation into governance. (The one exception is the exclusive lock, where the check turns locking on but the runLatest or sequential behaviour is chosen in YAML.)

    Risk tiers, and how not to let a service grade its own homework

    Because checks are tied to environments, not pipelines, one shared keystone-prod would apply Pillar's two-approver check to Facade's daily deploys. So each service gets its own environment, keystone-prod-facade and keystone-prod-pillar, set up in advance by the platform team with the checks that level of risk needs. The riskTier parameter in a service pipeline chooses which environment it goes to; it does not decide what that environment demands. A team can change its tier and land in a stricter room, but it cannot weaken the room it is standing in.

    The break-glass path

    A business-hours check from 09:00 to 16:00 is perfect for planned work and useless at 02:14, so the design needs a written-down way through. Give the on-call team a separate emergency environment with a single-approver check and no business-hours limit, which can only be reached by a pipeline run with a clear reason entered, and send an alert every time it is used. A set of checks with no emergency path does not get respected. It gets bypassed by someone with permissions nobody remembered they had.

    Act V · 02:14

    Abort

    A configuration value is wrong in production. Not disastrously wrong — a retry count set to 0 instead of 3 — but Gatehouse now fails every short partner timeout instead of retrying it, and the error rate is climbing past the alert limit.

    Everything in the last four acts was practice for the next four minutes. And now the only question that matters is the one we designed for in Act II: how quickly can this be put right?

    Same incident, three designs. Start the clock.

    Diagram 6 Rollback race · time to recovery start the clock →
    A · Runtime config service—
    B · Baked at deploy time—
    C · App Configuration + snapshot—
    elapsed00:00
    extra failed requests vs. snapshot restore—

    The interesting result is that the two self-managed options end up at about the same time. The built-in model takes eleven minutes, because configuration has to go through a build and a rolling deployment to reach a pod: safe, fully audited, and slow. The runtime service could in theory be instant, yet takes thirteen, because most of the time goes into finding the problem, not delivering the fix. Without a revision history, "put it back to how it was at 20:00" is not something you can do. You have to work out which values changed and reverse them by hand, correctly, at 02:14.

    This is the part worth remembering. Everyone compares how fast a system can apply a configuration change. What matters at 02:14 is how fast it can undo one, and the two have nothing to do with each other.

    What made model C fast

    Not the technology, but the point-in-time restore. App Configuration keeps a history of every value (30 days on Standard and Premium), and az appconfig kv restore --datetime puts the store back to how it looked at a chosen moment. The fix was not "find which value is wrong and edit it under pressure at 02:14". It was "put it back to 20:00 yesterday": one command, no investigation. Being able to go back to a known good state before understanding what broke is the most valuable thing an operational system can offer.

    Note what this is not. A snapshot cannot be restored over the store. Snapshots support create, archive and recover, where "recover" only means un-archiving the snapshot itself. Snapshots are the permanent record you link a release to and show an auditor; revision history is the lever you pull at 02:14. Mixing up the two is a common and costly mistake.

    The morning after: the drift you just created

    That emergency restore was done directly on the store, so now the store and keystone-config disagree. The next time anyone merges to that repo, the publish pipeline will overwrite the fix and bring the incident back, at some random later time, with nobody watching.

    This is the standard failure of every "Git is the source of truth, copied to a managed store" design, and it is better to build for it than to rely on memory. Two cheap mechanisms: a CI job that compares the store with the repo on a schedule and alerts if they differ, and a strict rule that an emergency change is not finished until the pull request copying it back into the repo is merged. The incident is closed when the repo matches production, not when the graph looks fine again.

    The bit no architecture gives you

    All three designs assume someone knew within a minute that something was wrong. That did not come from the configuration design. It came from an alert rule on an Application Insights metric, connected to an on-call team, plus a release marker on the response-time chart that made the cause obvious the moment someone looked.

    Build the fastest rollback in the world and it is worth nothing if it takes forty minutes for anyone to notice the problem. Set up monitoring first. The pipeline can only be as good as the signal that tells it something went wrong.

    Under the hood · tracing four services as one request

    One workspace, four service names, and the trace ID travelling across every hop. Distributed tracing is the difference between "the system is slow" and "Gatehouse's call to the payments partner is slow, and here is the exact request":

    // identical in all four services; only the role name differs
    builder.Services.AddApplicationInsightsTelemetry();
    builder.Services.AddSingleton<ITelemetryInitializer>(
        new CloudRoleNameInitializer(builder.Configuration["Telemetry:CloudRoleName"]));
    
    // the release annotation: makes "which deploy caused this?" a one-glance question
    builder.Services.Configure<TelemetryConfiguration>(c =>
        c.TelemetryInitializers.Add(new VersionInitializer(
            Environment.GetEnvironmentVariable("IMAGE_DIGEST"))));

    The alert rules that block deployments, and that woke someone up at 02:14:

    SignalThresholdWired to
    Failed request rate> 2% over 5 min, per rolePager + the Azure Monitor deployment check
    Response time of the slowest 5% of requests> 2× the usual value over the last 7 daysPager
    Dependency failures> 5% to any single partnerGatehouse team channel
    Config refresh failuresAny, sustained 10 minPlatform team — the store is unreachable
    Config driftStore differs from keystone-config mainPlatform team — an emergency edit was never back-ported

    That last row is the one people forget. If pods stop refreshing configuration, nothing breaks straight away. They just fall behind, until the day someone changes a value and half the pods never get it.

    Credits · Flight readiness review

    The whole design, on one page

    Six repositories, because things that change at different speeds and break at different scales should not share a release. One shared pipeline template that services extend instead of copy, enforced by a required-template check so the security scan cannot be skipped. One build per commit, promoted by digest through three environments, controlled by approvals the pipeline author cannot edit. And configuration split three ways by two questions — how quickly might I need to change this, and what happens if it is wrong — plus one rule above both: if it contains a credential, it is a secret, wherever else it might have belonged.

    THE THROUGH-LINE

    Every decision in this design is really the same decision in different clothes: keep apart the things that change at different speeds.

    Infrastructure from application. Structural config from operational config. Secrets from both. Pipeline logic from deployment permission. Build from deploy. Get that separation right and the go/no-go call becomes boring, which is the best compliment a release process can get.

    The decisions, collected

    #DecisionThe reason in one line
    01Keystone is a repo and a pipeline, not a podDo not make every service's uptime depend on one service at cold start
    02Six repositories, not fourTerraform and shared templates have their own schedule, their own risk and their own reviewers
    03Deployable unit is (digest, snapshot)Rolling back half a release creates a combination nobody tested
    04Config classified by how quickly it may need to changeChanges during an incident must not need a deploy; things that must be repeatable must never change
    05Gates live on environments, not in YAMLA check that the person being checked can edit is not a check
    06Federated identity everywhereRemoves a whole category of incidents instead of managing it

    If you're starting on Monday

    #Do thisNot this
    1Write the classification rule down before the first setting existsDeciding per-setting, forever, by argument
    2One shared template repo, tagged, from day oneCopy-paste the first pipeline three times
    3Build once; promote by digestA build stage per environment
    4Workload identity federation before the first secret existsA client secret in a variable group "for now"
    5Path filters and layered config folders on the config repoOne flat folder that rebuilds everything
    6Alerts and release annotations before the first production deployDiscovering you have no signal during the first incident
    7Practise a rollback deliberately, before you need oneFinding out the snapshot restore was never tested
    The honest caveat

    This design is sized for four services, three environments and one team that owns the platform. At twelve services, think hard about whether one pipeline per service still works, or whether you are ready for a pull-based GitOps controller inside the cluster. For one service, most of this is unnecessary ceremony; a single repo and a single pipeline will serve you better, and you should not feel bad about that. Architecture is an answer to a specific scale, and applying a design to a problem of a different size is how most architecture goes wrong.

    Colophon

    Every diagram on this page is a live simulation, not a screenshot. The timings in Diagram 6 are examples, based on realistic build, rollout, refresh and investigation times, not measured from a real system. What is described here is Azure DevOps and Azure App Configuration as of August 2026: snapshots cannot be changed and support only create, archive and recover (recover un-archives the snapshot; it does not put the store back; that is what the 30-day revision history and az appconfig kv restore are for); App Configuration access control works on the whole store, not on individual keys or labels; all environment checks run before deployment and are set by resource owners, not in YAML; and workload identity federation for Azure service connections is generally available. This article was checked against those docs after writing, and a dozen statements were corrected, including, embarrassingly, its own first description of snapshot rollback. The service names Keystone, Pillar, Gatehouse and Facade belong to this article, not to any product.

    ← Back to all stories
    ––

    Discussion