Azure DevOps is Microsoft's integrated software delivery platform: work tracking, Git hosting, CI/CD, package feeds and manual test management, sold as one service. Many teams meet only one piece of it, usually Pipelines, and treat the rest as noise. That misses the point of the product, which is traceability: a work item links to the branch, the pull request, the build that tested it and the deployment that shipped it, and policies at each step decide what may move forward.
This page explains how the parts fit, then builds a realistic pipeline for a small web API: build and test, publish one immutable artifact, deploy to staging, then promote to production behind an approval, using a service connection that holds no secret. Along the way it covers the YAML model and its three expression syntaxes, templates for governance, identity and secrets, environments and checks, branch policies, package feeds and agents, and then the failure modes and trade-offs that matter once the pipeline is real.
The five services and the hierarchy
Everything lives in an organisation (dev.azure.com/your-org), which is tied to a Microsoft Entra ID tenant for sign-in. An organisation holds projects, the boundary for permissions, work-item process and most settings. Inside a project are five services:
- Boards: work items (epics, features, user stories or product backlog items, tasks, bugs) under a process template such as Agile, Scrum, Basic or CMMI, with backlogs, sprints and queries.
- Repos: Git repositories with pull requests and branch policies (Team Foundation Version Control still exists for legacy codebases).
- Pipelines: CI/CD defined in YAML (or the older classic editor and classic release pipelines), executed on agents.
- Artifacts: package feeds for NuGet, npm, Maven, Python and Universal Packages, with upstream sources.
- Test Plans: manual and exploratory test management, licensed separately from the basic access level.
Services can be switched off per project, and each works with external tools: Pipelines builds GitHub repositories, and Boards can link to GitHub commits. Azure DevOps Server is the self-hosted edition for organisations that must run on their own hardware; this page describes the cloud service.
How a pipeline run works
When a trigger fires, Azure Pipelines fetches the YAML at that commit, expands every template and compile-time expression into one final document, and plans the run as stages containing jobs containing steps. Stages run in sequence by default, or as a graph via dependsOn. Each job is dispatched to an agent from a pool; a Microsoft-hosted agent is a fresh virtual machine for every job, so nothing on disk survives from one job to the next. Steps are scripts or tasks, versioned packaged actions such as AzureCLI@2.
Work moves between jobs only through explicit channels: pipeline artifacts, output variables, and caches. That isolation is a feature. The artifact built and tested in the Build stage is the exact set of bytes later deployed, which is the property that makes promotion between environments trustworthy.
Worked example: a multi-stage pipeline
The team owns a .NET web API deployed to Azure App Service. They want every commit to main built and tested, deployed to staging automatically, and deployed to production only after a human approves. The pipeline:
# azure-pipelines.yml
trigger:
branches:
include: [ main ]
paths:
exclude: [ docs/* ]
variables:
- group: orders-api-common # variable group, may be linked to Key Vault
- name: buildConfiguration
value: Release
stages:
- stage: Build
jobs:
- job: build_test
pool:
vmImage: ubuntu-latest
steps:
- task: UseDotNet@2
inputs: { packageType: sdk, version: 8.x }
- script: dotnet restore && dotnet build -c $(buildConfiguration) --no-restore
displayName: Build
- script: dotnet test -c $(buildConfiguration) --no-build --logger trx
displayName: Test
- task: PublishTestResults@2
condition: succeededOrFailed()
inputs: { testResultsFormat: VSTest, testResultsFiles: '**/*.trx' }
- script: dotnet publish src/Orders.Api -c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory)/app
- publish: $(Build.ArtifactStagingDirectory)/app
artifact: app
- stage: Staging
dependsOn: Build
jobs:
- deployment: deploy_staging
environment: orders-staging
pool: { vmImage: ubuntu-latest }
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app
- task: AzureCLI@2
inputs:
azureSubscription: sc-orders-staging # federated service connection
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
cd $(Pipeline.Workspace)/app && zip -r ../app.zip .
az webapp deploy -g rg-orders-stg -n orders-api-stg \
--src-path $(Pipeline.Workspace)/app.zip --type zip
- stage: Production
dependsOn: Staging
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: deploy_prod
environment: orders-production # approvals and checks are configured on this resource
pool: { vmImage: ubuntu-latest }
strategy:
runOnce:
deploy:
steps:
- template: templates/deploy-webapp.yml
parameters: { serviceConnection: sc-orders-prod, appName: orders-api-prod }Read it from the top. The trigger runs CI on pushes to main except documentation-only changes. Note what is missing: a pr block. For Azure Repos, pull-request validation is configured as a build-validation branch policy on the target branch, not with the pr keyword, which applies to GitHub and Bitbucket repositories. The Build stage publishes test results even when tests fail, so the failure is visible in the run's Tests tab, and publishes the app as a pipeline artifact named app.
The later stages use deployment jobs rather than ordinary jobs. A deployment job targets an environment, records deployment history against it, downloads artifacts automatically in its lifecycle hooks, and, most importantly, waits for every approval and check attached to that environment before it starts. The production stage's condition also refuses to deploy anything not built from main, so a manually queued run from a feature branch cannot reach production.
Templates and the three expression syntaxes
# templates/deploy-webapp.yml
parameters:
- name: serviceConnection
type: string
- name: appName
type: string
- name: runSmokeTest
type: boolean
default: true
steps:
- download: current
artifact: app
- task: AzureCLI@2
inputs:
azureSubscription: ${{ parameters.serviceConnection }} # resolved at compile time
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
cd $(Pipeline.Workspace)/app && zip -r ../app.zip .
az webapp deploy -g rg-orders -n ${{ parameters.appName }} \
--src-path $(Pipeline.Workspace)/app.zip --type zip
- ${{ if eq(parameters.runSmokeTest, true) }}:
- script: curl --fail --retry 5 --retry-delay 10 https://${{ parameters.appName }}.azurewebsites.net/healthz
displayName: Smoke testTemplates are how a platform team stops every service from inventing its own deployment logic. A step template like this one is included with typed parameters; a pipeline can also extends a template that owns the whole skeleton, and an environment check called Required template can refuse any run that does not extend an approved one. That is the main governance tool in Azure Pipelines.
The YAML mixes three syntaxes, and confusing them causes most template bugs. ${{ }} is a template expression, evaluated once at compile time, before the run starts; it can see parameters and statically defined variables, and it can insert or remove YAML, as the conditional smoke-test step does. $(var) is macro syntax, replaced at runtime just before a task executes; an undefined macro is left as literal text, which is a common silent failure. $[ ] is a runtime expression, used in conditions and in variable definitions that depend on earlier jobs' outputs. Rule of thumb: structure with ${{ }}, values with $(var), cross-job logic with $[ ].
Identity: service connections and workload identity federation
A pipeline deploys to Azure through a service connection, a project-level resource that stores how to authenticate to an external system. The traditional Azure Resource Manager connection stored a service principal's client secret, which expired, leaked into logs when mishandled, and needed rotation. The recommended form now uses workload identity federation: Azure DevOps issues a short-lived OpenID Connect token for the run, and Microsoft Entra ID exchanges it for an access token because an app registration or managed identity trusts tokens with a specific issuer and subject. The subject identifies the connection in the form sc://<org>/<project>/<service connection name>. No secret is stored anywhere, so there is nothing to rotate.
Scope each connection narrowly: one per environment, granted a role on just the resource group it deploys to, and restricted to the pipelines that need it rather than opened to all pipelines in the project. Service connections support the same approvals and checks as environments, so a production connection can itself demand approval.
For secrets the application needs at runtime, a variable group can be linked to an Azure Key Vault so values are read at run time rather than copied into Azure DevOps. Secret variables are masked in logs and are not exposed to pull-request builds from forks by default. Masking is a safety net, not a guarantee: a script that transforms a secret, by base64-encoding it for example, defeats it. The cloud secrets guide covers vault patterns generally.
Environments, approvals and checks
An environment is a named deployment target with a history and a set of checks. Common ones are manual approval by named users or groups, branch control (only runs from refs/heads/main may deploy), business hours, an exclusive lock so two runs never deploy at once, invoking an Azure Function or REST API to ask an external system for a go or no-go, querying Azure Monitor alerts, and Required template. Checks are evaluated when a stage that targets the environment is about to start, and they are configured on the resource, not in the YAML, so a developer cannot remove the production approval by editing the pipeline.
Deployment jobs offer three strategies. runOnce executes the lifecycle hooks once. rolling updates virtual machines in a VM-resource environment a batch at a time. canary deploys to increasing increments with hooks for routing traffic and checking health. The strategies give you the hooks; traffic shifting and health judgment are still yours to script, as described in canary deployment and blue-green deployment.
Repos, branch policies and Boards traceability
Branch policies on main are what make the pipeline's guarantees hold. Typical settings: a minimum number of reviewers, with resets when new commits are pushed; required build validation running the CI pipeline against the merged result of the pull request; all comments resolved; linked work items required; and automatically included reviewers for sensitive paths such as the pipeline folder. Mentioning a work item as AB#123 links commits from GitHub to Boards; in Azure Repos you link work items to the pull request directly. The result is a chain an auditor can follow from requirement to production deployment without asking anyone.
Artifacts feeds and agents
An Azure Artifacts feed hosts your internal packages and, through upstream sources, proxies public registries such as nuget.org or npmjs. Routing every restore through the feed gives you one place to see which public packages you depend on, keeps saved copies if an upstream package is removed, and lets you block a version. Views such as @Prerelease and @Release promote a package version without republishing it, mirroring artifact promotion in pipelines.
Microsoft-hosted agents need no maintenance and start clean, but they are reached over the internet and cannot see private networks. Self-hosted agents run on machines you manage, inside your network, and keep state between jobs, which speeds builds and lets them leak state too. Virtual machine scale set agent pools let Azure DevOps grow and shrink a pool of your own VMs. On any agent, the Cache@2 task restores dependency directories keyed on lock-file hashes, which often cuts minutes from restore steps.
Failure modes
- Rebuilding per environment. A pipeline that builds again for production deploys bytes nobody tested. Build once, publish an artifact, deploy that artifact everywhere.
- Approvals in the wrong place. An approval written as a manual step in YAML can be deleted by any contributor. Put approvals and branch control on the environment and service connection.
- Over-shared service connections. A connection with subscription-wide Owner, open to all pipelines, turns any pipeline edit into a privilege escalation. Scope roles and pipeline permissions.
- Literal macros. A misspelt
$(variable)is passed through as text and the script carries on with a bogus value. Validate required variables at the start of a job. - Compile-time versus runtime confusion.
${{ variables.x }}cannot see variables set by an earlier step. Use output variables and$[ ]expressions for cross-job values. - Stateful self-hosted agents. Leftover files and tools from previous jobs make builds pass on one agent and fail on another. Clean workspaces, or use ephemeral scale set agents.
Trade-offs
| Decision | Option A | Option B |
|---|---|---|
| Pipeline style | Classic editor and release pipelines: visual, not versioned with code | YAML: reviewed in pull requests, templatable, needs YAML fluency |
| Agents | Microsoft-hosted: zero maintenance, clean, no private network access | Self-hosted or scale set: network access and caching, you patch and secure them |
| Platform | Azure DevOps: Boards, Test Plans and policy controls in one product | GitHub with Actions: larger ecosystem of actions, tighter fit for open source workflows |
| Azure credentials | Secret-based service principal: works everywhere, secrets to rotate | Workload identity federation: no stored secret, requires Entra trust configuration |
For broader release practice, the release train guide covers cadence and promotion policy independent of tooling.
What to do next
- Convert one classic build or release pipeline to YAML, structured as build once, publish one artifact, then deployment jobs per environment.
- Create environments for staging and production, and move approvals, branch control and an exclusive lock onto them.
- Replace secret-based Azure Resource Manager connections with workload identity federation, one per environment, scoped to a resource group and to named pipelines.
- Add branch policies on main: required reviewers, build validation, comment resolution and linked work items.
- Extract shared deployment steps into a parameterised template in a central repository, and require it with the Required template check.
- Route package restores through an Artifacts feed with upstream sources, and add Cache@2 for dependency directories.