DevOps Lesson 13 of 56

Azure Pipelines, In Depth: YAML Stages, Jobs, Steps, Tasks, Templates, Triggers & Environments

Azure Pipelines is the CI/CD engine inside Azure DevOps, and for a very large slice of the enterprise world it is the pipeline — the one shipping the line-of-business .NET apps, the Java services, the Terraform that builds the cloud they run on. It predates GitHub Actions, it is more verbose, and it carries more conceptual surface area, which is precisely why so many engineers learn it by copying a azure-pipelines.yml that already works and never quite understanding why a job differs from a deployment, why $(var) and ${{ var }} and $[ var ] are three genuinely different things, or why the production approval they were promised is nowhere in the YAML file. That gap is where pipelines break under pressure.

This lesson closes the gap. We walk the azure-pipelines.yml file from the top down and explain every load-bearing part: the four-level hierarchy — stages → jobs → steps → tasks — and how data and dependencies flow across it; every trigger (CI trigger, PR pr, scheduled cron, and resources.pipelines/repositories triggers) with their path and branch filters and the batch setting; pools (Microsoft-hosted vmImage versus self-hosted and scale-set agents); variables at every scope and — the part everyone gets wrong — the three expression syntaxes and exactly when each is evaluated; templates (step, job, stage and extends) with typed parameters; conditions and the dependencies object; Environments with approvals & checks and the deployment strategies (runOnce, rolling, canary); service connections; and Pipeline Artifacts. This is the foundational companion to the advanced lesson on designing multi-stage pipelines with environments, approvals and gates — that one owns the governance mechanics (required-template enforcement, Azure Monitor gates, exclusive locks, workload-identity federation in depth); here we build the ground floor it stands on, and to the vendor-neutral anatomy of CI/CD.

Learning objectives

By the end of this lesson you will be able to:

Prerequisites & where this fits

You should be comfortable with Git basics (commit, branch, pull request — covered in Git in depth), the vendor-neutral anatomy of CI/CD (pipeline → stage → job → step, triggers, agents, artifacts versus cache), and YAML and its gotchas — everything here is YAML and the Norway/octal foot-guns absolutely apply. This lesson sits in the CI/CD module of the DevOps Zero-to-Hero course as the concrete, Azure-specific deep dive that follows the abstract anatomy lesson and runs parallel to the GitHub Actions fundamentals and GitLab CI fundamentals lessons. To do the lab you need a free Azure DevOps organisation (dev.azure.com) and a Git repository in it — both free — and nothing installed locally beyond a browser; the az CLI with the azure-devops extension helps but is optional.

Core concepts: the four-level hierarchy

Azure Pipelines has exactly four nested levels, and almost every confusion comes from blurring them.

Level What it is YAML key Isolation & data
Stage A major phase of the pipeline (Build, DeployDev, DeployProd) an item under stages: Stages run sequentially by default; each can target Environments and carry checks
Job A unit of work that runs on one agent an item under jobs: Each job gets a fresh, clean agent; jobs are isolated — anything shared must be passed via artifacts or output variables
Step A single action: a script or a task an item under steps: Steps in a job share the same agent and filesystem; a file written in step 2 is there in step 3
Task A pre-packaged, versioned unit of logic a step invokes - task: Name@major The reusable building block — DotNetCoreCLI@2, AzureCLI@2, Docker@2

Three facts trip up beginners most. Jobs do not share a filesystem or memory — they run on separate agents, so anything one job produces that another needs must move explicitly (an output variable for small strings, a Pipeline Artifact for files). Steps within a job do share the agent. And a “task” is a kind of step — a step is either a script/bash/pwsh shell step or a task: step; the task is the curated, marketplace-or-built-in version.

There is a fifth, optional concept that sits outside the work hierarchy but governs it: resources (resources: at the top of the file) declare the external things the pipeline depends on — other repositories, container images, packages, and other pipelines — and several of them can act as triggers. We cover them under triggers.

A run is born when a trigger fires. The pipeline is compiled (all ${{ }} template expressions resolve, all templates expand) into a final plan, then executed stage by stage. Crucially, a job that is not a deployment job does check out your source automatically (you can turn it off); a deployment job does not check out by default. Hold that distinction — it is the single most common “why is my repo empty?” surprise.

One repository normally holds one azure-pipelines.yml per pipeline, but a repo can back many pipelines, each pointing at its own YAML file. The file path is chosen when you create the pipeline in the UI, not fixed by convention the way GitHub Actions fixes .github/workflows/.

The pipeline file, top to bottom

A full multi-stage file has a predictable set of top-level keys. Here is the skeleton with the common ones, in the order you usually write them:

name: $(Date:yyyyMMdd)$(Rev:.r)     # the BUILD NUMBER format (not the display name)

trigger:                            # CI trigger (push to these branches)
  branches:
    include: [ main ]

pr:                                 # PR trigger (validation builds)
  branches:
    include: [ main ]

schedules:                          # scheduled (cron) triggers
  - cron: '0 6 * * 1-5'
    displayName: Weekday morning build
    branches: { include: [ main ] }
    always: false

resources:                          # external dependencies & their triggers
  repositories: []
  pipelines: []
  containers: []

variables:                          # pipeline-wide variables
  buildConfiguration: Release

parameters:                         # runtime parameters (compile-time, typed)
  - name: deployProd
    type: boolean
    default: false

pool:                               # default agent pool for all jobs
  vmImage: ubuntu-latest

stages:                             # the work
  - stage: Build
    jobs:
      - job: build
        steps:
          - script: dotnet build -c $(buildConfiguration)
Top-level key Purpose Notes
name The run/build number format string Confusingly not a display name; uses $(Date:…), $(Rev:.r), etc. Defaults to a date.rev.
trigger Continuous-integration (push) trigger Set trigger: none to disable; defaults to all branches if omitted on YAML pipelines created the classic way
pr Pull-request validation trigger GitHub repos honour pr: from YAML; Azure Repos PR triggers are configured via branch policies, not pr:
schedules Cron-based scheduled triggers UTC; replaces the old UI scheduler
resources External repos, pipelines, containers, packages Some double as triggers
parameters Typed runtime parameters Compile-time; appear in the Run dialog; can drive ${{ if }}/${{ each }}
variables Pipeline-scoped variables Can be inline, from a group, or from a template
pool Default agent pool Overridable per stage/job
stages The list of stages Omit it and you can write jobs: directly; omit that too and you can write steps: directly (single implicit stage/job)

A compression rule worth knowing immediately: the hierarchy is collapsible. If your pipeline has one stage you can drop stages: and write jobs: at the top. If it also has one job you can drop jobs: and write steps: at the top. The shortest valid pipeline is just a pool: and steps:. As soon as you need a second stage (e.g. a deploy), you must expand back to the full stages → jobs → steps form.

Triggers: every way a pipeline starts

The triggers are the single most important design decision in a pipeline — they decide when it runs and what it gets. There are four families: CI (push), PR (pull-request validation), scheduled (cron), and resource triggers (another pipeline, a repo, or a container image changing).

CI triggers — trigger

trigger fires on a push to the branches you list. It supports branch, tag and path filters, plus batch.

trigger:
  batch: true               # while a run is in progress, batch new pushes into one next run
  branches:
    include:
      - main
      - release/*           # wildcard: release/1.0, release/2.x
    exclude:
      - release/old/*
  tags:
    include:
      - v*                  # also fire when a matching tag is pushed
  paths:
    include:
      - src/*
    exclude:
      - docs/*
      - '**/*.md'
trigger key Effect
branches.include / branches.exclude Which branches’ pushes start a run (wildcards * and ** allowed)
tags.include / tags.exclude Fire on matching tag pushes
paths.include / paths.exclude Only run when changed files match — the monorepo lever
batch true collapses pushes that arrive while a run is in flight into a single subsequent run (saves agent minutes on busy branches)

The forms and the gotchas:

PR triggers — pr

pr runs a validation build when a pull request is opened or updated. This only works for GitHub and Bitbucket Cloud repositories. For Azure Repos, PR triggers are not expressed in YAML — you configure them as branch policies on the target branch (a “Build validation” policy that points at the pipeline). This is a frequent surprise for newcomers moving between repo types.

pr:
  autoCancel: true          # cancel the in-progress validation run when the PR gets a new push
  drafts: false             # do NOT run on draft PRs
  branches:
    include: [ main, release/* ]
  paths:
    include: [ src/* ]
pr key Effect
branches.include/exclude Which target branches’ PRs trigger validation
paths.include/exclude Only validate when changed files match
autoCancel true (default) cancels a running validation when the PR is updated
drafts true (default) runs on draft PRs; set false to skip until ready

Set pr: none to disable PR validation from YAML.

Scheduled triggers — schedules

Cron schedules live under schedules: (the modern, in-YAML way; the old UI-only scheduler still exists but YAML is preferred).

schedules:
  - cron: '0 3 * * *'             # 03:00 UTC daily
    displayName: Nightly build
    branches:
      include: [ main ]
    always: true                  # run even if there were no code changes since last run
  - cron: '0 6 * * 1-5'           # 06:00 UTC weekdays
    displayName: Weekday smoke
    branches: { include: [ main, release/* ] }
    always: false
schedules key Effect
cron Standard 5-field cron (minute, hour, day-of-month, month, day-of-week), UTC
displayName Friendly label in the runs list
branches.include/exclude Which branches’ YAML is used and built
always false (default) skips the scheduled run if nothing changed since the last successful scheduled run; true forces it every time

The five fields are always UTC — a 09:00 IST job is cron: '30 3 * * *'. The default always: false is the surprise: a nightly build on a quiet repo simply won’t run unless code changed, which is usually not what you want for a nightly — set always: true for true nightlies.

Resource triggers — pipelines, repositories, containers

resources: declares external dependencies, and three of them can also start this pipeline.

Pipeline completion trigger — run this pipeline when another pipeline finishes (classic build-then-release split):

resources:
  pipelines:
    - pipeline: ci                 # local alias used to download its artifacts
      source: app-ci               # the name of the upstream pipeline
      trigger:
        branches:
          include: [ main ]

With this, when app-ci completes on main, this pipeline runs and can download: ci to fetch the upstream artifacts. Omitting trigger: declares the dependency (for artifact download) without auto-running.

Repository resource trigger — fire when another repo changes (also how you consume templates from another repo):

resources:
  repositories:
    - repository: templates        # local alias
      type: git                    # git (Azure Repos) | github | bitbucket
      name: Platform/pipeline-templates
      ref: refs/tags/v3            # pin to a tag, not a branch
      trigger:
        branches: { include: [ main ] }

Container resource — declare a container the jobs run in or that triggers on a new image:

resources:
  containers:
    - container: linux
      image: mcr.microsoft.com/dotnet/sdk:8.0
Resource type Use
resources.pipelines Depend on / trigger from another pipeline; download its artifacts
resources.repositories Consume templates or check out another repo; trigger on its changes
resources.containers Run a job inside a container (container: on the job) or trigger on image push (ACR)
resources.packages Consume NuGet/npm packages as a typed resource

Pools: where jobs run

Every job runs on an agent drawn from a pool. There are two kinds: Microsoft-hosted (ephemeral VMs Microsoft creates fresh per job and destroys after) and self-hosted (machines or a VM scale set you register yourself).

# Microsoft-hosted, by image
pool:
  vmImage: ubuntu-latest

# Self-hosted pool, optionally constrained by demands (capabilities)
pool:
  name: linux-selfhosted
  demands:
    - Agent.OS -equals Linux
    - docker                     # the agent must advertise a 'docker' capability

pool: can sit at the pipeline (default for all jobs), stage, or job level — the most specific wins.

Pool field Meaning
vmImage Selects a Microsoft-hosted image (e.g. ubuntu-latest, windows-latest, macos-latest, or a pinned ubuntu-24.04)
name Selects a self-hosted (or scale-set) pool by name
demands Required agent capabilities — a job only runs on an agent that satisfies all demands (exists or -equals)

The Microsoft-hosted images in 2026 and what they map to:

vmImage OS
ubuntu-latest Latest Ubuntu LTS image (moves forward; pin ubuntu-24.04 / ubuntu-22.04 for stability)
windows-latest Latest Windows Server image (pin windows-2022)
macos-latest Latest macOS image (Apple-silicon based on latest; pin macos-14)

Microsoft-hosted versus self-hosted, the decision that comes up in every interview:

Microsoft-hosted Self-hosted / scale-set
Provisioning Fresh VM per job, auto-destroyed You install/maintain the agent; long-lived (or scale-set ephemeral)
Clean environment Always pristine Persists unless you clean it (workspace: clean)
Speed of first run Slower (cold image + tool install) Faster (warm caches, pre-installed tools)
Network access Public internet; no private-network reach Can sit inside your VNet to reach private resources
Free minutes Limited free parallel job + monthly minutes You pay for the VMs, not Azure DevOps minutes
Best for Public/standard builds, no private deps Private-network deploys, special hardware, heavy caches

Self-hosted scale-set agents (an Azure VM scale set Azure DevOps autoscales for you) give you the private-network and warm-tool benefits without hand-managing VMs — the deep operational treatment (image hardening, autoscale tuning, ephemeral agents) is its own lesson, self-hosted scale-set agents and hardening. For this foundational lesson, internalise: vmImage ⇒ Microsoft-hosted; name ⇒ self-hosted; demands ⇒ capability matching.

Pin the OS image (ubuntu-24.04, not ubuntu-latest) for anything reproducible — *-latest moves to the next LTS under you when Microsoft rolls the image, which can silently break a build with no code change.

Jobs: the full option set

A job declares the agent it runs on and the steps it executes; everything else controls ordering, parallelism, conditions, timeouts and workspace.

jobs:
  - job: build
    displayName: Build & Test
    pool: { vmImage: ubuntu-latest }
    dependsOn: [ lint ]                 # run after these jobs
    condition: succeeded()             # default condition (only if deps succeeded)
    timeoutInMinutes: 30               # kill the job after N minutes (0 = max allowed)
    cancelTimeoutInMinutes: 5          # grace period on cancellation
    continueOnError: false             # if true, a failed job is "succeeded with issues"
    workspace:
      clean: outputs                   # outputs | resources | all — clean before run (self-hosted)
    variables:
      LOG_LEVEL: debug
    strategy:                          # matrix / parallel / maxParallel
      maxParallel: 4
      matrix:
        py311: { PY_VERSION: '3.11' }
        py312: { PY_VERSION: '3.12' }
    steps:
      - script: echo "Building on $(PY_VERSION)"

dependsOn and the job graph

By default, all jobs in a stage run in parallel. dependsOn orders them into a DAG; dependsOn: [] (empty) explicitly says “no dependencies, start immediately”. A job with dependsOn: [a, b] waits for both.

jobs:
  - job: a
    steps: [ { script: echo a } ]
  - job: b
    steps: [ { script: echo b } ]
  - job: c
    dependsOn: [ a, b ]              # fan-in
    steps: [ { script: echo c } ]

Stages have their own dependsOn (default: each stage depends on the previous one, so they run sequentially). Setting dependsOn: [] on a stage makes it start at the very beginning in parallel with others — the way you fan out stages.

strategy: matrix, parallel, and maxParallel

A non-deployment job’s strategy has three forms:

# 1) matrix — run the job once per named combination
strategy:
  maxParallel: 3
  matrix:
    linux:   { imageName: ubuntu-latest }
    windows: { imageName: windows-latest }
    mac:     { imageName: macos-latest }
# then: pool: { vmImage: $(imageName) }

# 2) parallel — run N identical copies (e.g. to shard tests)
strategy:
  parallel: 5

# 3) maxParallel alone — cap concurrency of the matrix
strategy form Effect
matrix Named legs, each setting variables; pool.vmImage: $(var) lets one job target many OSes
parallel: N N identical job copies (slice tests by System.JobPositionInPhase / System.TotalJobsInPhase)
maxParallel Caps how many matrix/parallel legs run at once

Unlike GitHub Actions, the Azure matrix is an explicit map of named legs (you name linux, windows, …), not a Cartesian product — you list exactly the combinations you want, which is more verbose but unambiguous.

Output variables across jobs

A value computed in one job reaches another only by being set as an output variable and consumed via dependencies:

jobs:
  - job: setup
    steps:
      - bash: echo "##vso[task.setvariable variable=tag;isOutput=true]sha-$(Build.SourceVersion)"
        name: meta                          # the STEP needs a name to reference its output
  - job: use
    dependsOn: setup
    variables:
      builtTag: $[ dependencies.setup.outputs['meta.tag'] ]   # runtime expression
    steps:
      - script: echo "Building $(builtTag)"

Two non-obvious requirements: the step must have a name: (the reference is outputs['<stepName>.<varName>']), and you read it with the runtime $[ ] syntax (covered next), because the value only exists at runtime. Across stages the path changes to stageDependencies.<Stage>.<Job>.outputs['<step>.<var>'].

Variables and the three expression syntaxes

This is the part of Azure Pipelines that most distinguishes it from other systems and most often confuses people. There are three ways to reference a value, and they differ by when they are evaluated — which determines what they can do.

Syntax Name Evaluated Can do Cannot do
${{ variables.x }} Template expression (compile-time) When the YAML is parsed/compiled, before the run Drive ${{ if }}/${{ each }}, choose templates, expand loops See anything decided at runtime (e.g. another job’s output)
$(x) Macro syntax (runtime, textual) At runtime, by textual substitution just before a step runs Inject variable values into inputs: and scripts Be used to make decisions in condition: (it’s already substituted text); doesn’t exist if the var is undefined (left literal)
$[ x ] Runtime expression At runtime, when the stage/job is evaluated Compute condition: and variables: values from runtime data (e.g. dependencies.*) Be used inside arbitrary script text the way $(x) is

The mental model:

A worked contrast:

parameters:
  - name: env
    type: string
    default: dev

variables:
  # compile-time: chosen when YAML compiles, from a parameter
  isProd: ${{ eq(parameters.env, 'prod') }}
  # runtime: read another job's output (only known while running)
  upstreamTag: $[ dependencies.build.outputs['meta.tag'] ]

steps:
  # runtime textual substitution into a command
  - script: ./deploy.sh --tag $(upstreamTag) --config $(buildConfiguration)
  # compile-time decision: this step only EXISTS in the plan when env==prod
  - ${{ if eq(parameters.env, 'prod') }}:
      - script: ./extra-prod-guard.sh

Note the ${{ if }} step vanishes from the compiled pipeline entirely when the condition is false — it isn’t “skipped at runtime”, it was never there. A condition: (with $[ ] or functions) is there but evaluates to skip at runtime. That difference matters for logs, for dependencies, and for what reviewers see.

Variable scopes and kinds

Variables can be declared at pipeline, stage, or job scope, and come from three sources:

variables:
  - name: buildConfiguration          # 1) inline name/value
    value: Release
  - group: shared-secrets             # 2) a VARIABLE GROUP (Library; can be Key Vault-backed)
  - template: vars/common.yml         # 3) a variable TEMPLATE
Variable source What it is
Inline (name/value or key: value) Plain pipeline variables
Variable group (- group:) Shared values from the Library, optionally linked to Azure Key Vault so secrets resolve at runtime
Variable template (- template:) A reusable YAML file of variable definitions
Predefined System-provided, e.g. Build.SourceBranch, Build.BuildId, Agent.OS, System.DefaultWorkingDirectory
Queue-time Variables marked “Settable at queue time” that a user can override in the Run dialog
Output variables Set by a step with isOutput=true, consumed via dependencies

Secret variables are special. A secret variable (set in the UI/variable group as secret, or via Key Vault) is not auto-mapped into the environment as $(SECRET) for scripts — you must map it explicitly to avoid accidental leakage:

steps:
  - bash: ./deploy.sh
    env:
      API_KEY: $(apiKeySecret)         # explicitly map the secret into the step's env

Secrets are masked in logs (replaced with ***) when they appear verbatim — derived/encoded forms can still leak, so never echo a secret. For the disciplined treatment of stores, rotation, and the “secrets in Git” cardinal sin, see secrets & configuration management.

Setting variables at runtime

A step sets a variable for later steps (and optionally as a job output) with a logging command:

steps:
  - bash: |
      echo "##vso[task.setvariable variable=version]1.4.2"             # for later steps in this job
      echo "##vso[task.setvariable variable=tag;isOutput=true]abc123"  # also a job OUTPUT
    name: setvars
  - bash: echo "version is $(version)"
Logging command Effect
##vso[task.setvariable variable=x]value Set x for subsequent steps in the job
…;isOutput=true]value Also expose it as a job output (dependencies.<job>.outputs['<step>.x'])
…;issecret=true]value Mark the new variable as secret (masked)
##vso[build.addbuildtag]nightly Tag the run
##vso[task.setvariable variable=x;isreadonly=true]v Make it read-only

Steps and tasks: the unit of work

Inside a job, steps: is an ordered list. Each step is one of a small set of shortcuts or a full task:.

steps:
  - checkout: self                 # control source checkout (see below)

  - script: |                      # cmd on Windows, bash on Linux/macOS
      echo "hello"
    displayName: A script step
    workingDirectory: $(Build.SourcesDirectory)/app
    env: { CI: 'true' }
    condition: succeeded()
    continueOnError: false
    timeoutInMinutes: 10
    failOnStderr: true

  - bash: ./run.sh                 # always bash
  - pwsh: ./run.ps1                # PowerShell Core (cross-platform)
  - powershell: ./run.ps1          # Windows PowerShell

  - task: DotNetCoreCLI@2          # a TASK (versioned by @major)
    displayName: Publish
    inputs:
      command: publish
      publishWebProjects: true
      arguments: '-c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory)'
Step shortcut Expands to
script: CmdLine@2 task — cmd on Windows, bash on Unix
bash: Bash@3 task — always Bash
pwsh: PowerShell@2 with pwsh: true — cross-platform PowerShell
powershell: PowerShell@2 — Windows PowerShell
checkout: The repository-checkout step
download: / publish: Pipeline Artifact download/publish (see Artifacts)
task: Any built-in or marketplace task, e.g. AzureCLI@2, Docker@2

Common step keys: displayName, name (the referenceable id for outputs), condition, continueOnError, timeoutInMinutes, enabled, env, workingDirectory, and (for scripts) failOnStderr.

checkout: the source-control step

This is where the “empty workspace” surprises live:

steps:
  - checkout: self            # check out THIS repo (the default for normal jobs)
    clean: true               # delete untracked files first
    fetchDepth: 1             # shallow clone (faster); 0 = full history
    fetchTags: false          # skip fetching tags
    persistCredentials: true  # keep the token so later git commands can auth
    submodules: recursive     # also pull submodules
  - checkout: templates       # also check out a repository RESOURCE alias
checkout value/key Effect
self Check out the pipeline’s own repo (implicit for normal jobs)
none Check out nothing
<alias> Check out a resources.repositories repo
fetchDepth Shallow-clone depth; 0 = full history (needed for tag/version tooling)
clean Clean the working tree before checkout
persistCredentials Leave the system token available to subsequent git commands
submodules true/recursive to fetch submodules

The rules that bite: a normal job checks out self automatically (you only write checkout: to change its options or add more repos), but a deployment job checks out nothing by default — add - checkout: self if a deploy needs the repo. And if you list any explicit checkout: step, the implicit self is no longer added — list every repo you need.

Templates: factoring the pipeline

Copy-pasting YAML across pipelines is how they rot. Templates let you extract reusable fragments at four granularities, all with typed parameters. (This lesson covers authoring templates; the governance angle — required-template enforcement on protected resources — lives in the multi-stage/approvals lesson.)

Template kind Reuses Inserted with
Step template A list of steps - template: file.yml under steps:
Job template A list of jobs - template: file.yml under jobs:
Stage template A list of stages - template: file.yml under stages:
Variable template A list of variables - template: file.yml under variables:
extends template The whole pipeline shape extends: { template: file.yml } at the top level

Typed parameters

Templates take parameters (compile-time, strongly typed — far safer than untyped variables). The full type set:

# templates/build.yml
parameters:
  - name: project           # string (the default type)
    type: string
  - name: runTests
    type: boolean
    default: true
  - name: poolImage
    type: string
    default: ubuntu-latest
    values: [ ubuntu-latest, windows-latest ]   # restrict to a set (validated)
  - name: regions
    type: object            # list or map
    default: [ ]
  - name: buildSteps
    type: stepList          # the consumer passes in steps!
    default: [ ]

steps:
  - script: dotnet build ${{ parameters.project }}
  - ${{ if eq(parameters.runTests, true) }}:
      - script: dotnet test ${{ parameters.project }}
  - ${{ each region in parameters.regions }}:
      - script: ./deploy.sh ${{ region }}
  - ${{ parameters.buildSteps }}     # splice in caller-provided steps
Parameter type Holds
string A string (default if type omitted)
number A number
boolean true/false
object An arbitrary list or map
step / stepList A single step / a list of steps (consumer injects steps)
job / jobList A job / list of jobs
deployment / deploymentList A deployment job / list
stage / stageList A stage / list of stages
environment An environment reference

Adding values: [ … ] makes the parameter an enum the platform validates at compile time — a typo fails the run early. Parameters are referenced with ${{ parameters.name }} and drive ${{ if }}/${{ each }} because they are compile-time.

Consuming a step/job/stage template

# azure-pipelines.yml
stages:
  - stage: Build
    jobs:
      - job: build
        steps:
          - template: templates/build.yml      # local file
            parameters:
              project: src/App.csproj
              runTests: true

To consume a template from another repository, declare it as a resources.repositories alias and reference file.yml@alias:

resources:
  repositories:
    - repository: templates
      type: git
      name: Platform/pipeline-templates
      ref: refs/tags/v3                # pin to a tag, never a moving branch

stages:
  - template: stages/deploy.yml@templates
    parameters: { serviceName: payments-api }

extends templates

extends inverts control: instead of the pipeline including a fragment, the pipeline extends a template that owns the overall shape and exposes only the hooks the consumer may fill. This is the mechanism behind required-template governance.

# consumer pipeline
extends:
  template: pipeline.yml@templates
  parameters:
    serviceName: payments-api
    buildSteps:
      - script: dotnet build
      - script: dotnet test
# templates/pipeline.yml — owns the shape, accepts hooks
parameters:
  - name: serviceName
    type: string
  - name: buildSteps
    type: stepList
    default: [ ]
stages:
  - stage: Build
    jobs:
      - job: build
        steps:
          - ${{ parameters.buildSteps }}     # consumer's steps spliced here
          - script: ./mandatory-scan.sh      # the template ENFORCES this step

The power: a platform team can guarantee that every pipeline extending this template runs the mandatory scan, and by attaching a required-template check to a protected Environment, can make extending it the only way to deploy there — the deep dive is in the advanced lesson. For this lesson, the takeaway is: template: includes a fragment the author still controls; extends: hands control to a template that controls the author.

Conditions and the dependencies object

condition: decides whether a stage, job, or step runs, evaluated at runtime. The default is succeeded() — only run if everything it depends on succeeded. Once you set any explicit condition, you replace that default, so you must re-state succeeded() if you still want it.

stages:
  - stage: Deploy
    dependsOn: Build
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - job: deploy
        steps:
          - script: ./deploy.sh
          - script: ./notify-failure.sh
            condition: failed()           # only on failure

The status functions, usable in condition::

Function True when
succeeded() All dependencies succeeded (the implicit default)
failed() A dependency failed
succeededOrFailed() Ran regardless of success/failure (but not if cancelled)
always() Always — even on failure/cancellation (use for cleanup)
canceled() The run was cancelled

And the helpers: eq, ne, and, or, not, in, notIn, contains, startsWith, endsWith, coalesce, counter.

Reading dependency results in a condition uses the dependencies object (jobs in a stage) or stageDependencies (across stages):

  - job: report
    dependsOn: [ test, build ]
    condition: always()
    variables:
      testResult: $[ dependencies.test.result ]     # Succeeded | Failed | Skipped | Canceled
    steps:
      - script: echo "test result was $(testResult)"
Object Reads
dependencies.<job>.result A needed job’s outcome (within the stage)
dependencies.<job>.outputs['<step>.<var>'] A needed job’s output variable
stageDependencies.<Stage>.<Job>.result A job’s result across stages
stageDependencies.<Stage>.<Job>.outputs['<step>.<var>'] Cross-stage output variable

A classic trap: because the default condition is succeeded(), a “cleanup” job downstream of a failing one is skipped unless you give it condition: always() (or succeededOrFailed()). Be explicit.

Environments, deployment jobs, approvals and checks

A deployment job (- deployment: instead of - job:) is the specialised job for releasing. It does three things a normal job cannot: it targets an Environment (recording deployment history against it), it unlocks deployment strategies (runOnce/rolling/canary with lifecycle hooks), and it is the resource that approvals and checks gate.

stages:
  - stage: DeployProd
    dependsOn: DeployTest
    jobs:
      - deployment: deploy
        displayName: Deploy to production
        environment: prod                 # the Environment (created on first run if absent)
        pool: { vmImage: ubuntu-latest }
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current        # fetch this run's published artifact
                  artifact: app
                - script: ./deploy.sh

Approvals & checks — not in the YAML

The single most important conceptual point, and the reason this lesson complements the advanced one: approvals and gates are not pipeline YAML. They are checks attached to a protected resource — almost always an Environment (configured under Pipelines → Environments → … → Approvals and checks), sometimes a service connection or variable group. The pipeline declares what it deploys and where; the Environment’s checks decide whether it may proceed. This is deliberate: a developer editing azure-pipelines.yml cannot delete a production approval, because the approval lives on the Environment under a different permission.

Check (on the Environment) What it does
Approvals Pause until designated users/groups approve (set timeout; disable self-approval for four-eyes)
Business hours Only pass within a time window/zone
Exclusive lock Only one run through the Environment at a time
Invoke Azure Function / REST API Automated gate against an external system
Query Azure Monitor alerts Block while an alert is firing
Required template Reject any pipeline that doesn’t extends an approved template
Evaluate artifact / Branch control Restrict which artifacts/branches may deploy

Checks run before the deployment job’s agent is acquired — a blocked approval costs zero agent minutes. The exhaustive treatment of every check, their timeouts and retry cadences, exclusive-lock blast-radius pitfalls, and codifying them with Terraform is in the advanced multi-stage/approvals lesson.

An auto-created Environment (created implicitly the first time the YAML names it) has no checks. Always pre-create production Environments and attach checks before a pipeline can target them, or the first prod run sails straight through ungated.

Deployment strategies

The strategy: of a deployment job picks how the release rolls out and which lifecycle hooks are available. Hooks run in order: preDeploy → deploy → routeTraffic → postRouteTraffic → on: { failure | success }.

strategy:
  runOnce:                       # simplest: deploy once
    preDeploy:   { steps: [ { script: ./pre.sh } ] }
    deploy:      { steps: [ { download: current, artifact: app }, { script: ./deploy.sh } ] }
    routeTraffic:{ steps: [ { script: ./route.sh } ] }
    postRouteTraffic: { steps: [ { script: ./smoke.sh } ] }
    on:
      failure:   { steps: [ { script: ./rollback.sh } ] }
      success:   { steps: [ { script: ./notify.sh } ] }
Strategy Behaviour Extra keys
runOnce Run each lifecycle hook once
rolling Roll out to agents/VMs in batches maxParallel (count or %) — how many targets at a time
canary Release in increasing increments, pausing between increments: [ 10, 20, ... ] (percentages per wave)

rolling and canary are primarily for VM and Kubernetes environment resources (where there are multiple targets to roll across). The hooks are where you wire health checks: e.g. run an Azure Monitor probe in postRouteTraffic and roll back in on.failure. Inside a deployment job there is no implicit checkoutdownload your artifacts (or add - checkout: self).

Service connections: authenticating to the outside

Pipelines reach external systems — Azure, AWS, Docker registries, Kubernetes clusters, SonarQube — through service connections (created under Project Settings → Service connections), referenced by name in the relevant task.

steps:
  - task: AzureCLI@2
    inputs:
      azureSubscription: payments-prod-sc    # the service-connection name
      scriptType: bash
      scriptLocation: inlineScript
      inlineScript: az group list -o table
Service-connection type Connects to
Azure Resource Manager An Azure subscription/resource group (the most common)
Docker Registry / ACR A container registry for Docker@2 push/pull
Kubernetes A cluster for KubernetesManifest@1/Kubernetes@1
GitHub / generic Git External repos
Generic / SSH / npm / NuGet / SonarQube / AWS / GCP Their respective systems

The security-critical choice for the Azure RM connection is its authentication scheme:

Auth scheme What it stores Verdict
Workload Identity Federation (OIDC) Nothing — short-lived OIDC tokens exchanged with Entra ID Preferred — no secret to leak or rotate
Service principal (secret) A client secret that expires Legacy; rotate or migrate
Managed identity Uses the agent’s identity (self-hosted) Good for self-hosted in Azure

Use Workload Identity Federation for new Azure connections — Azure DevOps presents a short-lived OIDC token to Microsoft Entra ID, which exchanges it for an access token via a federated credential; nothing is stored and nothing expires under you. A service connection is itself a protected resource and can carry its own approval check. The deep mechanics of WIF and converting existing secret-based connections are in the advanced lesson.

Artifacts: moving build outputs

Because jobs and stages run on separate agents, files produced in one reach another only as a Pipeline Artifact. The modern shortcuts are publish (upload) and download (fetch).

stages:
  - stage: Build
    jobs:
      - job: build
        steps:
          - script: dotnet publish -o $(Build.ArtifactStagingDirectory)
          - publish: $(Build.ArtifactStagingDirectory)   # upload
            artifact: app
  - stage: Deploy
    dependsOn: Build
    jobs:
      - deployment: deploy
        environment: dev
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current        # download THIS run's artifacts
                  artifact: app
                - script: ls $(Pipeline.Workspace)/app
Shortcut Expands to Notes
publish: <path> + artifact: <name> PublishPipelineArtifact@1 Uploads a folder as a named Pipeline Artifact
download: current DownloadPipelineArtifact@2 Fetches artifacts from the current run into $(Pipeline.Workspace)
download: <pipelineAlias> same Fetches artifacts from a pipeline resource (build-then-release)
download: none Suppress the automatic download in deployment jobs

Deployment jobs auto-download the current run’s artifacts (and a pipeline-resource’s) unless you say download: none — another reason a deploy doesn’t checkout source: it expects built artifacts, not the repo.

The artifact landscape, briefly:

Artifact kind Use
Pipeline Artifacts (publish/download) Pass build outputs between jobs/stages and into the next pipeline — the default
Build Artifacts (PublishBuildArtifacts@1) The older mechanism; Pipeline Artifacts are faster and preferred
Universal Packages (UniversalPackages@0) Versioned binary packages in Azure Artifacts feeds, for cross-pipeline/long-lived sharing
Pipeline Caching (Cache@2) Speed up runs by restoring dependency directories (keyed by a hash) — an optimisation, not a deliverable; a miss is harmless

Pipeline Artifacts versus Universal Packages is the interview distinction: a Pipeline Artifact is scoped to moving outputs within or directly between pipelines; a Universal Package is a versioned, independently consumable package living in a feed, for when many consumers over time need it. And caching is orthogonal — “I might need this again to go faster” — never a substitute for an artifact (“I produced this and something downstream needs it”).

Diagram: the anatomy of an Azure Pipelines run

Azure Pipelines anatomy — a trigger starts the pipeline; stages contain jobs that run on agents from pools; deployment jobs target Environments gated by approvals and checks; steps run tasks; data crosses boundaries via output variables and Pipeline Artifacts, and clouds are reached through service connections

The diagram traces a single run from the top: a trigger (CI/PR/scheduled/resource) matches and compiles the pipeline (all ${{ }} template expressions resolve, all templates expand); stages run on the dependsOn graph; each stage’s jobs are scheduled onto agents from a pool (Microsoft-hosted or self-hosted); a deployment job targets an Environment whose approvals & checks gate it before any agent is acquired; each job’s steps run tasks; and data crosses boundaries the only ways it can — output variables between jobs/stages, Pipeline Artifacts for files — while service connections authenticate the reach into Azure and registries. Keep this picture in mind whenever a deploy “can’t find” something a build made, or a job’s workspace is mysteriously empty.

Hands-on lab

You’ll build a small but complete multi-stage pipeline in a free Azure DevOps organisation: a Build stage that publishes an artifact, a DeployDev stage with a deployment job that downloads it, an Environment with a manual approval, an output variable passed across stages, a matrix job, and conditions. Everything here fits the free tier.

Step 1 — Create an organisation, project and repo

  1. Go to https://dev.azure.com and sign in (free). Create a new organisation if you don’t have one, then a project named pipelines-lab (Git, private is fine).
  2. In Repos, initialise the repo with a README so it isn’t empty.

Step 2 — Add a tiny app and the pipeline file

Add two files via the web editor (Repos → Files → ⋯ → New file), or clone and push.

app.sh (our “build” and “deploy” stand-ins):

#!/usr/bin/env bash
echo "built artifact at $(date -u)" > app.txt
echo "BUILD OK"

azure-pipelines.yml:

name: $(Date:yyyyMMdd)$(Rev:.r)

trigger:
  branches:
    include: [ main ]

pr:
  branches:
    include: [ main ]

pool:
  vmImage: ubuntu-latest

variables:
  buildConfiguration: Release

stages:
  - stage: Build
    displayName: Build & Test
    jobs:
      - job: build
        displayName: Build (matrix)
        strategy:
          maxParallel: 2
          matrix:
            linux:   { TARGET: linux }
            alpine:  { TARGET: alpine }
        steps:
          - script: |
              chmod +x app.sh && ./app.sh
              echo "##vso[task.setvariable variable=builtBy;isOutput=true]$(TARGET)"
            name: builder
            displayName: Build for $(TARGET)
          - publish: $(System.DefaultWorkingDirectory)/app.txt
            artifact: app-$(TARGET)        # unique per matrix leg

  - stage: DeployDev
    displayName: Deploy to dev
    dependsOn: Build
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: deploy
        displayName: Deploy
        environment: dev                   # auto-created on first run
        variables:
          fromBuild: $[ stageDependencies.Build.build.outputs['linux.builder.builtBy'] ]
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: app-linux
                - script: |
                    echo "Deploying artifact built by: $(fromBuild)"
                    cat $(Pipeline.Workspace)/app-linux/app.txt
                  displayName: Deploy step

Step 3 — Create the pipeline

In Pipelines → Pipelines → New pipeline, choose Azure Repos Git, select the repo, then Existing Azure Pipelines YAML file, pick /azure-pipelines.yml, and Run.

Expected behaviour:

Step 4 — Add an approval and see it gate

  1. Go to Pipelines → Environments → dev → ⋯ → Approvals and checks → + → Approvals. Add yourself as an approver and save.
  2. Re-run the pipeline. This time, when Build finishes, DeployDev pauses as “Waiting” — the deployment job’s agent is not consumed yet.
  3. Approve it from the run page. The deploy proceeds. You have just seen the load-bearing fact: the approval is on the Environment, not in the YAML — nothing in your file changed.

Step 5 — Exercise the conditions

  1. Create a branch feature/x, edit app.sh, and open a pull request into main. The PR trigger runs Build (validation), but DeployDev is skipped (the condition requires main).
  2. Push a second commit to the PR branch and watch the in-progress validation auto-cancel (the default pr.autoCancel).

Step 6 — Validation

# Optional: with the az CLI + azure-devops extension
az extension add --name azure-devops
az pipelines runs list --top 3 \
  --org https://dev.azure.com/<your-org> --project pipelines-lab \
  --query "[].{id:id, status:status, result:result, num:buildNumber}" -o table

A successful lab: a green multi-stage run, two parallel build legs, two artifacts, the deploy printing the cross-stage output variable, the approval pausing the deploy stage with no agent consumed, and the PR run skipping the deploy stage.

Cleanup

Azure DevOps charges nothing for what you used here. To tidy up: delete the dev Environment (Environments → dev → ⋯ → Delete), delete the pipeline (Pipelines → ⋯ → Delete), and optionally delete the project (Project Settings → Overview → Delete). Pipeline Artifacts expire on their own per the retention policy.

Cost note

Every Azure DevOps organisation includes one free Microsoft-hosted parallel job with a monthly minutes allowance for private projects (public projects get more free parallelism). This lab uses well under that. The two matrix legs run in parallel only if you have parallel capacity; on the single free parallel job they queue and run one after another — correct, just slower. Self-hosted agents don’t consume the hosted-minutes allowance at all (you pay for the VMs instead).

Common mistakes & troubleshooting

Symptom Likely cause Fix
Deployment job’s workspace is empty / “file not found” Deployment jobs don’t checkout and you didn’t download Add - download: current (or - checkout: self if you need the repo)
PR trigger never fires on an Azure Repos repo pr: is ignored for Azure Repos Configure a Build validation branch policy on the target branch instead
A condition like eq(variables.x,'y') behaves oddly Mixed up $( ), ${{ }}, $[ ] Use ${{ }} for compile-time, $[ ] for runtime conditions, $( ) only for textual substitution
Cross-job/stage output variable is empty Missing step name:, wrong path, or wrong syntax Reference dependencies.<job>.outputs['<stepName>.<var>'] with $[ ]; use stageDependencies across stages
Cleanup job/step skipped after a failure Default condition is succeeded() Add condition: always() (or succeededOrFailed())
Secret variable is blank in a script Secrets aren’t auto-mapped to the environment Map it explicitly: env: { KEY: $(secretVar) }
First production deploy ran with no approval Environment was auto-created with no checks Pre-create the Environment and attach checks before targeting it
${{ if }} step “doesn’t run” even when true at runtime ${{ }} is compile-time — it can’t see runtime values Use a runtime condition: with $[ ]/functions instead
Matrix artifact upload collides Same artifact: name across matrix legs Make the name unique, e.g. app-$(TARGET)
Build “broke” with no code change vmImage: ubuntu-latest moved to a new image Pin the image (ubuntu-24.04) for reproducibility
Nightly scheduled build never runs always: false and no changes since last run Set always: true on the schedule

Best practices

Security notes

Interview & exam questions

1. Describe the Azure Pipelines hierarchy and what isolation each level has. Stages → jobs → steps → tasks. Stages run sequentially by default (each on its own dependsOn). Each job runs on a fresh, isolated agent; jobs share nothing, so data moves via output variables (strings) or Pipeline Artifacts (files). Steps within a job share the agent’s filesystem. A task is a packaged, versioned kind of step.

2. What are the three expression syntaxes, and how do they differ? ${{ }} is a compile-time template expression — evaluated when the YAML is parsed, used to shape the pipeline (if/each, choosing templates); it can’t see runtime values. $( ) is macro/runtime textual substitution — the variable’s text is spliced into a command/input just before the step runs. $[ ] is a runtime expression — evaluated at runtime for condition: and variables: values, where you need functions and the dependencies object.

3. A normal job sees the repo but my deployment job’s workspace is empty — why? Normal jobs check out self automatically; deployment jobs do not check out anything by default (they’re meant to consume built artifacts). Add - download: current for artifacts, or - checkout: self if the deploy genuinely needs the source.

4. How do you pass a value from one job to another, and across stages? The producing step sets an output variable (##vso[task.setvariable variable=x;isOutput=true]v) and has a name:. The consumer references it with $[ dependencies.<job>.outputs['<step>.x'] ] within a stage, or $[ stageDependencies.<Stage>.<Job>.outputs['<step>.x'] ] across stages.

5. Where do approvals live, and why not in the YAML? On the Environment (or another protected resource) as a check, under separate permissions. Keeping them off the YAML means a developer editing azure-pipelines.yml cannot remove a production gate, and checks run before an agent is acquired, so a blocked approval costs no agent minutes.

6. Microsoft-hosted vs self-hosted agents — when each? Microsoft-hosted: ephemeral, always-clean VMs, free minutes, no maintenance — for standard builds with no private dependencies. Self-hosted (or scale-set): you maintain them, but they can sit inside your VNet (reach private resources), keep warm caches/tools, and use special hardware — for private-network deploys and heavy/specialised builds.

7. What is the difference between a template: include and an extends: template? template: includes a reusable fragment (steps/jobs/stages/variables) into a pipeline the author still fully controls. extends: hands control to a template that owns the whole pipeline shape and exposes only parameterised hooks — the basis for required-template governance, where a protected Environment can mandate that every pipeline deploying to it extends an approved template.

8. How do CI, PR, and scheduled triggers differ, and what’s the Azure Repos PR caveat? trigger fires on pushes (with branch/tag/path filters and batch). pr fires on pull-request updates — but only for GitHub/Bitbucket; Azure Repos PR validation is set via branch policies, not YAML. schedules fire on cron (UTC), with always controlling whether they run when nothing changed.

9. What does batch: true do on a CI trigger? While a run is in progress on the branch, new pushes are collected rather than each starting a run; when the current run finishes, a single run executes covering all batched changes. It trades latency for fewer, larger runs on busy branches.

10. Name the deployment strategies and a use for the lifecycle hooks. runOnce (deploy once), rolling (batches of targets, maxParallel), canary (incremental waves, increments). Hooks preDeploy → deploy → routeTraffic → postRouteTraffic → on.{failure,success} let you, e.g., run a health probe in postRouteTraffic and roll back in on.failure.

11. Why is a secret variable blank in my script, and how do you fix it? Secret variables are deliberately not auto-mapped into the process environment (to avoid leaks). Map them explicitly: env: { API_KEY: $(apiKeySecret) } on the step.

12. Pipeline Artifact vs Universal Package vs Cache? A Pipeline Artifact moves build outputs within or directly between pipelines (publish/download). A Universal Package is a versioned, independently consumable package in an Azure Artifacts feed for many/long-lived consumers. Caching is an optimisation that restores dependency directories to speed runs — a miss is harmless and it is never a substitute for an artifact.

Quick check

  1. Which job type checks out source automatically, and which does not?
  2. Of $( ), ${{ }}, $[ ], which is evaluated at compile time?
  3. How are PR triggers configured for an Azure Repos repository?
  4. What single setting makes a nightly scheduled build run even when nothing changed?
  5. Where do you attach a production approval so a pipeline author can’t remove it?

Answers

  1. A regular job checks out self automatically; a deployment job checks out nothing by default.
  2. ${{ }} (template expressions are compile-time).
  3. Via a Build validation branch policy on the target branch — not the YAML pr: key.
  4. always: true on the schedule.
  5. On the Environment as an Approval check (a protected resource governed by separate permissions).

Exercise

Extend the lab pipeline into a fuller delivery pipeline:

  1. Add a DeployProd stage that dependsOn: DeployDev, targeting a prod Environment pre-created with a manual approval and an exclusive lock, with condition restricting it to main.
  2. Factor the build steps into a step template templates/build.yml with a typed parameter target (validated with values:), and call it from both matrix legs.
  3. Convert the pipeline to extends a templates/pipeline.yml that owns the stage shape and injects the consumer’s buildSteps via a stepList parameter, and enforces a mandatory ./scan.sh step the consumer can’t remove.
  4. Add a scheduled nightly trigger (always: true) and a pipeline resource that downloads an upstream pipeline’s artifact.
  5. Add an Azure Resource Manager service connection using Workload Identity Federation and an AzureCLI@2 step in the prod deploy that runs az account show.

Success criteria: prod pauses on its approval (no agent consumed) and serialises under the exclusive lock; the build template is reused with a validated parameter; the extends template guarantees the scan step runs; the nightly fires without code changes; and the Azure step authenticates with no stored secret.

Certification mapping

Glossary

Next steps

You now know the foundational Azure Pipelines model end to end. From here:

Azure PipelinesCI/CDYAMLTemplatesTriggersEnvironments
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments