Every organization on Azure DevOps eventually has the same pipeline archaeology problem: forty repositories, each with a YAML file copy pasted from the one before it, drifting independently. Multi stage YAML done well solves deployment governance and repeatability in one design. Done badly it is classic pipelines with extra indentation. The difference is templates, environments, and how identity reaches Azure.
The Shape of a Good Pipeline
trigger:
branches:
include: [main]
resources:
repositories:
- repository: templates
type: git
name: platform/pipeline-templates
ref: refs/tags/v2.3.0
stages:
- stage: build
jobs:
- template: jobs/dotnet-build.yml@templates
parameters:
projectPath: src/Orders.Api
- stage: deploy_test
dependsOn: build
jobs:
- template: jobs/deploy-containerapp.yml@templates
parameters:
environment: orders-test
appName: orders-api
- stage: deploy_prod
dependsOn: deploy_test
jobs:
- template: jobs/deploy-containerapp.yml@templates
parameters:
environment: orders-prod
appName: orders-api
Everything real lives in a versioned central template repository, pinned by tag so template upgrades are deliberate, reviewable events instead of surprises across forty pipelines. Application pipelines shrink to parameters. When the platform team fixes the build template, teams adopt it by bumping one tag.
Environments: Where Governance Lives
Deployment jobs target environments, and environments carry checks that run before any stage touching them: manual approvals from named groups, business hours windows, Azure Monitor alert gates that block deployment while the service is unhealthy, and required template checks that enforce every deployment to production flows through the sanctioned template. This is the crucial inversion: governance attaches to the environment once, centrally, instead of being re-implemented in every pipeline. A developer can write whatever YAML they like; production still requires the approval, the health gate, and the blessed template.
jobs:
- deployment: deploy
environment: orders-prod
strategy:
runOnce:
deploy:
steps:
- task: AzureCLI@2
inputs:
azureSubscription: sc-prod-oidc
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az containerapp update \
--name $(appName) \
--resource-group rg-apps-prod \
--image $(acr)/$(appName):$(Build.BuildNumber)
Service Connections Without Secrets
Use workload identity federation for every Azure service connection. The connection maps to an Entra application with a federated credential trusting Azure DevOps as the issuer; tokens are exchanged at runtime and there is no client secret to expire on a Saturday or leak in a variable group. Scope connections tightly: one per environment per team, with the minimum RBAC role at the narrowest scope, never one god connection with Owner on the subscription. Pair that with variable groups linked to Key Vault for the application settings that genuinely are secrets, and your pipelines hold no credentials at all.
Habits That Keep It Healthy
Build once, deploy the same artifact everywhere; if your production stage rebuilds, you are testing one binary and shipping another. Keep stage conditions honest with dependsOn plus succeeded rather than clever custom conditions nobody can predict. Alert on pipeline duration trends, because a build that quietly grew from six minutes to twenty is a productivity tax everyone pays daily. And run your agents as scale set agents in your own VNet when pipelines need to reach private endpoints, which after this month of posts, everything should.
Tomorrow: the GitHub Actions equivalent, OIDC to Azure with no secrets anywhere.
Cheers
Osama
Leave a comment