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 walkthrough of the launch sequence — and an honest argument about where configuration should actually 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 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 the words "as long as nothing else changed." At 15:00 the release goes, and at 15:04 four services are running a version of themselves that did not exist this morning.

    A room that can say go without flinching is not a room with better people in it. It is a room where somebody, months earlier, made a series of unglamorous architectural decisions about repositories, artifacts, gates and configuration — and where every one of those decisions was made so that on release day there is simply less to be afraid of.

    This article builds that room. We're designing Project Keystone: a four-service ecosystem on AKS, delivered by Azure DevOps, provisioned by Terraform, with Key Vault holding the secrets and Application Insights watching the patient. We'll design it the way an architect actually does — brief first, then the decisions the brief didn't mention, then the one decision everybody gets wrong.

    The brief

    ServiceResponsibilityWhy it's separate
    KeystoneConfiguration for the whole ecosystemThe stone at the apex of an arch. Every other stone leans on it; remove it and the arch is rubble.
    PillarDomain service — the business logicLoad-bearing. Owns the model, the rules, the database. Changes deliberately.
    GatehouseIntegration gateway — third partiesThe fortified entrance. Everything foreign comes through here and is made safe before it goes further.
    FacadeBackend-for-frontendThe visible face. Shaped entirely by what the UI needs, and changes as often as the UI does.

    Four services, four repositories. That is the brief as handed over, and it is a perfectly reasonable brief. It is also, as we'll see in Act III, two repositories short — and the two that are missing are the ones that decide whether this thing is still maintainable in eighteen months.

    How to read this

    The main text is the design conversation — plain English, no prerequisites. Every Under the hood panel opens onto the real YAML, HCL and C#. The Decision blocks are architecture decision records: the call, the alternatives, and what it costs. Read only the decisions and you still get the design.

    Act I · The vehicle

    What we are actually launching

    Before you can design a pipeline you have to be honest about the shape of the thing it delivers. Four services is not one system deployed four times; it's four systems with four different change rates, four different blast radii, and four different answers to "what happens if this is wrong at 3am."

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

    ServiceDeploysIf it's wrongGate
    FacadeSeveral times a dayA screen looks odd. Reversible in minutes.Automated only
    GatehouseWeeklyA partner integration breaks. Recoverable, embarrassing.Automated + contract tests
    PillarWeekly, carefullyWrong data written. Possibly unrecoverable.Human approval
    KeystoneHourly, in theoryAll three at once.Depends entirely on Act II

    Look at that last row. Configuration has the highest change frequency and the widest blast radius in the entire system. That combination is unusual and it is dangerous, and it is why configuration — not the pipeline, not the cluster — is the real architectural problem here.

    Click through the system. Every box is something you'll provision 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 apex of an arch. It carries almost no weight itself — but every other stone transfers its load through it, and until it is set the arch cannot stand on its own. It is simultaneously the least substantial piece and the one you cannot remove. That is configuration exactly: a few kilobytes of text on which the entire structure silently depends.

    Act II · The fuel problem

    How does configuration actually reach a service?

    The brief says Keystone "serves configuration to the other three." That sentence is doing an enormous amount of hidden work, and how you interpret it determines more about this system's reliability than any other decision you will make. So let's not interpret it. Let's interrogate it.

    There are three honest answers, and the industry has shipped all three at scale. They are not equally good, but none of them is stupid — they trade different things away.

    ModelWhat it meansConfig change takes
    A · Runtime serviceKeystone runs as a pod. The other three call it at startup and refresh periodically.Seconds
    B · Baked at deployThe Keystone repo publishes versioned artifacts; pipelines render them into ConfigMaps and deploy them with the image.A full deploy
    C · Managed storeThe Keystone repo is source of truth, synced into Azure App Configuration. Services read from there.Seconds

    Everyone compares these on the happy path, where they look nearly identical. The only useful comparison is on a bad night. Pick a model, then break it 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
    blast radius

    The failure that decides it

    Work through the "cluster-wide restart" scenario on model A and you find the argument that settles this. It is not a hypothetical — it is an ordinary Tuesday on AKS. A node pool upgrades. A zone blips. Somebody drains a node. Suddenly a large fraction of your pods restart at once.

    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 replicas. It is now receiving every startup request in the cluster, simultaneously, from services that cannot proceed without an answer.

    You have made the availability of every service in the system conditional on one service, at precisely the moment when everything is already degraded.

    Be fair to model A here, because the sloppy version of this argument is wrong. A configuration call that blocks startup should be gated by a readiness probe, not a liveness probe, and a startupProbe exists precisely so that a slow-starting pod isn't killed and restarted into a death spiral. Configure those correctly and the stampede degrades into "everything starts slowly" rather than "nothing starts at all." Anyone who tells you a runtime config service inevitably melts down is describing a probe misconfiguration.

    The real argument is quieter and harder to escape: you now own a tier. Cache last-known-good on disk. Stagger startup. Size replicas for a cold-start thundering herd rather than steady-state reads. Add a PodDisruptionBudget. Make reads non-blocking with sane defaults. Keep all of it working as the system grows. Every one of those is real, permanent engineering — spent replicating what a managed configuration store does on its first day, for a service whose entire job is to return a few kilobytes of text.

    Decision 01

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

    Decision
    There is no Keystone service running in the cluster. keystone-config is the git source of truth; its pipeline validates configuration and publishes it to two destinations — versioned artifacts for deploy-time baking, and Azure App Configuration snapshots for runtime reads.
    Alternatives rejected
    Model A — a runtime config service. Rejected because it converts a static asset into a runtime dependency and creates a correlated failure mode at cold start. Model B alone — baking everything. Rejected because it makes it impossible to change a rate limit during an incident without a deploy.
    Cost of this decision
    Two mechanisms instead of one, so engineers must know which is which. We pay that down with the classification rule below — and by making the wrong choice hard to express in code.
    When we'd revisit
    If configuration ever needs per-tenant resolution with inheritance and computed values — genuine logic rather than lookup — a real service starts to earn its keep. Four services and three environments is nowhere near that line.

    So which mechanism for which setting?

    Here is the rule that makes the two-mechanism design livable, and it is the single most useful idea in this article:

    THE CLASSIFICATION RULE

    Don't ask "where does config live?" Ask two questions about each setting: how fast might I need to change this, and what happens if it's wrong?

    Anything you might need to change during an incident must not require a deploy. Anything that must be reproducible must be immutable 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 in the system belongs to exactly one. Try it — the boundaries are less obvious than they look.

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

    The two that catch almost everybody are the connection string and the log level. A connection string looks structural — it points at infrastructure Terraform provisioned and changes only when that infrastructure changes. But the moment a credential appears anywhere in the value, the whole value is promoted to secret, because a string is only as classifiable as its most sensitive component. The server name is structural; the string containing the password is not. A log level runs the other way: it feels trivial and permanent, but the entire reason it exists is so you can turn it up at 02:00 while staring at a problem. That's operational, and baking it makes it useless at exactly the moment you reach for 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 a literal, not into a pipeline variable marked secret and then echoed into a manifest. App Configuration stores a reference to a Key Vault secret; the pod resolves it at runtime using its own workload identity. If a secret value ever appears in a git diff, a pipeline log or a kubectl get configmap, the design has failed regardless of how carefully 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 per service per environment. The deploy pipeline renders it, hashes it, and names the ConfigMap after the hash — so a config change forces a new ConfigMap and therefore a genuine rollout, rather than silently mutating one in place:

    # 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 reject in-place updates to data, so "somebody edited the ConfigMap at 3am" needs a delete-and-recreate rather than a quiet kubectl edit — visible in an audit log instead of invisible. Not impossible; conspicuous, which is most of the value.

    Two things the hash-naming trick needs to actually work. The Deployment's pod spec must be re-rendered to reference the new ConfigMap name — a new ConfigMap nobody references changes nothing, and this is the step teams forget. And because old ConfigMaps accumulate forever, you need a pruning job that skips any still referenced by a retained ReplicaSet, or you'll eventually prune the thing a rollback needed.

    Operational — Azure App Configuration with refresh

    Read with a sentinel key so the app makes one cheap check rather than watching every setting, and only reloads 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 bump Pillar:Sentinel once. Every pod picks up all ten together within the refresh interval — no partial application, no deploy.

    Secret — Key Vault, referenced

    The pod authenticates as itself via workload identity; no secret is ever written to the cluster. In App Configuration the value is a reference, not a literal:

    # 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 with the pod's managed identity, and hands the application a resolved string it never persists. Rotating the secret in Key Vault changes every consumer 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 on your startup path, so it deserves the same scrutiny we gave model A. Three things make it a materially different risk:

    • Snapshots are immutable. A snapshot is a named, frozen subset of the store. It can be created, archived and recovered — but never edited or deleted. Pin a release to a snapshot and you get exactly the reproducibility of baked config, with a managed service doing the serving.
    • The provider caches. Configuration is loaded once at startup and then only re-checked on the refresh interval. A blip in the store does not take running pods down; it means they keep serving the values they already have.
    • It isn't your uptime. The failure mode is a managed regional service having a bad day, not a two-replica deployment you wrote being stampeded by its own dependents. Those are not the same risk, even when the availability numbers look similar.

    The honest caveat: a pod still needs the store on the startup path unless you say otherwise. Layer the providers so structural values come from the baked ConfigMap first and App Configuration improves on them when reachable — and mark it optional, which is the single argument that turns "the store is down so nothing starts" into "the store is down so we run on 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 registered appsettings.json and environment variables before you touch it — re-adding them moves them to the end of the chain and changes precedence, which is worth knowing before you copy this into an app that relies on the defaults.

    One thing not to claim: an environment variable is not really a break-glass lever here. Changing one means editing the Deployment, which is a rollout — the exact deploy that model B was just criticised for needing.

    Act III · The launch pad

    Six repositories, not four

    The brief said four repositories. The brief was describing the product, not the system, and there are two more things here that change on their own schedule, carry their own blast radius, and need their own reviewers. Giving them a home is not bureaucracy — it is the difference between a platform and a pile.

    Terraform needs a repository. The cluster, the registry, the vault, the configuration store and the Application Insights workspace are not features of any one service. They change weekly rather than hourly, a mistake takes down all four services rather than one, and the person who should approve "resize the node pool" is not the person who should approve "change a button label." Putting infrastructure in a service repo means every routine deploy carries the latent risk of recreating 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 tagging convention and the deployment logic. Fix a vulnerability in the scan step and you fix it four times, badly, at four different times. Worse, a service team can quietly drop the scan step from their own pipeline and nobody notices.

    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 — genuinely defensible, and if the four services shipped together as one unit I would choose it. They don't: Facade ships daily and Pillar weekly, and a monorepo makes that difference harder to express, not easier. Terraform inside each service repo — rejected outright; shared infrastructure with four owners has no owner.
    Cost of this decision
    Cross-repo changes need coordination, and a developer touching config and code together opens two pull requests. We accept that; the alternative couples release cadences that should stay independent.

    Which pipeline runs when you change a file?

    This is where the folder layout stops being cosmetic. Inside keystone-config, configuration is layered — and the layout is the blast-radius model. 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 gated harder.

    Change a file and watch what fires.

    Diagram 4 Change a file, see what builds click a path →
    Pipelines triggered
    blast radius
    gate required
    Pick a file to see the trigger cascade.

    Two things worth noticing. First, a change to global/ fans out to three pipelines and therefore three deploys — which is exactly why it deserves an approval that a service-scoped change does not. Second, editing the README triggers nothing at all. That sounds trivial; on a busy repo, path filters are the difference between a CI system people trust and one they've learned to ignore.

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

    Every trigger in this design fires after a merge, which is the moment a mistake becomes production's problem. The cheapest gate in the whole system is the one that runs before the merge: a build validation policy on each repository so pull requests must build and pass tests; JSON-schema validation of 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 fan out to three production deploys, catching it at review time costs minutes and catches most of what the later gates exist to survive.

    The trap in shared templates

    Referencing a template repository without pinning a version means every service silently rebuilds on whatever main happens to be at that moment. One bad commit to the template repo breaks all four pipelines simultaneously, including the pipeline you'd use to fix it. Pin to a tag — ref: refs/tags/v3.2.0 — and bump it deliberately, service by service.

    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 deliberately tiny. It declares what it is and hands everything else to the template — note extends rather than 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 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 plain template include. An include is a convenience — the calling pipeline can still do whatever else it likes alongside it. An extends template defines the schema of the pipeline: the service repo can only supply the parameters the template accepts. Combine that with 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 property of the system.

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

    The platform repo provisions everything shared. The detail worth copying is the service connection: workload identity federation, so Azure DevOps authenticates to Azure with a short-lived federated token instead of a client secret somebody has to rotate.

    # 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 half is easy to forget and fails silently without it — the ServiceAccount must be annotated with the client id, and the pod labelled to opt in:

    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 rotate at 2am because somebody left. That is not a security nicety — it removes an entire category of incident.

    Two things this snippet still leaves open, because they are decisions rather than oversights. Key Vault and App Configuration are shown with default public network access; in a regulated environment you'd put private endpoints in front of both and pay for the DNS complexity. And the platform pipeline itself needs the discipline the Terraform piece argues for: remote state with locking, plan on the pull request, and apply the saved plan rather than a fresh one.

    Act IV · The countdown

    Build once, fly three times

    Here is the principle that separates a delivery pipeline from a build script, and it is violated constantly: the artifact that reaches production must be the exact artifact you tested. Not an equivalent one. Not one rebuilt from the same commit with the same Dockerfile. The same bytes.

    Rebuilding per environment feels harmless — same source, same steps, surely the same result. It isn't. Base images move. Transitive dependencies resolve differently an hour later. A scanner that passed at 14:00 passes on an image that no longer exists at 15:00. The thing you approved and the thing you shipped are two different artifacts that merely look alike.

    THE RULE

    Build produces one immutable image, identified by its digest. Every environment after that is a deployment, not a build. The digest is the flight number, and it does not change between the pad and orbit.

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

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

    Two gates in that sequence deserve a closer look, because they're the ones that make the go/no-go poll from the cold open possible.

    The approval is not in the YAML. It's configured on the environment by the platform team, and the pipeline author cannot modify it — which is the entire point. A gate that the person being gated can edit is not a gate. The same mechanism gives you branch control, business-hours windows, and an exclusive lock so two releases can't deploy to production concurrently.

    The Azure Monitor check is the one most teams never wire up, and it's the one that turns Application Insights from a dashboard into a participant. It queries your alert rules and refuses to let a stage start while the service is firing alerts.

    There is a detail here that trips up almost everyone, including plenty of published examples. Every environment check in Azure Pipelines is a pre-deployment gate. There is no post-deployment check in YAML pipelines — that existed in classic release pipelines and did not survive the move. So an Azure Monitor check on your production environment does not watch the deployment you just did; it stops you deploying onto a service that is already unhealthy, which is valuable but different.

    To actually verify after a rollout, add a second stage that targets its own environment and depends on the deploy stage. That stage's pre-deployment check then evaluates once the rollout has landed and had time to soak — which is post-deploy verification built out of the only primitive the platform gives you. It's 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 an immutable record of the configuration that ran beside it. Rolling back means restoring both — the digest by redeploying it, the configuration by a point-in-time restore to that snapshot's moment.
    Why
    Rolling back code while leaving configuration forward — or vice versa — produces a combination that has never been tested anywhere. Most "the rollback made it worse" stories are this.
    Important nuance
    Pods read live key-values with sentinel refresh, not the pinned snapshot — if they read the snapshot directly it would be frozen and refresh could never fire, which would defeat the whole operational tier. The snapshot is the record, not the source. Choosing otherwise is defensible, but you cannot have both.
    Cost
    Snapshots accumulate and consume quota, so archive them on a schedule. And the 30-day revision-history window is a real limit: it is a rollback lever, not an archive.

    The thing that does not roll back

    Everything above assumes rollback is possible. For Pillar it frequently isn't, and any design for a domain service that owns its database has to say so out loud.

    Once a migration has run, "restore the previous digest" restores code that may not understand the schema now in front of it. The pair from Decision 03 silently becomes a triple — image, config, and schema version — and the third one is the only one that refuses to go backwards. A dropped column does not come back because you redeployed yesterday's container.

    THE ONLY SAFE SHAPE

    Expand, migrate, contract — and never in one release. Add the new column nullable and deploy code that writes both. Backfill. Deploy code that reads the new one. Only in a later release, once you are certain you will not roll back past it, drop the old column.

    Every step is independently reversible. The release that removes your ability to roll back happens on a calm afternoon, deliberately, and not on the same day as the feature.

    Practically: migrations run as a separate step before the rollout, they are forward-only and additive within a release, and the pipeline records the schema version alongside the digest and the snapshot. And the deployment gate for Pillar should be honest about what it is really approving — not "ship this code" but "accept that from this point, rollback is a restore from backup rather than a redeploy."

    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 configured on the keystone-prod environment, in the UI, by the platform team:

    CheckConfigurationStops
    Approval2 reviewers from Platform; requester may not 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 makes this governable rather than merely automated. (The one honest exception is the exclusive lock, where the check turns locking on but the runLatest-versus-sequential behaviour is chosen in YAML.)

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

    Because checks bind to environments rather than to pipelines, a single shared keystone-prod would apply Pillar's two-approver gate to Facade's daily deploys. So each service gets its own environment — keystone-prod-facade, keystone-prod-pillar — pre-configured by the platform team with the checks that tier deserves. The riskTier parameter in a service pipeline selects which environment it lands on; it does not define what that environment demands. A team can change their tier and find themselves in a stricter room, but they cannot weaken the room they are standing in.

    The break-glass path

    A business-hours check that runs 09:00–16:00 is exactly right for planned work and exactly wrong at 02:14, so the design needs a documented way through. Give the on-call rota a dedicated emergency environment with a single-approver check and no business-hours restriction, reachable only from a pipeline run with an explicit reason parameter — and alert on every use. A gate system with no emergency path doesn't 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 catastrophically wrong — a retry budget set to 0 instead of 3 — but Gatehouse is now failing every transient partner timeout instead of retrying it, and the error rate is climbing through the alert threshold.

    Everything about the previous four acts was rehearsal for the next four minutes. And the only question that matters now is the one we designed for in Act II: how fast can this be made right?

    Same incident, three architectures. 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 land in roughly the same band. The baked model spends eleven minutes because configuration has to travel through a build and a rolling deployment to reach a pod — safe, fully audited, and slow. The runtime service is theoretically instant and takes thirteen, because the clock is dominated by diagnosis, not by delivery: without a revision history, "put it back to how it was at 20:00" is not an operation you can perform. You have to work out which values changed and reverse them by hand, correctly, at 02:14.

    That is the part worth internalising. The speed at which a system can apply a configuration change is the number everyone compares. The speed at which it can undo one is the number that matters at 02:14, and they are not related.

    What actually made model C fast

    Not the technology — the point-in-time restore. App Configuration keeps a revision history of every key-value (30 days on Standard and Premium), and az appconfig kv restore --datetime reverts the store to how it looked at a chosen moment. Recovery was not "work out which value is wrong and edit it under pressure at 02:14." It was "put it back to 20:00 yesterday" — one command, no diagnosis. Being able to return to a known-good state before understanding what broke is the single most valuable property an operational system can have.

    Note what this is not. A snapshot cannot be restored over the store — snapshots support create, archive and recover, where "recover" means un-archiving the snapshot itself. Snapshots are the immutable record you pin a release to and hand an auditor; revision history is the lever you pull at 02:14. Conflating the two is a common and expensive misreading of the feature.

    The morning after: the drift you just created

    That emergency restore was made directly against the store, which means the store and keystone-config now disagree. The next time anybody merges to that repository, the publish pipeline will faithfully overwrite the fix and reopen the incident — at some unpredictable later moment, with nobody watching.

    This is the standard failure of every "git is the source of truth, synced to a managed store" design, and it is worth building for rather than remembering. Two mechanisms, both cheap: a CI job that diffs the store against the repository on a schedule and alerts on divergence, and a hard rule that an emergency change is not finished until the back-port pull request is merged. The incident is closed when the repository agrees with production, not when the graph recovers.

    The bit no architecture gives you

    All three models assume somebody 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, wired to a rota, plus a release annotation on the latency chart that made the cause obvious the moment somebody looked at it.

    Build the fastest rollback in the world and it is worth nothing if the mean time to noticing is forty minutes. Instrument 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 cloud role names, and the trace context flowing 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 gate the deployment, and that woke somebody at 02:14:

    SignalThresholdWired to
    Failed request rate> 2% over 5 min, per rolePager + the Azure Monitor deployment check
    p95 server duration> 2× the 7-day baselinePager
    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 silently stop refreshing configuration, nothing breaks immediately — they just quietly drift out of date until the day somebody changes a value and half the fleet doesn't get it.

    Credits · Flight readiness review

    The whole design, on one page

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

    THE THROUGH-LINE

    Every decision in this design is the same decision wearing different clothes: separate the things that change at different rates.

    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 poll is boring — which is the highest compliment a release process can be paid.

    The decisions, collected

    #DecisionThe reason in one line
    01Keystone is a repo and a pipeline, not a podDon't make everyone's availability depend on one service at cold start
    02Six repositories, not fourTerraform and shared templates have their own cadence, blast radius and reviewers
    03Deployable unit is (digest, snapshot)Rolling back half a release produces a combination nobody tested
    04Config classified by change speedIncident-time changes must not require a deploy; reproducible things must be immutable
    05Gates live on environments, not in YAMLA gate the gated person can edit is not a gate
    06Federated identity everywhereRemoves an entire category of incident rather than 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 you'll want to look hard at whether pipeline-per-service still scales or whether you're ready for a pull-based GitOps controller in the cluster. At one service, most of this is ceremony — a single repo and a single pipeline would serve you better and you should not feel bad about that. Architecture is a response to a specific scale, and quoting a design back at a problem it wasn't sized for is how most of it goes wrong.

    Colophon

    Every diagram on this page is a live simulation, not a screenshot. Diagram 6's timings are illustrative — modelled on realistic build, rollout, refresh and diagnosis durations, not measured from a specific system. Behaviour described reflects Azure DevOps and Azure App Configuration as of August 2026: snapshots are immutable and support create, archive and recover only (recovering un-archives the snapshot; it does not revert the store — that is what the 30-day revision history and az appconfig kv restore are for); App Configuration data-plane RBAC is store-scoped with no key or label granularity; all environment checks are evaluated as pre-deployment gates and are configured by resource owners rather than in YAML; workload identity federation for Azure service connections is generally available. This piece was reviewed against those docs after drafting and a dozen claims were corrected — including, embarrassingly, its own original description of snapshot rollback. The service names — Keystone, Pillar, Gatehouse, Facade — are this article's, not a product's.

    ← Back to all stories