Azure Pipelines & CI
Stages/jobs/steps, triggers, fast feedback.
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.
# trigger + machine + work, in one reviewable filetrigger:branches:include: [main]paths:exclude: [docs/**, README.md] # docs edits shouldn't burn build minutespool:vmImage: ubuntu-latest # Microsoft-hosted agent, fresh VM per jobvariables:npm_config_cache: $(Pipeline.Workspace)/.npmstages:- stage: Buildjobs:- job: build_teststeps:- task: Cache@2 # restore the npm cache from earlier runsinputs:key: 'npm | "$(Agent.OS)" | package-lock.json'path: $(npm_config_cache)- script: npm cidisplayName: Restore dependencies- script: npm test -- --cidisplayName: Run tests- script: npm run builddisplayName: Build- task: PublishPipelineArtifact@1inputs:targetPath: $(System.DefaultWorkingDirectory)/distartifact: 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.
Starting: Run tests==============================================================================Task : Command lineVersion : 2.250.1==============================================================================/usr/bin/bash --noprofile --norc /home/vsts/work/_temp/8f3c1b.sh> [email protected] test> jest --ciPASS src/auth/token.test.jsPASS src/routes/orders.test.js (4.87 s)Test Suites: 12 passed, 12 totalTests: 87 passed, 87 totalTime: 21.4 sFinishing: 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.
# one-time: install the extension, set org/project defaultsaz extension add --name azure-devopsaz devops configure --defaults \organization=https://dev.azure.com/contoso project=webapp# register the YAML file as a pipelineaz pipelines create --name webapp-ci --repository webapp \--repository-type tfsgit --branch main --yml-path azure-pipelines.yml# queue a run, then check recent historyaz pipelines run --name webapp-ci --branch mainaz pipelines runs list --top 3 --output tableRun 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 manual117 20260714.1 completed failed 42 webapp-ci main 2026-07-14 11:47:03.118245 individualCI116 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.
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.
# 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 swapaz 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 productionaz webapp deploy --name webapp-prod --resource-group rg-webapp \--slot staging --src-path webapp-drop.zip --type zipaz 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*).
# Bicep: declare the web app + slot, let Azure converge to itaz 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 providerterraform init # Installing hashicorp/azurerm v4.31.0...terraform plan -out tfplanterraform apply tfplan# Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
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.
# From a repo with azure-pipelines.yml that runs on ubuntu-latest:az pipelines run --name "ci-lab" --branch main --openaz pipelines runs list --pipeline-name "ci-lab" --top 3 -o tableaz pipelines runs show --id <runId> --query "{status:status,result:result}" -o json
$ az pipelines runs list --pipeline-name ci-lab --top 3 -o tableRun 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.
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?pr: does nothing for Azure Repos, so PR validation has to come from a branch policy.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.batch: true collapses pushes that arrive mid-run into one follow-up run. It has nothing to do with turning PR builds 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?npm ci restore itself any quicker.