Azure DevOps in 2022: Lock Down Your YAML Pipelines First
Most Azure DevOps organisations I look at moved to YAML pipelines in 2020 or 2021 and stopped there. The pipeline builds, it deploys, and anyone who can edit azure-pipelines.yml can push anything to production through a service connection that has Contributor on a subscription. The feature work Microsoft shipped through 2021 was mostly about closing that gap, and it is the part of Azure DevOps I’d put first on the list for 2022.
This post isn’t a YAML tutorial. If you need the basics, start with Mastering Azure DevOps YAML Pipelines and Azure Pipelines Templates for Reusable CI/CD. This is about what to switch on, in what order, and where I’d hold back.
The problem: YAML moved the control into the repository
Classic release pipelines kept deployment logic in the portal, behind release permissions. YAML moved it into the repository, which is the right call for versioning and review, but it changes who can deploy. If a branch policy is weak, or someone can queue a pipeline from their own branch, the YAML on that branch is what runs, and it can reference any service connection or variable group the pipeline is authorised to use.
So the question for 2022 isn’t “which new task should we use?” It’s “what stops a modified pipeline file from reaching production?” Azure Pipelines answers that by putting controls on the resources (environments, service connections, agent pools, variable groups, secure files and repositories) rather than on the pipeline definition. The YAML asks for a resource, and the resource decides whether to allow it.
Environments and checks: the control point that matters
An environment is the target a deployment job declares. On its own it gives you deployment history. Its real value is the approvals and checks you attach to it, which run before any job targeting it starts and which live outside the YAML, so a pull request can’t remove them.
The checks I use, and what each is for:
| Check | What it enforces | When I’d use it |
|---|---|---|
| Approvals | Named users or groups must approve the stage | Production, and anything customer-facing |
| Branch control | The run must come from allowed branches, optionally with branch protection on | Every non-dev environment |
| Required template | The pipeline must extends from a specific template in a specific repo and ref | Production service connections |
| Business hours | Deployments only start in a time window | Teams with a change freeze or an on-call constraint |
| Exclusive lock | Only one run deploys to the resource at a time | Shared environments and stateful deployments |
| Invoke Azure Function / REST API, Query Azure Monitor alerts | An external gate passes before the stage runs | Health and change-ticket gates |
Branch control is the cheapest win. Without it, a feature branch run can deploy to production if someone approves without reading the run details closely. With it, the check fails before an approver ever sees the request.
Exclusive lock originally kept only the latest waiting run and cancelled the others. In August 2021 Microsoft added a sequential option, so queued runs deploy one after another instead (Sprint 190 release notes). You choose this with lockBehavior: sequential or lockBehavior: runLatest at pipeline or stage level in the YAML, so it is not a control in itself; the lock check on the environment is. I’d pick “latest only” for application deployments, where the newest build is the one you want, and sequential for database migrations, where skipping an intermediate run can skip a schema change.
Here is a pipeline that deploys through environments. It contains nothing about approvals, and that is the point: the checks are configured on the contoso-web-prod environment in the portal.
trigger:
branches:
include:
- main
pool:
vmImage: 'ubuntu-20.04'
variables:
buildConfiguration: 'Release'
stages:
- stage: Build
jobs:
- job: Build
steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '6.0.x'
# restore, build and test steps omitted for brevity; gate on tests before this in real pipelines
- script: dotnet publish src/WebApp/WebApp.csproj --configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)/app
displayName: 'Publish web app'
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: '$(Build.ArtifactStagingDirectory)/app'
includeRootFolder: false
archiveType: 'zip'
archiveFile: '$(Build.ArtifactStagingDirectory)/webapp.zip'
- publish: '$(Build.ArtifactStagingDirectory)/webapp.zip'
artifact: 'drop'
- stage: DeployProd
dependsOn: Build
lockBehavior: sequential
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployWeb
environment: 'contoso-web-prod'
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: '<your-prod-service-connection>'
appType: 'webApp'
appName: '<your-web-app-name>'
package: '$(Pipeline.Workspace)/drop/webapp.zip'
The condition on the stage is a convenience, not a control. Anyone can delete it in a branch. The branch control check on the environment and the service connection is what actually enforces it. The same goes for lockBehavior: it only matters if an exclusive lock check is on the environment.
Required templates: make the safe path the only path
Checks on an environment cover deployment jobs. They don’t stop someone from writing a plain job with an AzureCLI@2 step that uses the production service connection directly. The fix is to put the checks on the service connection too, and the strongest one there is the required template check.
The idea is that a platform team owns a template repository. The template accepts the application’s steps as a parameter, wraps them in whatever is mandatory (scanning, an approved agent pool, no inline scripts against production) and the service connection only allows runs that extends from it. Microsoft’s guidance on template security describes the pattern.
# pipeline-templates/secure-deploy.yml (in the platform team's repository)
parameters:
- name: buildSteps
type: stepList
default: []
- name: appName
type: string
stages:
- stage: Build
jobs:
- job: Build
pool:
vmImage: 'ubuntu-20.04'
steps:
# Allow-list: only these tasks may run. Anything else, including script steps, fails the run.
- ${{ each step in parameters.buildSteps }}:
- ${{ if in(step.task, 'UseDotNet@2', 'DotNetCoreCLI@2', 'PublishTestResults@2') }}:
- ${{ step }}
- ${{ if not(in(step.task, 'UseDotNet@2', 'DotNetCoreCLI@2', 'PublishTestResults@2')) }}:
- script: |
echo "##vso[task.logissue type=error]Step '${{ step.displayName }}' is not on the allowed task list"
exit 1
displayName: 'Blocked step'
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: '$(Build.ArtifactStagingDirectory)/app'
includeRootFolder: false
archiveType: 'zip'
archiveFile: '$(Build.ArtifactStagingDirectory)/webapp.zip'
- publish: '$(Build.ArtifactStagingDirectory)/webapp.zip'
artifact: 'drop'
# Owned by the template: callers cannot change the environment, connection or steps here.
- stage: Deploy
dependsOn: Build
lockBehavior: sequential
jobs:
- deployment: DeployWeb
pool:
vmImage: 'ubuntu-20.04'
environment: 'contoso-web-prod'
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: '<your-prod-service-connection>'
appType: 'webApp'
appName: '${{ parameters.appName }}'
package: '$(Pipeline.Workspace)/drop/webapp.zip'
# azure-pipelines.yml (in the application repository)
trigger:
branches:
include:
- main
resources:
repositories:
- repository: templates
type: git
name: '<your-project>/pipeline-templates'
ref: refs/heads/main
extends:
template: secure-deploy.yml@templates
parameters:
appName: '<your-web-app-name>'
buildSteps:
- task: UseDotNet@2
displayName: 'Install .NET 6 SDK'
inputs:
packageType: 'sdk'
version: '6.0.x'
- task: DotNetCoreCLI@2
displayName: 'Test'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: '--configuration Release'
- task: DotNetCoreCLI@2
displayName: 'Publish'
inputs:
command: 'publish'
publishWebProjects: false
projects: 'src/WebApp/WebApp.csproj'
arguments: '--configuration Release --output $(Build.ArtifactStagingDirectory)/app'
zipAfterPublish: false
The production service connection only appears inside the template’s Deploy stage, so the application team can change what gets built but not where or how it is deployed. I use an allow-list rather than a block-list on purpose. Blocking AzureCLI* tasks sounds sensible, but it is easy to get around with AzurePowerShell@5, AzureResourceManagerTemplateDeployment@3 or a plain script step. Permitting only known task names closes all of those at once. Microsoft’s template security doc shows the same idea by iterating each pair in step and rewriting any step that uses a forbidden task.
To enforce it, open the service connection, go to Approvals and checks, add Required template, and name the repository, the ref and the template path (secure-deploy.yml). The ref in the check has to match the ref: refs/heads/main in the consuming pipeline, otherwise the check fails even for a pipeline that does extend the template.
When not to do this: if you have two developers and one subscription, a required template adds a second repository to maintain for very little gain. Approvals and branch control are enough. Required templates pay off once you have several teams sharing service connections and a platform team that can own the template.
Repositories are protected resources now
Before 2021 the only control was the 2020 setting that limits the job token to repositories the YAML explicitly checks out; in May 2021 (Sprint 186) repositories also became protected resources with their own pipeline permissions and checks, like service connections. Combined with the organisation or project pipeline setting Limit job authorization scope to referenced Azure DevOps repositories, a pipeline has to reference a repo in its YAML, and be granted access to it, before its job token can read it.
This matters for two reasons. It stops a pipeline in a low-trust repo from reading code it has no business seeing, and it lets you put checks on the template repository itself, so nobody quietly points ref at an unreviewed branch of your templates.
Alongside it, I switch on the two job authorisation scope settings (Limit job authorization scope to current project for release and non-release pipelines). The default build identity otherwise has access across the whole organisation, which is far more than a pipeline needs. The Azure Repos Git pipeline docs cover the “Limit job authorization scope” settings, and the job access tokens page explains what each scope means for the build identity. Expect a few pipelines that check out code from other projects to break; fix them by granting access explicitly rather than by switching the setting back off.
Service connections: fewer, narrower, rotated
The service connection is the credential, so it’s where most of the risk sits. As of early 2022 an Azure Resource Manager service connection authenticates with a service principal (secret or certificate), or with a managed identity when your self-hosted agents run on Azure VMs. There is no secretless option for Microsoft-hosted agents, so a service principal secret is stored in Azure DevOps and has to be rotated.
My rules:
- One connection per environment, scoped to a resource group, not a subscription. The automatic setup scopes to whatever you pick in the dialog; pick the resource group.
- Don’t tick “Grant access permission to all pipelines”. Authorise pipelines one at a time so every new pipeline is a deliberate decision. New resources now default to off (Sprint 194), but connections created before that still have it on, so audit the existing ones.
- Track the secret expiry. Expired service principal secrets are one of the most common causes of a production deployment failing on a day nobody changed anything. Put the expiry date in your operations calendar.
- Use managed identity where you run your own agents. If production deployments already go through self-hosted agents in a VNet, a managed identity on those VMs removes the stored secret entirely.
Two operational things to plan for
Hosted parallelism for new organisations. Because of crypto-mining abuse, Microsoft stopped the automatic free grant of Microsoft-hosted parallel jobs for new public projects in February 2021, and later for many new private projects too. New organisations now request the grant through a form or pay for a parallel job. If you’re spinning up an organisation for a new client or business unit, do this in week one, not on the day of the first deployment.
Pin your agent images. windows-2022 has been available in preview on Microsoft-hosted agents since September 2021, and Microsoft starts moving windows-latest to it on 17 January 2022, finishing by March. That is exactly the kind of change that breaks a release a week from now if your production pipeline says windows-latest. I pin ubuntu-20.04 and windows-2019 (or windows-2022 once a project is tested on it) in production pipelines, and let a scheduled pipeline run against -latest so image changes show up as a failed test run, not a failed release.
Where I’d start
If you only do three things this quarter: put branch control and approvals on your production environments and service connections, turn on the repository protection and job authorisation scope settings, and remove “all pipelines” access from every service connection. None of it needs a pipeline rewrite, and each one closes a path to production that most teams don’t realise is open. Required templates come next, once you have a platform team to own them.
And if you’re debating whether to leave Azure Pipelines for GitHub Actions instead, the controls above are a fair yardstick. Whatever you run on, a pipeline file anyone can edit shouldn’t be the only thing standing between a branch and production.