Part 8 of 833 min read · 7 diagramsAI-assisted

Azure DevOps

Table of Contents#

  1. Where Azure DevOps Fits — Microsoft's Enterprise Play
  2. Azure DevOps Pricing at a Glance
  3. The Five Services — Boards, Repos, Pipelines, Artifacts, Test Plans
  4. YAML Pipelines vs. Classic Pipelines
  5. Anatomy of azure-pipelines.yml
  6. Stages, Jobs, and Steps — Three Layers of Nesting
  7. A Minimal Pipeline, Built Up Step by Step
  8. Multi-Stage Pipelines and dependsOn
  9. Matrix Builds — strategy: matrix
  10. Artifacts and Caching
  11. Templates — Azure's Reusability Model
  12. Pipeline Decorators — Organization-Wide Injected Steps
  13. Service Connections and Workload Identity Federation
  14. A Full Worked Example: Deploying to Azure via Workload Identity Federation
  15. Variables and Variable Groups
  16. Environments, Approvals, and Checks
  17. Deployment Strategies — runOnce, rolling, and canary
  18. Branch Policies — Azure Repos
  19. Agent Pools — Microsoft-Hosted and Self-Hosted
  20. A Full Realistic Multi-Stage Pipeline
  21. Four-Way Comparison — GitHub, GitLab, Bitbucket, and Azure DevOps
  22. Common Mistakes
  23. Worked Practice Problems
  24. Summary and What's Next

Where Azure DevOps Fits — Microsoft's Enterprise Play#

Azure DevOps closes out this series' platform tour with a fourth distinct positioning, worth naming as precisely as the other three were: Azure DevOps is Microsoft's enterprise project-and-software-delivery suite, historically evolved from Team Foundation Server (TFS) — a product with roots going back to enterprise .NET shops long before "DevOps platform" was a marketing category anyone used. It bundles project planning (Boards), source control (Repos — which can host Git or, still in real legacy use, the older centralized TFVC), CI/CD (Pipelines), package management (Artifacts), and manual/exploratory test management (Test Plans) as five separate but integrated services under one umbrella.

Diagram

One structural fact distinguishes Azure DevOps from every platform covered so far, and it's genuinely important: Azure Pipelines can build and deploy from any Git repository — Azure Repos, GitHub, GitLab, Bitbucket, or a generic Git server — not only from Azure DevOps's own repo hosting. This means "using Azure DevOps" and "using Azure Repos" are two independent decisions; a team can keep code on GitHub entirely and use only Azure Pipelines for CI/CD, something none of GitHub Actions, GitLab CI/CD, or Bitbucket Pipelines support in reverse (each of those is tied to its own platform's repos). This chapter covers Azure Pipelines primarily, with Azure Repos and Boards covered only where they intersect with pipeline mechanics (branch policies, work-item linking) — mirroring this course's build-first-then-govern structure from the GitHub chapters, condensed into one chapter as with GitLab and Bitbucket.

Keep that cross-git-host capability in mind while reading — several sections ahead return to it directly, since it changes what "adopting Azure DevOps" even means as a decision compared to the other three platforms in this series, each of which bundles CI/CD and repo hosting as one inseparable choice.


Azure DevOps Pricing at a Glance#

TierIncluded parallel jobs / minutesTypical fit
Free (Basic)1 free Microsoft-hosted parallel job (1,800 minutes/month), unlimited self-hosted parallel jobsSmall teams, open-source (public projects get more free Microsoft-hosted parallelism)
Basic + Test PlansSame Pipelines allowance, adds manual/exploratory test managementTeams needing formal QA test-case tracking
Paid parallel jobsPurchased per additional concurrent Microsoft-hosted pipelineLarger teams running many pipelines concurrently

The billing unit here is meaningfully different from the "included minutes, billed per-minute past that" model of the other three platforms — Azure Pipelines bills primarily by parallel job slots (how many pipelines can run concurrently), each included slot then carrying its own separate monthly minute allowance, rather than one shared pool of minutes. A team with a single parallel job slot and 20 pipelines queued at once runs them one at a time regardless of how many total minutes remain in the month — a genuinely different scaling bottleneck to plan around than GitHub's or GitLab's shared-minute-pool model, and one worth understanding explicitly before assuming "we have plenty of minutes left" means "our pipelines aren't queuing."


The Five Services — Boards, Repos, Pipelines, Artifacts, Test Plans#

ServiceWhat it isRough equivalent elsewhere
BoardsWork item tracking (epics, features, user stories, bugs), Kanban/Scrum boardsJira (Bitbucket's ecosystem), GitHub Issues/Projects, GitLab Issues
ReposGit (or legacy TFVC) source controlGitHub/GitLab/Bitbucket repo hosting
PipelinesCI/CD — the focus of this chapterGitHub Actions, GitLab CI/CD, Bitbucket Pipelines
ArtifactsPackage feeds (NuGet, npm, Maven, Python)GitHub Packages, GitLab Package Registry
Test PlansManual and exploratory test case managementNo direct equivalent in the other three platforms covered in this series

Test Plans is worth calling out as genuinely distinctive — none of GitHub, GitLab, or Bitbucket ship a first-party manual/exploratory test-case management tool as part of their core product; this is commonly handled by a separate third-party tool (TestRail, Zephyr) alongside any of the other three platforms. For an enterprise QA organization still running significant manual/exploratory testing alongside automated pipelines, this native integration is a genuine differentiator, similar in spirit to how Bitbucket's Jira integration (Part 7) is its own standout strength.


YAML Pipelines vs. Classic Pipelines#

Azure DevOps carries a real, still-relevant legacy split worth understanding precisely, since a lot of existing enterprise Azure DevOps usage predates YAML pipelines entirely:

Classic PipelinesYAML Pipelines
Defined asA visual, drag-and-drop designer, stored as UI configurationA azure-pipelines.yml file, checked into the repo alongside the code
Version-controlled with the code?No — lives entirely in Azure DevOps's own databaseYes — reviewed in the same PR as the code it builds
ReusabilityTask groups (UI-configured, less portable)Templates (versioned, file-based)
Current Microsoft guidanceLegacy — still supported, still genuinely common in older enterprise setupsThe recommended approach for any new pipeline
Diagram

This chapter covers YAML Pipelines exclusively, both because it's Microsoft's own current recommendation and because it's the only one of the two models that fits the "pipeline as code" premise this entire series has been built on since Part 1 — a Classic pipeline, being pure UI configuration outside version control, is a genuine step backward against every principle established in this course about reviewable, auditable infrastructure and pipeline changes (directly echoing Part 2's IaC argument against manual, undocumented console changes). A team still running Classic pipelines in production should treat migrating to YAML as real, prioritizable technical debt, not a cosmetic preference.


Anatomy of azure-pipelines.yml#

Azure Pipelines has the deepest structural nesting of any platform covered in this series — four layers instead of GitHub's three (Workflow → Jobs → Steps) or GitLab/Bitbucket's two (Pipeline → Jobs/Steps):

Diagram
  • Pipeline — the whole file, plus a trigger: (push events) and pr: (pull request events) at the top.
  • Stage — the highest-level grouping (Build, Test, Deploy), each running on its own agent allocation, ordered via dependsOn (covered shortly). A single-stage pipeline can omit stages: entirely and just declare jobs: directly at the top level — the extra nesting layer is optional for simple pipelines.
  • Job — a unit of work assigned to one agent, analogous to a GitHub/GitLab job.
  • Step — either a script: (raw shell) or a task: (a pre-built, versioned unit of automation, e.g. AzureCLI@2) — Azure's version of GitHub's Action / GitLab's Pipe, covered in the Templates section.
trigger:
  branches:
    include: [main]

stages:
  - stage: Build
    jobs:
      - job: BuildJob
        pool:
          vmImage: ubuntu-latest
        steps:
          - script: npm ci
          - script: npm run build

Stages, Jobs, and Steps — Three Layers of Nesting#

The four-layer model exists specifically to let a single pipeline express genuinely large, multi-team, multi-environment delivery processes without external tooling — a Build stage feeding into parallel DeployStaging and DeployCanary stages, each containing multiple jobs, is entirely native:

stages:
  - stage: Build
    jobs:
      - job: Compile
        pool: { vmImage: ubuntu-latest }
        steps:
          - script: npm ci && npm run build

  - stage: Test
    dependsOn: Build
    jobs:
      - job: UnitTests
        pool: { vmImage: ubuntu-latest }
        steps:
          - script: npm test
      - job: LintCheck
        pool: { vmImage: ubuntu-latest }
        steps:
          - script: npm run lint

  - stage: Deploy
    dependsOn: Test
    jobs:
      - deployment: DeployProd
        environment: production
        strategy:
          runOnce:
            deploy:
              steps:
                - script: ./deploy.sh

Notice the Test stage's two jobs (UnitTests, LintCheck) run in parallel by default (same job-parallelism default as GitHub and GitLab-within-a-stage), while stages themselves are sequential unless a dependsOn graph says otherwise — structurally the closest of the four platforms to GitLab's stage-sequential-by-default model, though Azure's dependsOn operates at the stage level primarily, with job-level dependsOn an additional, less commonly needed refinement within a stage.


A Minimal Pipeline, Built Up Step by Step#

Step 1 — bare minimum, no stages at all (the optional top layer, omitted for simplicity):

pool:
  vmImage: ubuntu-latest
steps:
  - script: echo "hello"

Step 2 — a real Node project:

trigger:
  branches: { include: [main] }
pool:
  vmImage: ubuntu-latest
steps:
  - task: NodeTool@0
    inputs: { versionSpec: '20.x' }
  - script: npm ci
  - script: npm test

Step 3 — introducing explicit jobs, so build and test can eventually run on different pools/images:

trigger:
  branches: { include: [main] }
jobs:
  - job: Build
    pool: { vmImage: ubuntu-latest }
    steps:
      - task: NodeTool@0
        inputs: { versionSpec: '20.x' }
      - script: npm ci
      - script: npm run build
  - job: Test
    pool: { vmImage: ubuntu-latest }
    steps:
      - task: NodeTool@0
        inputs: { versionSpec: '20.x' }
      - script: npm ci
      - script: npm test

Step 4 — promoting to full stages, adding a PR trigger and a manual-gated deploy:

trigger:
  branches: { include: [main] }
pr:
  branches: { include: [main] }

stages:
  - stage: Build
    jobs:
      - job: BuildAndTest
        pool: { vmImage: ubuntu-latest }
        steps:
          - task: NodeTool@0
            inputs: { versionSpec: '20.x' }
          - script: npm ci
          - script: npm run build
          - script: npm test

  - stage: Deploy
    dependsOn: Build
    condition: succeeded()
    jobs:
      - deployment: DeployProd
        environment: production        # approval gate configured here, covered later in this chapter
        pool: { vmImage: ubuntu-latest }
        strategy:
          runOnce:
            deploy:
              steps:
                - script: ./deploy.sh

A task: (like NodeTool@0 above) is the third distinct kind of step, alongside script: and (covered next) template: — a versioned, parameterized, pre-built unit shipped either by Microsoft or a third party via the Visual Studio Marketplace, closest in spirit to a GitHub Action but, like Bitbucket's Pipes, generally narrower in scope than a full reusable job.


Multi-Stage Pipelines and dependsOn#

dependsOn at the stage level is Azure's DAG-building mechanism — the same underlying need already covered for GitHub's job-level needs: (Part 4) and GitLab's job-level needs: (Part 6), applied one layer up, at stage granularity:

stages:
  - stage: BuildFrontend
    jobs: [...]
  - stage: BuildBackend
    jobs: [...]
  - stage: DeployStaging
    dependsOn:
      - BuildFrontend
      - BuildBackend
    jobs: [...]
  - stage: DeployProdCanary
    dependsOn: DeployStaging
    jobs: [...]
  - stage: DeployProdFull
    dependsOn: DeployProdCanary
    condition: succeeded()
    jobs: [...]
Diagram

condition: is worth its own callout — it's Azure's if:/rules: equivalent, and its default value is easy to get wrong. By default, a stage's implicit condition is succeeded() — it only runs if every stage it dependsOn succeeded. Explicitly writing condition: succeeded() (as shown for DeployProdFull above) is redundant with the default but is common practice for clarity; the more consequential use of condition: is overriding that default — e.g. condition: failed() for a rollback/cleanup stage that should run only when something upstream broke, or condition: always() for a notification stage that should run regardless of outcome, directly mirroring the if: failure() / if: always() semantics already covered for GitHub Actions in Part 4.


Matrix Builds — strategy: matrix#

jobs:
  - job: Test
    strategy:
      matrix:
        Node18Linux:
          nodeVersion: '18.x'
          imageName: 'ubuntu-latest'
        Node20Linux:
          nodeVersion: '20.x'
          imageName: 'ubuntu-latest'
        Node20Windows:
          nodeVersion: '20.x'
          imageName: 'windows-latest'
      maxParallel: 3
    pool:
      vmImage: $(imageName)
    steps:
      - task: NodeTool@0
        inputs: { versionSpec: $(nodeVersion) }
      - script: npm ci
      - script: npm test

Two details distinguish Azure's matrix syntax from GitHub's and GitLab's, worth flagging precisely: first, each matrix entry is an explicitly named key (Node18Linux, not an auto-generated combination label) — giving direct control over how each leg is labeled in the pipeline UI, rather than an auto-derived name from the combination values. Second, maxParallel: caps how many matrix legs run concurrently, independent of how many total legs are defined — directly relevant given this chapter's earlier point about Azure's parallel-job-slot billing model: a matrix with 12 legs but only 1 available parallel job slot runs strictly one at a time regardless of maxParallel:'s value, since the actual concurrency ceiling is set by the account's purchased parallel jobs, not by the matrix definition itself.


Artifacts and Caching#

The same cache-for-speed, artifact-for-data-passing split covered for all three prior platforms:

steps:
  - task: Cache@2
    inputs:
      key: 'npm | "$(Agent.OS)" | package-lock.json'
      restoreKeys: 'npm | "$(Agent.OS)"'
      path: '$(Pipeline.Workspace)/.npm'
  - script: npm ci
  - script: npm run build
  - task: PublishPipelineArtifact@1
    inputs:
      targetPath: 'dist'
      artifact: 'dist'

Consuming the artifact in a later stage:

  - stage: Deploy
    dependsOn: Build
    jobs:
      - job: DeployJob
        steps:
          - task: DownloadPipelineArtifact@2
            inputs:
              artifact: 'dist'
              path: '$(Pipeline.Workspace)/dist'
          - script: ./deploy.sh $(Pipeline.Workspace)/dist

Unlike GitLab's automatic artifact propagation (Part 6) or Bitbucket's automatic next-step propagation (Part 7), Azure requires an explicit DownloadPipelineArtifact@2 step — structurally closer to GitHub Actions' explicit actions/download-artifact model (Part 4) than to its two more automatic peers. Both PublishPipelineArtifact@1 and Cache@2 are themselves task:s, not special first-class YAML keywords — a consistent pattern throughout Azure Pipelines where a huge amount of functionality (not just third-party integrations) is delivered as versioned, parameterized tasks rather than baked directly into the YAML schema.


Templates — Azure's Reusability Model#

Azure's reusability primitive, templates, is genuinely the most flexible of the four platforms' equivalent mechanisms — a single template: keyword can reference and extend YAML at any of the four structural layers (steps, jobs, stages, or the entire pipeline), not just one fixed granularity:

# templates/build-steps.yml — a reusable STEP template
parameters:
  - name: nodeVersion
    type: string
    default: '20.x'

steps:
  - task: NodeTool@0
    inputs: { versionSpec: ${{ parameters.nodeVersion }} }
  - script: npm ci
  - script: npm run build
# azure-pipelines.yml — consuming it
jobs:
  - job: Build
    pool: { vmImage: ubuntu-latest }
    steps:
      - template: templates/build-steps.yml
        parameters:
          nodeVersion: '18.x'

A full pipeline template — Azure's closest equivalent to a GitHub reusable workflow or GitLab CI/CD Component — uses extends:, which is structurally different from template: in an important way: extends: lets the template itself enforce structure the calling pipeline cannot override (e.g. mandating a security-scan stage always runs), rather than the calling file simply pulling in optional pieces:

# templates/secure-pipeline.yml — an ENFORCING template
parameters:
  - name: buildSteps
    type: stepList
    default: []

stages:
  - stage: Build
    jobs:
      - job: BuildJob
        steps:
          - ${{ parameters.buildSteps }}
  - stage: MandatorySecurityScan          # every consuming pipeline gets this, no way to skip it
    jobs:
      - job: SASTScan
        steps:
          - script: run-sast-scanner.sh
# azure-pipelines.yml — consuming via extends, NOT template:
extends:
  template: templates/secure-pipeline.yml
  parameters:
    buildSteps:
      - script: npm ci
      - script: npm run build
template: (steps/jobs/stages)extends: (whole pipeline)
GranularityAny one layer — reusable piece, composed into a larger pipeline you still controlThe entire pipeline's structure
Can the caller override/skip parts?Yes — it's opt-in compositionNo — the template controls the overall shape; the caller only fills in designated parameter slots
Rough analogyGitHub composite action / GitLab includeGitLab's compliance pipelines (Part 6) — centrally enforced, non-bypassable structure

This extends:-based enforcement is a genuine, distinctive strength worth comparing directly against GitLab's compliance pipelines from Part 6 — both solve the same "a specific control must run, and individual project maintainers must not be able to quietly remove it" governance problem this series has now returned to three times, using different mechanisms: GitLab injects centrally at the group/framework level entirely outside the project's own file; Azure achieves the same non-bypassable guarantee via the calling pipeline's own file structurally being unable to define stages the extends: template doesn't expose a parameter for.


Pipeline Decorators — Organization-Wide Injected Steps#

Worth a dedicated mention as the single most powerful (and least commonly known) governance mechanism covered anywhere in this series: a pipeline decorator is an extension, installed at the Azure DevOps organization level, that automatically injects steps into every single pipeline in the entire organization — with no extends: reference, no include:, and no action required from any individual pipeline author at all.

Diagram

A decorator is authored as a small Azure DevOps extension (published to the organization privately, not necessarily the public Marketplace) and, once installed, applies retroactively to every existing pipeline and automatically to every pipeline created afterward — genuinely zero YAML changes required anywhere. A common real-world use: injecting a mandatory secret-scanning step before every single job runs, organization-wide, closing even the gap extends: still has (a pipeline must at least reference the enforcing template) — a decorator requires no reference at all.

Comparing all three centralized-governance mechanisms encountered across this series side by side clarifies exactly where each sits on a spectrum from "opt-in" to "structurally required" to "organization-wide and unavoidable":

MechanismPlatformRequires the pipeline author to do anything?
CI/CD Component / include:GitLabYes — must explicitly include: it
Compliance pipelineGitLabNo — injected via a compliance framework label on the project
extends: templateAzure DevOpsYes — the pipeline file must use extends: instead of plain stages:
Pipeline decoratorAzure DevOpsNo — organization-wide, fully automatic, zero pipeline-level reference

A decorator is the strongest guarantee of the four, precisely because it requires no cooperation whatsoever from any individual pipeline's author — it's worth reaching for specifically when a control is genuinely non-negotiable organization-wide (a mandatory secret scan, a mandatory license-compliance check) rather than something that should vary project-by-project, where extends: templates or compliance-framework labeling (allowing different frameworks for different project types) remain the more appropriate, more granular tool.


Service Connections and Workload Identity Federation#

A Service Connection is Azure DevOps's stored authorization to an external system (an Azure subscription, AWS, GCP, a container registry, another Azure DevOps project) — referenced by name from a pipeline, rather than the pipeline holding raw credentials directly.

Diagram

Workload Identity Federation is Azure's name for the exact same OIDC pattern already covered twice in this series (GitHub's OIDC in Part 5, GitLab's ID tokens in Part 6) — a Service Connection configured with Workload Identity Federation has Azure DevOps present a short-lived, signed token to Microsoft Entra ID (Azure AD) at pipeline run time, exchanged for temporary Azure credentials, with no client secret stored in Azure DevOps at all. Microsoft's own current guidance explicitly recommends Workload Identity Federation over the older Service Principal + stored secret approach for exactly the reason this series has established repeatedly: a stored secret is exploitable indefinitely until manually rotated, while a federated identity issues nothing that outlives a single pipeline run.


A Full Worked Example: Deploying to Azure via Workload Identity Federation#

Step 1 — one-time setup (via Azure Portal or Azure CLI, conceptually mirroring the IAM trust-policy setup from every prior platform's OIDC example): create a Service Connection of type "Azure Resource Manager," authentication method "Workload Identity federation (automatic)" — Azure DevOps and Azure AD handle the federated credential trust relationship between themselves automatically when this option is chosen, a meaningfully lower-friction setup step than manually authoring a JSON trust policy the way GitHub's and GitLab's AWS examples required.

Step 2 — the pipeline, referencing the Service Connection by name — notably, no permissions: or id_tokens: block is needed in the YAML at all, since the OIDC token exchange is handled entirely by the AzureCLI@2 task itself once it's told which Service Connection to use:

stages:
  - stage: Deploy
    jobs:
      - deployment: DeployToAzure
        environment: production
        pool: { vmImage: ubuntu-latest }
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureCLI@2
                  inputs:
                    azureSubscription: 'my-workload-identity-connection'   # references the Service Connection by name
                    scriptType: bash
                    scriptLocation: inlineScript
                    inlineScript: |
                      az storage blob upload-batch \
                        --account-name myproductionstorage \
                        --destination '$web' \
                        --source dist/

This is the most ergonomically simple of the four platforms' OIDC worked examples in this series, and it's worth being explicit about why: GitHub and GitLab both require the pipeline author to understand and correctly configure the raw OIDC mechanics (a permissions:/id_tokens: declaration, and a manually-authored cloud-side trust policy referencing specific claims). Azure DevOps's Service Connection abstraction absorbs nearly all of that complexity into a one-time, portal-driven setup step — the pipeline YAML itself looks almost identical to how it would look with a legacy secret-based Service Connection, with the actual credential-security improvement happening entirely "behind" the Service Connection's name. This is a genuine ergonomic win, at the tradeoff of the underlying mechanism being somewhat less visible/self-documenting directly in the YAML compared to GitHub's or GitLab's more explicit, in-file OIDC declarations.


Variables and Variable Groups#

ScopeConfiguredTypical use
Pipeline variablesInline in the YAML, or via UINon-sensitive, pipeline-specific values
Variable GroupsProject Settings → Pipelines → LibraryShared across multiple pipelines; can be linked to Azure Key Vault
Secret variablesMarked "Keep this value secret" (a checkbox) in a Variable Group or pipeline settingsCredentials, tokens
variables:
  - group: 'shared-prod-config'    # a Variable Group, defined once in the Library, reused across pipelines
  - name: buildConfiguration
    value: 'Release'

steps:
  - script: echo "Deploying with $(dbConnectionString)"    # pulled from the linked Variable Group

Variable Groups linked directly to Azure Key Vault are worth a specific callout, since they represent a meaningfully different secrets architecture than any prior platform's native secret store. Rather than Azure DevOps itself being the system of record for a secret's value, a Key-Vault-linked Variable Group makes Azure DevOps a pass-through — the actual secret value lives and is rotated in Key Vault, and the pipeline fetches the current value at run time. This is architecturally the same "don't make the CI/CD platform the source of truth for a secret it doesn't need to own" principle GitLab's external-secrets integration (Part 6) applies, and directly parallels this course's GitOps chapter's External Secrets Operator pattern (Part 3) — the same idea (a reference lives in the tracked config; the real value lives in a dedicated secrets system) recurring in a third distinct context across this series.


Environments, Approvals, and Checks#

Azure's implementation of the manual-approval-gate concept this series has now covered four times, with genuinely the richest set of gate types among the four platforms:

- stage: DeployProd
  jobs:
    - deployment: DeployProd
      environment: production        # approvals/checks configured on this Environment, NOT in YAML
      strategy:
        runOnce:
          deploy:
            steps:
              - script: ./deploy.sh

Configured on the production Environment itself (Pipelines → Environments → production → Approvals and checks), not in the YAML file — several distinct check types, usable individually or layered together:

Check typeWhat it does
ApprovalsNamed users/groups must manually approve — the direct equivalent of GitHub required reviewers / GitLab protected-environment approval rules
Business hoursDeployment only permitted within a configured time window — no direct equivalent in the other three platforms covered in this series
Invoke Azure Function / REST APIAn external system must return a success response before the deployment proceeds — e.g. a custom "is the on-call engineer currently paged on something else?" check
Branch controlRestricts which branches can deploy to this environment, similar to GitHub's deployment-branch-rules
Required templateEnforces that the pipeline deploying to this environment must extend a specific governance template (directly composing with the extends: mechanism from earlier in this chapter)

The "Invoke REST API" check type deserves particular attention as a genuinely distinctive capability — it turns the deployment gate into an arbitrary, extensible programmable check rather than a fixed menu of built-in options, letting a platform team wire in literally any internal system's own "is it safe to deploy right now" logic (a feature-flag service, an incident-status API, a change-freeze calendar) as a hard gate, without needing Azure DevOps itself to natively understand that system. This composes naturally with this course's Incident Management series' concept of a deployment freeze during an active incident — a genuinely automatable version of that policy, rather than a purely process-and-trust convention.


Deployment Strategies — runOnce, rolling, and canary#

Every deployment job example so far in this chapter used strategy: runOnce: — deploy everything, once, in one pass. Azure Pipelines natively supports two more deployment strategies as first-class strategy: options, directly implementing the rolling and canary deployment patterns this series first covered tool-agnostically in Part 1 — worth returning to that original diagram now that there's a concrete platform mechanism for it.

- deployment: DeployProd
  environment: production
  strategy:
    rolling:
      maxParallel: 20%              # replace instances in batches of 20% at a time
      preDeploy:
        steps:
          - script: ./drain-traffic.sh
      deploy:
        steps:
          - script: ./deploy-new-version.sh
      routeTraffic:
        steps:
          - script: ./enable-traffic.sh
      postRouteTraffic:
        steps:
          - script: ./run-health-check.sh
      on:
        failure:
          steps:
            - script: ./rollback.sh
        success:
          steps:
            - script: ./notify-success.sh
- deployment: DeployProdCanary
  environment: production
  strategy:
    canary:
      increments: [10, 25, 50, 100]   # % of the fleet, in successive waves
      deploy:
        steps:
          - script: ./deploy-canary-slice.sh
      postRouteTraffic:
        steps:
          - script: ./check-canary-health.sh
      on:
        failure:
          steps:
            - script: ./rollback-canary.sh

Each named phase (preDeploy, deploy, routeTraffic, postRouteTraffic) maps directly onto the generic deployment-strategy lifecycle this series established in Part 1, and the on: failure: / on: success: blocks are Azure's built-in, structural equivalent of the automated-rollback discipline this course's Incident Management series argues for — expressed here as a first-class pipeline construct rather than something a team has to hand-roll with conditional steps. None of GitHub Actions, GitLab CI/CD, or Bitbucket Pipelines ship an equivalent first-class rolling/canary deployment-strategy primitive — on those three platforms, implementing a genuine canary or rolling rollout inside the pipeline itself (as opposed to delegating it entirely to the underlying deployment target, e.g. a Kubernetes rolling update the pipeline merely triggers) requires hand-authoring the wave/health-check/rollback logic as ordinary script steps. This is a second genuinely distinctive Azure DevOps capability, alongside cross-git-host CI/CD, worth weighing directly if a team's deployment strategy is a first-order platform-selection criterion.


Branch Policies — Azure Repos#

For teams using Azure Repos specifically (recall: Azure Pipelines works with any git host, but branch policies specifically are an Azure Repos feature), the branch-protection-equivalent controls:

  • Require a minimum number of reviewers, with an option requiring at least one reviewer who did not also author the change — a subtly stronger check than a bare reviewer count, since it explicitly prevents self-approval in edge cases a naive count check might miss.
  • Check for linked work items — a PR must reference at least one Azure Boards work item before it can merge, directly enforcing the "every change traces to a tracked reason" discipline, tightly coupling Repos and Boards the same way Bitbucket's Smart Commits couple Bitbucket and Jira (Part 7), though expressed as a hard merge gate rather than a commit-message convention.
  • Require a successful build — the same required-status-check concept covered for every prior platform, referencing a specific YAML pipeline by name.
  • Comment resolution — every PR comment thread must be marked resolved before merge, Azure's version of Bitbucket's "all tasks resolved" merge check (Part 7).

Azure Repos has no first-party CODEOWNERS-file equivalent either, sharing the same gap already flagged for Bitbucket in Part 7 — path-scoped mandatory reviewers on Azure Repos require branch policies scoped per-path via multiple, separately-configured policies rather than one declarative file checked into the repo, a meaningfully more manual, UI-driven process than GitHub's or GitLab's single-file CODEOWNERS approach.


Agent Pools — Microsoft-Hosted and Self-Hosted#

Pool typeScopeRough equivalent
Microsoft-hostedFresh VM per job, Microsoft-managedGitHub-hosted runners
Self-hosted (private pool)Registered machines, organization-managedGitHub/GitLab/Bitbucket self-hosted runners
Azure Virtual Machine Scale Set agentsSelf-hosted, but auto-scaling based on demand, Azure-managed provisioningClosest to GitHub's/GitLab's Kubernetes-executor-based autoscaling runners
pool:
  name: my-self-hosted-pool
  demands:
    - agent.os -equals Linux
    - customCapability -equals gpu-enabled

Pipeline permissions on an agent pool are a distinctive, worth-noting security control — a specific YAML pipeline must be explicitly authorized to use a given self-hosted agent pool before it can, independent of ordinary repo/project access. This closes a specific gap the search research for this chapter flagged directly: without this authorization layer, any pipeline in the project could target a sensitive self-hosted pool just by naming it in pool:, regardless of whether that specific pipeline's author should have access to whatever that pool's agents can reach — a materially more granular control than the tag/label-matching model covered for GitHub, GitLab, and Bitbucket's self-hosted runners, which generally rely on tag-matching alone without a separate per-pipeline authorization step.


A Full Realistic Multi-Stage Pipeline#

The same build → test → scan → deploy-staging → approval → deploy-production shape from every prior platform chapter, in Azure's four-layer, template-composed model:

trigger:
  branches: { include: [main] }

stages:
  - stage: Build
    jobs:
      - job: BuildAndUnitTest
        pool: { vmImage: ubuntu-latest }
        steps:
          - template: templates/build-steps.yml
          - script: npm test
          - task: PublishPipelineArtifact@1
            inputs: { targetPath: dist, artifact: dist }

  - stage: SecurityScan
    dependsOn: Build
    jobs:
      - job: SAST
        pool: { vmImage: ubuntu-latest }
        steps:
          - script: npm audit --audit-level=high

  - stage: DeployStaging
    dependsOn: [Build, SecurityScan]
    jobs:
      - deployment: DeployStagingJob
        environment: staging
        pool: { vmImage: ubuntu-latest }
        strategy:
          runOnce:
            deploy:
              steps:
                - task: DownloadPipelineArtifact@2
                  inputs: { artifact: dist }
                - script: ./deploy.sh --env staging

  - stage: SmokeTest
    dependsOn: DeployStaging
    jobs:
      - job: Smoke
        pool: { vmImage: ubuntu-latest }
        steps:
          - script: curl -f https://staging.example.com/healthz

  - stage: DeployProduction
    dependsOn: SmokeTest
    condition: succeeded()
    jobs:
      - deployment: DeployProdJob
        environment: production      # Approvals + checks configured here = the manual gate
        pool: { vmImage: ubuntu-latest }
        strategy:
          runOnce:
            deploy:
              steps:
                - task: DownloadPipelineArtifact@2
                  inputs: { artifact: dist }
                - script: ./deploy.sh --env production
Diagram

Four-Way Comparison — GitHub, GitLab, Bitbucket, and Azure DevOps#

The complete comparison this series has been building toward since Part 4 — every dimension covered across all four platform chapters, side by side:

GitHub ActionsGitLab CI/CDBitbucket PipelinesAzure DevOps
Pipeline file(s)Many independent workflow filesTypically one .gitlab-ci.ymlOne bitbucket-pipelines.yml, named trigger sectionsOne azure-pipelines.yml, four-layer nesting
Default schedulingParallel unless needs:Sequential-by-stage unless needs:Fully sequential unless parallel:Sequential-by-stage unless dependsOn
Native matrix buildsYesYesNoYes (named legs, maxParallel:)
Reusable steps/jobsComposite Actions / Reusable Workflowsinclude: / CI/CD ComponentsPipes (narrow) / YAML anchors (single-repo)template: (any layer) / extends: (enforcing)
Non-bypassable centralized governanceOrg rulesets (access control only, not injected jobs)Compliance pipelines (injected, non-removable)No direct equivalentextends: templates (structurally non-bypassable)
Manual approval gateEnvironment required reviewersProtected environment + when: manualDeployment + trigger: manualEnvironment Approvals (+ Business Hours, REST API checks)
OIDC to cloud — setup frictionManual trust-policy authoringManual trust-policy authoringManual trust-policy authoringLowest — Service Connection UI absorbs most complexity
Built-in SAST/DAST/dependency scanningOpt-in (GHAS + Actions)Deepest — near one-line includeThinnest — mostly third-party PipesOpt-in (Microsoft Defender for DevOps + tasks)
CODEOWNERS-equivalent path reviewYesYes (approval rules)NoNo (multiple scoped branch policies instead)
Cross-git-host CI/CDNo — tied to GitHub reposNo — tied to GitLab reposNo — tied to Bitbucket reposYes — can build/deploy from any git host
Distinctive strengthEnormous third-party ecosystemDeepest built-in security/complianceDeepest native Jira integrationEnterprise governance depth, cross-platform CI/CD, native test management

The pattern worth taking away from this full comparison, more than any single row: every platform solves the same underlying problems from Part 1's tool-agnostic model — trigger, sequence, isolate, gate, authenticate, scope. Where they genuinely diverge is how much structure and enforcement the platform itself is willing to own on an organization's behalf — from GitHub's ecosystem-driven, opt-in-everything philosophy at one end, to GitLab's and Azure's centrally-enforceable governance mechanisms at the other, with Bitbucket's leaner, integration-focused approach occupying its own distinct niche built around Atlassian-ecosystem value rather than either raw feature breadth or built-in governance depth. No platform is simply "the best" independent of an organization's actual size, compliance posture, and existing tooling investment.


Common Mistakes#

MistakeWhy it's a problemFix
Still building new pipelines on the Classic designerConfig lives outside version control — invisible to code review, the exact problem CI/CD-as-code exists to solveUse YAML pipelines for anything new; treat existing Classic pipelines as migration debt
Assuming maxParallel: controls actual concurrencyThe real ceiling is the account's purchased parallel job slots, not the matrix definitionCheck the actual parallel-job allocation before assuming a large matrix will run as fast as it "should"
Storing a Service Connection with a long-lived Service Principal secret when Workload Identity Federation was availableSame indefinite-exposure risk as any static credentialUse Workload Identity Federation (automatic) for any new Service Connection
Forgetting DownloadPipelineArtifact@2 in a later stageUnlike GitLab/Bitbucket's automatic propagation, Azure requires it explicitlyAlways pair PublishPipelineArtifact@1 with an explicit download step in the consuming stage/job
Using template: when the goal is actually non-bypassable enforcementtemplate: composition can still be selectively ignored/reordered by the calling fileUse extends: when the requirement is genuinely "this cannot be skipped," not just "this is available to reuse"
No pipeline-level authorization configured on a sensitive self-hosted agent poolAny pipeline in the project can target it by name, regardless of who authored that pipelineExplicitly scope which YAML pipelines may use a sensitive pool via its permissions settings
No path-scoped branch policy on infra/pipeline-definition directoriesAzure Repos has no CODEOWNERS-equivalent single file — an easy gap to forget without oneConfigure a dedicated branch policy scoped to those specific paths, mirroring CODEOWNERS' intent manually
Using runOnce for a genuinely high-risk production deployDeploys everything in one pass, with no built-in wave/health-check/rollback structureUse rolling or canary strategy for anything where blast-radius control matters, and use the on: failure: hook
Assuming a decorator can be scoped to just one projectDecorators are organization-wide by design — that's the entire point of the guarantee they provideUse extends: templates instead when the requirement is project-specific, not truly organization-wide

Worked Practice Problems#

Problem 1: A team's matrix build defines 12 legs (maxParallel: 12) but the pipeline consistently takes as long as if only 2 ran at a time. What's the most likely explanation, and how would you confirm it?

Answer: The account most likely has only 1-2 purchased Microsoft-hosted parallel job slots — maxParallel: sets an upper bound on concurrency within the matrix's own logic, but the actual number of legs that can run simultaneously is capped by however many parallel job slots the organization has available across its entire Azure DevOps usage, not by anything the pipeline YAML itself declares. Confirm by checking Organization Settings → Parallel jobs; if it shows 1-2 available Microsoft-hosted slots, that's the real bottleneck, and the fix is either purchasing additional parallel job slots or moving some of the matrix legs to a self-hosted pool with its own separate concurrency capacity.

Problem 2: A platform team wants to guarantee that a specific security-scan stage runs on every pipeline in every project, with no individual project team able to remove or skip it — comparable to what GitLab's compliance pipelines (Part 6) provide. Which Azure DevOps mechanism achieves this, and specifically why does template: alone not suffice?

Answer: extends:, not plain template:. A pipeline using template: to pull in a security-scan step still fully controls its own overall stage list — nothing stops that same pipeline from simply not including the template reference, or reordering/conditionally skipping around it, since template: is opt-in composition the calling file remains in charge of. extends: inverts that control: the template defines the pipeline's overall stage structure (including a mandatory security-scan stage the template itself always includes), and the calling file can only fill in the specific parameter slots the template exposes — it structurally cannot add, remove, or reorder the template-defined stages, which is exactly the non-bypassable guarantee the requirement calls for.

Problem 3: An organization currently hosts code on GitHub but wants Azure DevOps's richer environment-approval checks (specifically, the "Invoke REST API" gate tied to an internal change-freeze calendar) for production deployments, without migrating source control off GitHub. Is this achievable, and how?

Answer: Yes — this is precisely the cross-git-host capability that distinguishes Azure DevOps from the other three platforms in this series. Azure Pipelines can be configured against a GitHub repository as its source (via a GitHub service connection for repo access), triggering Azure Pipelines runs off GitHub push/PR events while keeping GitHub as the actual code host, PR review surface, and everything else. The azure-pipelines.yml file itself would live in the GitHub repo; Azure DevOps's Environments, Approvals, and the REST API check specifically would be configured entirely within Azure DevOps, independent of GitHub having any equivalent feature natively. This is a genuinely available, real-world pattern precisely because Azure Pipelines was built to not require Azure Repos specifically — none of GitHub Actions, GitLab CI/CD, or Bitbucket Pipelines offer the reverse (using their CI/CD engine against a foreign platform's repos).

Problem 4: A team migrating from a Kubernetes-native rolling deployment (handled entirely by the cluster, per this course's Kubernetes deep-dive) to Azure Pipelines asks whether they should express their canary rollout using Azure's native canary: deployment strategy or just let the pipeline trigger a kubectl apply and let Kubernetes handle the rollout itself, as before. What's the actual tradeoff?

Answer: Both are legitimate, and the choice hinges on who should own the canary logic and health-check decisions. Letting Kubernetes handle it (the pipeline just applies a manifest with an updated image, and the cluster's own rolling-update or a service-mesh-based canary controller manages the wave-by-wave rollout) keeps that logic co-located with the runtime platform already responsible for it, and avoids duplicating rollout logic in two places — generally the better default when the team already has mature Kubernetes-native canary/rollout tooling (e.g. Argo Rollouts, covered as a suggested topic in this course). Using Azure's native canary: strategy instead centralizes the wave/health-check/rollback logic directly in the pipeline definition, visible in the same file as everything else — genuinely useful when the deployment target isn't Kubernetes at all (a VM fleet, an App Service slot swap) and has no native rollout controller of its own to delegate to. The mistake to avoid is doing both redundantly — layering Azure's canary waves on top of Kubernetes' own rolling update without reconciling which system is actually making the wave-timing and health-check decisions.


Summary and What's Next#

Azure DevOps closes out this series' platform tour with a four-layer pipeline model (Stages → Jobs → Steps, with an optional top layer) reflecting its enterprise, multi-team delivery-process roots, and a template:/extends: reusability mechanism that is genuinely the most flexible of the four platforms — spanning any structural layer, with extends: specifically providing the same kind of non-bypassable centralized governance GitLab's compliance pipelines provide, via a different mechanism. Workload Identity Federation delivers the same OIDC-based, credential-free cloud authentication established twice already in this series, with meaningfully lower pipeline-author-facing setup friction than GitHub's or GitLab's more explicit, manually-configured equivalents. Environments with Approvals and Checks provide the richest set of deployment-gate types among the four platforms, including business-hours restrictions and arbitrary REST API-driven gates. Azure DevOps's single most structurally distinctive capability — building and deploying from any git host, not only its own Azure Repos — has no equivalent anywhere else in this series.

This completes the four-platform tour this series set out to cover: GitHub (Parts 4-5), GitLab (Part 6), Bitbucket (Part 7), and Azure DevOps (this chapter) — four different answers to the same underlying CI/CD model established tool-agnostically back in Part 1, each shaped by a different platform philosophy: ecosystem breadth, integrated-platform depth, Atlassian-suite integration, and enterprise governance flexibility, respectively. The right choice for any real team depends on organizational context far more than on any single feature comparison — existing tooling investment, compliance requirements, team size, and which adjacent ecosystem (GitHub's Marketplace, GitLab's built-in scanning, Atlassian's Jira, or Azure's enterprise governance and cross-platform reach) actually maps onto that team's real, current needs.

Whichever platform a given engagement lands on, the transferable skill this four-chapter tour was built to leave behind is the mapping exercise itself: given a fifth platform this series never covers, know which questions to ask — how does it trigger, sequence, and gate; how does it authenticate outward without a stored secret; how, if at all, can a control be made non-bypassable — and go find that platform's own specific answer.