Azure Pipelines & CI

Stages/jobs/steps, triggers, fast feedback.

Beginner30 min · lesson 1 of 15

A car factory has one long assembly line. Parts go in at one end, get welded, painted and inspected at fixed stations along the way, and a finished car rolls off the far end. If a weld fails inspection, the line stops right there instead of shipping a bad car. Azure Pipelines is that assembly line for code: the automation service inside Azure DevOps that takes every push, builds it, runs the tests, and turns the result into something you can deploy. The line itself is written down in a plain-text file called azure-pipelines.yml (YAML, a readable format for describing structured settings) that lives in your repository and gets versioned, reviewed and reverted like any other code.

What that buys you is continuous integration (CI): building and testing the whole codebase automatically on every change, so a broken commit is caught in minutes instead of turning up on release day. The machine doing the actual work is called an agent. It is either a Microsoft-hosted VM (virtual machine, a computer that exists only as software) created fresh for each job and thrown away afterwards, or a self-hosted machine you own and maintain. The trade-offs between those two get their own lesson, *Agents & pools*. Everything else in Azure Pipelines is vocabulary for organizing what runs on those agents.

Stages, jobs, and steps

A pipeline is a set of nested boxes. Stages are the biggest boxes, the coarse phases of the line: Build, DeployTest, DeployProd. They run one after another unless you rewire the order with dependsOn. Inside each stage sit jobs, and the job is the unit of scheduling: every job is handed to its own agent, so several jobs in the same stage can run at the same time (a test matrix across three Node versions, say). Inside each job sit steps, which run in order on that one agent. A script step runs shell commands. A task step runs a packaged, versioned unit of work someone else already wrote, like Cache@2 or PublishPipelineArtifact@1. Because every job gets a brand new agent, nothing you create in one job survives into the next unless you publish it on purpose. That is precisely what artifacts are for.

Two mechanics are worth knowing on day one. Before any of your steps run, the agent performs an implicit checkout: it clones the repository into $(Build.SourcesDirectory) and injects predefined variables such as $(Build.BuildNumber) and $(Build.ArtifactStagingDirectory) into the environment of every step. The second is the run number you see everywhere, like 20260714.2. That is the default name format and nothing more: the date, then a counter that resets each day. Here is a complete CI pipeline you could run as it stands.

azure-pipelines.yml
# trigger + machine + work, in one reviewable file
trigger:
branches:
include: [main]
paths:
exclude: [docs/**, README.md] # docs edits shouldn't burn build minutes
pool:
vmImage: ubuntu-latest # Microsoft-hosted agent, fresh VM per job
variables:
npm_config_cache: $(Pipeline.Workspace)/.npm
stages:
- stage: Build
jobs:
- job: build_test
steps:
- task: Cache@2 # restore the npm cache from earlier runs
inputs:
key: 'npm | "$(Agent.OS)" | package-lock.json'
path: $(npm_config_cache)
- script: npm ci
displayName: Restore dependencies
- script: npm test -- --ci
displayName: Run tests
- script: npm run build
displayName: Build
- task: PublishPipelineArtifact@1
inputs:
targetPath: $(System.DefaultWorkingDirectory)/dist
artifact: webapp-drop # downstream stages deploy exactly this

When this file lands on main, Azure DevOps reads it, turns it into a plan, queues the build_test job and streams the log to your browser live. A failed step fails the job. A failed job fails the whole run. Whoever pushed the change hears about it within minutes. A healthy run leaves an audit trail that reads like this.

build log — run 20260714.2 (excerpt)
Starting: Run tests
==============================================================================
Task : Command line
Version : 2.250.1
==============================================================================
/usr/bin/bash --noprofile --norc /home/vsts/work/_temp/8f3c1b.sh
> jest --ci
PASS src/auth/token.test.js
PASS src/routes/orders.test.js (4.87 s)
Test Suites: 12 passed, 12 total
Tests: 87 passed, 87 total
Time: 21.4 s
Finishing: Run tests
...
Finishing: PublishPipelineArtifact
# job build_test: succeeded in 3m 41s — the Cache@2 hit saved ~70s of npm ci

Running pipelines from the terminal

The web portal is fine for reading logs. Registering pipelines, queueing runs and asking what happened overnight is faster from a terminal, and scriptable, which matters when you are triaging an incident at 2am or bootstrapping fifty repositories at once. Azure Pipelines is managed through the azure-devops extension of the standard az CLI (command-line interface, the text-based tool you type commands into). One naming quirk to memorize: --repository-type tfsgit is the historical value for repos hosted in Azure Repos. Use github for code hosted on GitHub.

terminal — az pipelines
# one-time: install the extension, set org/project defaults
az extension add --name azure-devops
az devops configure --defaults \
organization=https://dev.azure.com/contoso project=webapp
# register the YAML file as a pipeline
az pipelines create --name webapp-ci --repository webapp \
--repository-type tfsgit --branch main --yml-path azure-pipelines.yml
# queue a run, then check recent history
az pipelines run --name webapp-ci --branch main
az pipelines runs list --top 3 --output table
Run ID Number Status Result Pipeline ID Pipeline Name Source Branch Queued Time Reason
-------- ----------- --------- --------- ------------- --------------- --------------- -------------------------- ------------
118 20260714.2 completed succeeded 42 webapp-ci main 2026-07-14 15:04:11.532106 manual
117 20260714.1 completed failed 42 webapp-ci main 2026-07-14 11:47:03.118245 individualCI
116 20260713.4 completed succeeded 42 webapp-ci main 2026-07-13 17:22:54.902337 individualCI

Read the Reason column and you can reconstruct the week. manual means a person queued that run by hand. individualCI means a push tripped the trigger. Run 117 failing and run 118 succeeding on the same branch is the CI loop doing exactly its job: a regression landed, the pipeline caught it, the fix shipped inside the hour, and nobody else ever pulled broken code.

Triggers, caching, and the cost of a slow pipeline

The trigger: block is the contract for when the line starts up. Branch filters narrow it to the branches you care about. Path filters stop a typo fix in the README from burning build minutes. batch: true collapses pushes that arrive while a run is already going into a single follow-up run instead of a queue five deep. Then there is one genuinely surprising Azure quirk. For repositories hosted in Azure Repos, the pr: trigger in YAML is *ignored*. Pull request validation, meaning the build that runs on a proposed change before anyone merges it, is configured through branch policies instead (covered in *Azure Repos & branch policies*). Teams lose whole afternoons adding pr: blocks and wondering why nothing ever fires.

Speed is something you engineer on purpose. Cache@2 keyed on package-lock.json turns a 90-second dependency restore into a few seconds on any run where the lock file has not changed. Splitting tests across parallel jobs buys wall-clock time, and it costs you parallel-job licenses, because Microsoft-hosted parallelism is a quota you are granted or you buy. The target is a hard number. Keep a CI run under roughly ten minutes. Past that, developers stop waiting, switch to something else, and start routing around the pipeline entirely.

The CI assembly line
1push to main
trigger fires; docs paths excluded
2agent assigned
fresh VM, implicit checkout
3restore + build + test
cache hit, fail fast on red
4publish artifact
webapp-drop, immutable
5deploy stage
environment checks, slot swap
One file describes the whole line. Every push enters at the top, and only a build that survived the tests leaves as an artifact that later stages are allowed to deploy.

From CI to deployment: slots, sticky settings, and infrastructure as code

CI finishes with an artifact sitting in storage. Something still has to put it in front of real users. In YAML that something is a deployment job pointed at an environment, a named target like production where approvals and checks get attached (full treatment in *Release pipelines & approvals*). On Azure App Service the first deployment pattern you meet is the deployment slot: a second, parallel copy of your app with its own URL (web address) and its own configuration, rather like a spare stage set built offstage while the audience watches the current one. Deploy the artifact to the slot, let it warm up, smoke-test it, then swap. The swap exchanges which copy the production hostname points at, so users never hit a cold or half-deployed app.

terminal — slot swap with sticky settings
# create a staging slot beside production (needs Standard tier or higher)
az webapp deployment slot create --name webapp-prod \
--resource-group rg-webapp --slot staging
# --slot-settings marks these STICKY: pinned to the slot,
# they do NOT travel to production during a swap
az webapp config appsettings set --name webapp-prod \
--resource-group rg-webapp --slot staging \
--slot-settings ASPNETCORE_ENVIRONMENT=Staging \
APPLICATIONINSIGHTS_CONNECTION_STRING=$STAGING_AI_CONN
# deploy the zipped CI artifact to staging, then swap it into production
az webapp deploy --name webapp-prod --resource-group rg-webapp \
--slot staging --src-path webapp-drop.zip --type zip
az webapp deployment slot swap --name webapp-prod \
--resource-group rg-webapp --slot staging --target-slot production
# swap returns silently on success (a minute or two); traffic moves, no cold start

The subtle part is configuration. By default, app settings and connection strings travel with the code during a swap. Anything you mark with --slot-settings is sticky: it stays glued to the slot it was set on, so staging keeps its staging connection string and production keeps its production one, however many swaps happen. Get that backwards and one swap quietly points production at the staging database, which is a classic Friday outage. A couple of related WEBSITE_* app settings tune the swap itself. Set WEBSITE_SWAP_WARMUP_PING_PATH=/healthz and Azure pings that path on the incoming code before it completes the swap. Pair it with WEBSITE_SWAP_WARMUP_PING_STATUSES=200,202, because out of the box *any* HTTP response counts as warmed up, even a 500 (the code a web server returns when it has fallen over). Swap-with-preview, auto swap and traffic-split rollouts all build on this in *Zero-downtime deployment*.

So where does webapp-prod itself come from? Not from clicking around the portal. It comes from infrastructure as code, meaning your servers and services are written down in a file the same way your app is, and the pipeline deploys that file the same way it deploys the app. Here is a taste of both toolchains you go deep on later (*ARM & Bicep*, *Terraform on Azure*).

terminal — IaC deploy
# Bicep: declare the web app + slot, let Azure converge to it
az deployment group create --resource-group rg-webapp \
--template-file main.bicep --parameters appName=webapp-prod
# "provisioningState": "Succeeded" <- idempotent: rerunning is a no-op
# Terraform: same infrastructure via the azurerm provider
terraform init # Installing hashicorp/azurerm v4.31.0...
terraform plan -out tfplan
terraform apply tfplan
# Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
A pipeline people work around stops protecting anything
If CI takes forty minutes, or fails at random, developers stop trusting it. They merge without waiting for the green tick, and an admin hands out check-bypass rights "for now". From that moment every control sitting downstream of the pipeline quietly stops applying to real code: the tests today, and the secret scanning and dependency audits you add in *DevSecOps in the pipeline*. So treat pipeline speed (caching, parallel jobs) and pipeline reliability (quarantine a flaky test the day it first flakes) as security work. And treat the right to bypass a branch check the way you treat production access: rare, logged, reviewed.

Every decision above lived in one file: the trigger, the pool, the cache key, the artifact name. That file diffs, reviews and reverts like source code, which is the whole point of describing the line instead of clicking it together. The next lesson takes the file apart properly: templates that stop you pasting the same forty lines into fifty repositories, variables versus parameters, and expressions and conditions. That is the difference between a pipeline that works and one that scales.

One thing the example above does not show is that a push is not the only way to start the line. A scheduled trigger runs a nightly build on a cron expression (a compact way of writing something like "every weeknight at 2am"). A resource trigger starts a run when another pipeline finishes or a new container image is published. Pull request validation, on Azure Repos, arrives through branch policies. Each of those answers a different question about when the line should start, and one pipeline can carry several of them at once.

Keep the first pipeline you write thin: restore, build, unit test, publish artifact, stop. Anything you bolt on afterwards sits in the path of every push forever, so a linter that takes four minutes is a four-minute tax on the whole team, all day, every day. Deployment belongs in a later stage behind an environment with approvals attached, where waiting is the point rather than a cost.

Try this

Open Azure DevOps and create a starter YAML pipeline that does almost nothing: an echo and an az --version on a Microsoft-hosted agent. Queue it from a branch and watch the log until the job goes green. The aim is to see the whole loop once with no application code in the way.

terminal
# From a repo with azure-pipelines.yml that runs on ubuntu-latest:
az pipelines run --name "ci-lab" --branch main --open
az pipelines runs list --pipeline-name "ci-lab" --top 3 -o table
az pipelines runs show --id <runId> --query "{status:status,result:result}" -o json
output
$ az pipelines runs list --pipeline-name ci-lab --top 3 -o table
Run ID Status Result Source Branch
------ --------- --------- -------------
1842 completed succeeded main
# Sample output
$ az pipelines runs show --id 1842 --query "{status:status,result:result}" -o json
{
"status": "completed",
"result": "succeeded"
}

Takeaway

Remember: a pipeline is stages, then jobs, then steps, running on an agent that gets thrown away when the job ends. CI means every push earns a build-and-test verdict in minutes, rather than a nasty surprise at release time.

Next: add a real restore, build and test job, and make a failing test fail the run, so main stays green because the pipeline will not let it be anything else.

Quick check
01Your pipeline builds a repo that lives in Azure Repos. You add a pr: trigger block to azure-pipelines.yml so a build runs on every pull request, then watch as not a single PR build ever starts. What is going on?
Correct — The lesson flags this as the Azure surprise that costs teams an afternoon: pr: does nothing for Azure Repos, so PR validation has to come from a branch policy.
Incorrect — No. pr: is a perfectly valid top-level trigger. The problem is that Azure Repos pays no attention to it, not where you put it in the file.
Incorrect — No. batch: true collapses pushes that arrive mid-run into one follow-up run. It has nothing to do with turning PR builds on.
Incorrect — No. Path filters only decide which changed files are worth starting a run for. They do not switch off PR triggering, which Azure Repos ignores either way.
02A build job in one stage compiles a binary, and a job in a later stage needs to deploy that binary. Why does the build job have to publish it as a pipeline artifact instead of leaving it on disk where it was built?
Correct — Each job gets a clean agent and nothing carries over between jobs unless you publish it, which is the whole reason artifacts exist.
Incorrect — No. All the steps in one job share a single agent and a single working directory. Jobs get separate agents, steps do not.
Incorrect — No. Steps in one job already share a filesystem. Artifacts exist to cross the boundary between jobs, not between steps.
Incorrect — No. The implicit checkout happens once, at the start of the job, cloning into $(Build.SourcesDirectory).
03Your Node.js CI job burns about 90 seconds on npm ci every single run. Total CI time has crept past ten minutes and developers have started skipping the pipeline. You want that restore time down for the smallest added cost. What do you reach for first?
Incorrect — No. Parallel jobs are a paid quota, and fanning the work out does not make the npm ci restore itself any quicker.
Incorrect — No. The Microsoft-hosted VM size is fixed. You cannot ask for a larger box.
Incorrect — No. That buys you patching, scaling and hardening chores to solve a problem caching handles for nothing.
Correct — The lesson shows a Cache@2 hit cutting roughly 70 seconds off that restore, at no extra licensing cost.

Related