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.
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.
| Service | Responsibility | Why it's separate |
|---|---|---|
| Keystone | Configuration for the whole ecosystem | The stone at the apex of an arch. Every other stone leans on it; remove it and the arch is rubble. |
| Pillar | Domain service — the business logic | Load-bearing. Owns the model, the rules, the database. Changes deliberately. |
| Gatehouse | Integration gateway — third parties | The fortified entrance. Everything foreign comes through here and is made safe before it goes further. |
| Facade | Backend-for-frontend | The 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.
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.
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.
| Service | Deploys | If it's wrong | Gate |
|---|---|---|---|
| Facade | Several times a day | A screen looks odd. Reversible in minutes. | Automated only |
| Gatehouse | Weekly | A partner integration breaks. Recoverable, embarrassing. | Automated + contract tests |
| Pillar | Weekly, carefully | Wrong data written. Possibly unrecoverable. | Human approval |
| Keystone | Hourly, in theory | All 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.
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.
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.
| Model | What it means | Config change takes |
|---|---|---|
| A · Runtime service | Keystone runs as a pod. The other three call it at startup and refresh periodically. | Seconds |
| B · Baked at deploy | The Keystone repo publishes versioned artifacts; pipelines render them into ConfigMaps and deploy them with the image. | A full deploy |
| C · Managed store | The 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.
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.
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.
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.Here is the rule that makes the two-mechanism design livable, and it is the single most useful idea in this article:
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.
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.
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.
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.
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.
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.
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:
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.
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.
keystone-config, pillar-domain, gatehouse-integration,
facade-bff, plus keystone-platform (Terraform) and
keystone-pipelines (shared YAML templates, tagged and versioned).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.
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.
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.
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.
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:
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:
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.
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.
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.
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.
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."
(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.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.
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."
# 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:
| Check | Configuration | Stops |
|---|---|---|
| Approval | 2 reviewers from Platform; requester may not approve | Shipping unreviewed |
| Branch control | refs/heads/main only, protection required | Shipping from a feature branch |
| Business hours | 09:00–16:00 Mon–Thu | Friday-evening heroics |
| Required template | Must extend service-pipeline.yml | Skipping the security scan |
| Exclusive lock | On; ordering set by lockBehavior in YAML | Two releases colliding |
| Azure Monitor | No firing alerts on the service's App Insights rules | Deploying 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.)
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.
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.
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.
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.
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.
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.
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.
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:
| Signal | Threshold | Wired to |
|---|---|---|
| Failed request rate | > 2% over 5 min, per role | Pager + the Azure Monitor deployment check |
| p95 server duration | > 2× the 7-day baseline | Pager |
| Dependency failures | > 5% to any single partner | Gatehouse team channel |
| Config refresh failures | Any, sustained 10 min | Platform team — the store is unreachable |
| Config drift | Store differs from keystone-config main | Platform 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.
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.
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.
| # | Decision | The reason in one line |
|---|---|---|
| 01 | Keystone is a repo and a pipeline, not a pod | Don't make everyone's availability depend on one service at cold start |
| 02 | Six repositories, not four | Terraform and shared templates have their own cadence, blast radius and reviewers |
| 03 | Deployable unit is (digest, snapshot) | Rolling back half a release produces a combination nobody tested |
| 04 | Config classified by change speed | Incident-time changes must not require a deploy; reproducible things must be immutable |
| 05 | Gates live on environments, not in YAML | A gate the gated person can edit is not a gate |
| 06 | Federated identity everywhere | Removes an entire category of incident rather than managing it |
| # | Do this | Not this |
|---|---|---|
| 1 | Write the classification rule down before the first setting exists | Deciding per-setting, forever, by argument |
| 2 | One shared template repo, tagged, from day one | Copy-paste the first pipeline three times |
| 3 | Build once; promote by digest | A build stage per environment |
| 4 | Workload identity federation before the first secret exists | A client secret in a variable group "for now" |
| 5 | Path filters and layered config folders on the config repo | One flat folder that rebuilds everything |
| 6 | Alerts and release annotations before the first production deploy | Discovering you have no signal during the first incident |
| 7 | Practise a rollback deliberately, before you need one | Finding out the snapshot restore was never tested |
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.
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.