Pipeline as code (YAML)
Versioned pipelines, variables, templates.
A click-configured pipeline is a chef who cooks from memory. The dish comes out right because somebody once pushed the correct buttons in the correct order, and the day it comes out wrong, nobody can say what changed. A YAML pipeline is the recipe written down and pasted into the cookbook. (YAML is a plain-text format for writing structured settings, the kind with indented lines and colons.) The file azure-pipelines.yml sits in the repository right next to the code it builds, so every change to *how* you ship becomes a pull request of its own, with a diff, a reviewer, and a rollback that takes one commit.
In Azure DevOps, a pipeline is the automated workflow that builds, tests, and deploys a repository. Pipeline as code means that workflow lives in a version-controlled text file instead of in settings stored inside the service. You get three things the point-and-click editor cannot give you. History: who changed the build, when, and why. Review: branch policies, the rules that guard a branch, apply to pipeline changes exactly as they apply to application changes. Reproducibility: check out last month's commit and you get last month's pipeline. The build definition stops being a setting somebody vaguely remembers touching and becomes a real, auditable part of the project.
The shape of the file: stages, jobs, steps
The file has a strict pecking order, like a book with parts, chapters, and sentences. A pipeline holds stages, the big phases such as Build and Deploy. Stages run in sequence and can stop and wait for a human approval. Stages hold jobs, and a job is a unit of work that runs start to finish on a single machine. Jobs hold steps, which are either an inline script block (shell commands you type yourself) or a task, a versioned building block such as AzureWebApp@1 published by Microsoft or by your own platform team. Wrapped around that skeleton sit the trigger (which branch pushes start a run), the pool (which fleet of machines runs the jobs), variables (values worked out while the run is happening), parameters (typed inputs settled before the run even starts), and templates, reusable YAML files that let you write the security scan once and use it everywhere.
# The whole delivery process, reviewed like any other codetrigger:branches:include: [ main ]pool:vmImage: 'ubuntu-latest' # Microsoft-hosted agent (next lesson)resources:repositories:- repository: templatestype: gitname: Platform/pipeline-templatesref: refs/tags/v2.4.0 # pin shared templates like any dependencyvariables:- group: prod-secrets # Key Vault-backed; no secret values in this file- name: buildConfigurationvalue: Releasestages:- stage: Buildjobs:- job: build_and_teststeps:- task: UseDotNet@2inputs: { packageType: 'sdk', version: '8.x' }- script: |dotnet test -c $(buildConfiguration)dotnet publish -c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory)displayName: Test and publish- template: steps/sast-scan.yml@templates # org-wide scan, defined once- task: PublishPipelineArtifact@1inputs: { targetPath: '$(Build.ArtifactStagingDirectory)', artifact: 'webshop' }- stage: DeploydependsOn: Buildjobs:- deployment: to_staging_slotenvironment: webshop-prod # approvals & checks attach HERE, not in YAMLstrategy:runOnce:deploy:steps:- task: AzureWebApp@1inputs:azureSubscription: 'prod-connection' # workload identity federationappName: 'webshop-prod'deployToSlotOrASE: trueresourceGroupName: 'rg-webshop'slotName: 'staging'package: '$(Pipeline.Workspace)/webshop'
Here is what happens under the hood, because it explains most of the confusing bugs. When you queue a run, Azure Pipelines *compiles* the YAML before any machine is involved. It fetches the pinned template repositories, expands every template: reference, and evaluates ${{ }} template expressions and parameters into one flat run plan. $(var) macros are swapped in far later, moments before the individual task executes. And $[ ] runtime expressions, which exist for conditions and for variable values, evaluate during the run too, right before the job or stage they gate begins. Three syntaxes, three moments in time. Most "my variable is empty" mysteries turn out to be a compile-time expression asking for a value that does not exist yet.
Register it and run it from the command line
The definition lives in the repo, but Azure DevOps still needs a pipeline *resource* that points at the file. That is a one-time registration. The azure-devops extension for the Azure CLI (command-line interface, the az command you type into a terminal) makes the registration scriptable, which starts to matter the moment you look after dozens of projects. Creating pipelines by hand in a web page is precisely the un-reviewable clicking that pipeline-as-code exists to stamp out.
# One-time: add the Azure DevOps extension and set defaultsaz extension add --name azure-devopsaz devops configure --defaults organization=https://dev.azure.com/contoso project=WebShop# Register the YAML file as a pipeline (the definition stays in the repo)az pipelines create --name webshop-ci --repository WebShop \--repository-type tfsgit --branch main \--yml-path azure-pipelines.yml --skip-first-run# Queue a run and check on itaz pipelines run --name webshop-ci --branch main --output table# Run ID Number Status Result Pipeline ID Pipeline Name Source Branch Queued Time Reason# -------- ---------- ---------- -------- ------------- --------------- --------------- -------------------------- --------# 118 20260714.1 notStarted 42 webshop-ci main 2026-07-14 10:47:02.153208 manualaz pipelines runs list --pipeline-ids 42 --top 1 \--query '[].{id:id, result:result, finished:finishTime}'# [ { "id": 118, "result": "succeeded", "finished": "2026-07-14T10:49:37.4266667Z" } ]# Non-secret shared config lives in a variable group; Key Vault secrets are# linked to a group in Library, or pulled per-job with the AzureKeyVault@2 taskaz pipelines variable-group create --name prod-secrets \--variables ASPNETCORE_ENVIRONMENT=Production --authorize false
A variable group is a named bag of values, managed under Pipelines → Library and shared across several pipelines. Link one to Azure Key Vault, which is Azure's managed store for secrets, and only the secret *names* get mapped into the group. The values themselves are fetched fresh from the vault at run time and never stored in Azure DevOps at all. The other route is the AzureKeyVault@2 task, which pulls named secrets into one job. Either way the pipeline has to prove who it is, and it does that through a service connection, a stored identity Azure DevOps uses on your behalf. In 2026 that identity should use *workload identity federation*: the run trades a short-lived OIDC token (OpenID Connect, a standard way for one system to vouch for another) for access, so there is no client secret sitting on a shelf waiting to be rotated or leaked.
Provision with Bicep in the same run
Your application needs somewhere to land. If a human builds that landing place by hand in the portal, staging and production drift apart within weeks, and nobody notices until the same build behaves differently in the two. Bicep is Azure's declarative infrastructure language: you describe the end state you want rather than the steps to get there. It compiles down to ARM JSON (Azure Resource Manager templates, the raw format Azure actually reads), and Azure Resource Manager works out the difference between what exists today and what you asked for. Deployments are idempotent, a word that means running the same template a second time changes nothing. Put az deployment group create inside an AzureCLI@2 step and the infrastructure ships from the very same reviewed commit as the app that runs on it.
// App, plan, and a staging slot as declared stateparam appName stringparam location string = resourceGroup().locationresource plan 'Microsoft.Web/serverfarms@2023-12-01' = {name: '${appName}-plan'location: locationsku: { name: 'P1v3' } // Premium v3: slots supported}resource app 'Microsoft.Web/sites@2023-12-01' = {name: appNamelocation: locationproperties: { serverFarmId: plan.id, httpsOnly: true }}resource staging 'Microsoft.Web/sites/slots@2023-12-01' = {parent: appname: 'staging'location: locationproperties: { serverFarmId: plan.id }}// Deploy from an AzureCLI@2 step (or your shell):// az deployment group create -g rg-webshop -f infra/main.bicep \// -p appName=webshop-prod --query properties.provisioningState -o tsv// Succeeded
Terraform on Azure: state is the part that bites
Terraform tackles the same job under a different contract. It keeps a written ledger of everything it manages, called the state file, and compares that ledger against reality and against your .tf code. On somebody's laptop that file is one bad rm away from disaster, and it usually holds secrets in plain text as a bonus. In a pipeline it belongs in a remote backend. On Azure that means a storage account. The azurerm backend takes a blob lease as a lock, so two apply runs firing at the same moment cannot scribble over each other, and use_azuread_auth signs in with the pipeline's federated identity instead of a storage account key. Run plan and apply as two separate steps with the saved plan file handed between them. Then what a reviewer approved is exactly what executes, with no room for reality to shift in between.
# Remote state with locking — never on a laptop, never in the repoterraform {backend "azurerm" {resource_group_name = "rg-tfstate"storage_account_name = "sttfstatewebshop"container_name = "tfstate"key = "webshop.prod.tfstate"use_azuread_auth = true # Entra ID auth, no storage account keys}required_providers {azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }}}provider "azurerm" {features {}# azurerm 4.x requires a subscription ID — in the pipeline it arrives as# the ARM_SUBSCRIPTION_ID environment variable, not hardcoded here}# In the pipeline, three separate steps:# terraform init # wires the backend, pulls azurerm ~> 4.x# terraform plan -out=tfplan# Plan: 3 to add, 0 to change, 0 to destroy.# terraform apply tfplan # a saved plan applies only what was reviewed# Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Slot swap: deploy cold, swap warm
An App Service deployment slot is a second, fully live copy of your app with its own hostname and its own settings, like a spare stage set built behind the curtain while the show is still running. The pattern goes like this. The pipeline deploys to the staging slot. Warm-up requests confirm the new build is genuinely alive. Then a swap flips Azure's routing so staging becomes production in one move, and the old production build parks in staging, giving you a rollback that takes seconds. Configuration is where this bites people. By default, app settings travel with the code during a swap. Settings marked as slot settings, usually called sticky, stay with the slot instead. Connection strings, ENVIRONMENT flags, and Application Insights role names all need to be sticky. Otherwise your freshly swapped production code wakes up talking to staging's database.
# Sticky ("slot") settings stay with the SLOT during a swap — production keeps# its own connection strings and flags no matter which build is running thereaz webapp config appsettings set -g rg-webshop -n webshop-prod \--slot-settings ENVIRONMENT=production WEBSITE_TIME_ZONE=UTC# [# { "name": "ENVIRONMENT", "slotSetting": true, "value": "production" },# { "name": "WEBSITE_TIME_ZONE", "slotSetting": true, "value": "UTC" }# ]# Make the swap wait on a real health check before shifting trafficaz webapp config appsettings set -g rg-webshop -n webshop-prod --slot staging \--settings WEBSITE_SWAP_WARMUP_PING_PATH=/healthz \WEBSITE_SWAP_WARMUP_PING_STATUSES=200# The swap itself: staging code goes live, old production parks in stagingaz webapp deployment slot swap -g rg-webshop -n webshop-prod \--slot staging --target-slot production# (exits 0 with no output on success)# Instant rollback is the same command run again
What the swap really does, in order: Azure copies production's sticky settings onto the staging workers and restarts them, then sends warm-up pings, honoring WEBSITE_SWAP_WARMUP_PING_PATH and whichever status codes you allow. Watch that second part. By default any HTTP response at all counts as warm, including a 500, which is a weaker check than most people assume. Only once the workers answer healthy does Azure flip the routing rules, so no user request ever lands on a cold process. Slots need the Standard tier or above. The deeper progressive-delivery patterns, canary traffic percentages and blue-green across whole environments, get their own treatment later in the course.
azure-pipelines.yml is in Git history forever, readable by everyone with repo access, and one stray echo away from a build log. Pull secrets from Key Vault instead, through a linked variable group or the AzureKeyVault@2 task. They arrive as secret variables, which means two things: Azure DevOps masks them in log output, and it deliberately does not map them into environment variables unless a step asks for that with env:. Now the limit. Masking only catches exact string matches. If a script base64-encodes the secret, URL-escapes it, or splits it across two lines, the log prints the pieces in the clear. A secret variable protects you from a careless echo. It does not protect you from a determined script, and it is not encryption. The strongest position is owning no secret at all, which is what a workload identity federation service connection gives you: short-lived OIDC tokens issued per run, with nothing to hardcode, rotate, or leak.What YAML costs, and the habits that pay it back
The honest downsides first. YAML cares about whitespace, and a bad indent fails when you queue the run, not when you type it, so use the pipeline editor's Validate button before you push. Its Download full YAML option shows the fully compiled result, which is the fastest way to see what your template logic actually produced when it surprises you. Approvals and checks are not in the YAML at all. They attach to environments and to service connections, which means somebody editing the pipeline cannot delete the production gate. Habits that compound as the org grows: keep shared templates in one central repository, consume them through resources.repositories, and pin them to a tag like any other dependency; put a *required template* check on production service connections so only pipelines extending the vetted template can reach production; give each environment its own least-privilege service connection instead of one subscription-wide contributor. YAML asks for more thought up front than clicking does, and it hands that back the first time somebody asks what changed.
Every job above quietly assumed one thing: a machine to run on. vmImage: ubuntu-latest asked for a Microsoft-hosted agent out of a pool, and that single line carries decisions about cost, build caching, and what the network can reach. Choosing between Microsoft-hosted and self-hosted agents, and hardening the self-hosted ones that end up holding your credentials, is what comes next in Agents & pools.
Here is one more way a click-configured pipeline rots quietly: somebody edited the agent demands last Thursday and left no trace of it anywhere. A YAML file in the repo keeps that trace in the commit log, with a name attached. Once the file exists, variables, variable groups, and templates are what stop it ballooning into copy-pasted duplication. Keep the timing rule in your head as you write them. ${{ }} resolves while the YAML is compiled and $() resolves as each task runs. Mix those two up and you will spend an afternoon chasing a value that was never there.
Templates are how a platform team hands application teams a paved road. The scanning step is already in the template, the publish pattern is standard, the pools are ones somebody vetted. An application repo extends that template instead of copying fifty lines of script it will never keep up to date. Pair it with branch policies and even a change to the pipeline itself needs a reviewer before it can touch main.
Try this
Commit a minimal azure-pipelines.yml that references a variable group and a template stub. Push it to a branch, open a pull request if your policies require one, then prove the YAML you wrote is the YAML the run executed by opening the downloaded YAML in the run summary.
cat > azure-pipelines.yml <<'YAML'trigger:- mainpool:vmImage: ubuntu-latestvariables:buildConfiguration: Releasesteps:- script: echo "Building with $(buildConfiguration)"displayName: Show variableYAML# After push:az pipelines run --name "yaml-lab" --branch main# Sample: inspect run → "... downloaded YAML"
$ az pipelines run --name yaml-lab --branch mainRun 1901 queued# Sample output in the job log:Building with Release
Takeaway
The line to carry out of here: a build defined in azure-pipelines.yml earns the same treatment as application code, with a commit history, pull request policies, and a rollback that is one revert away.
Next: pull the restore and build steps out into a shared template, and move any secret values into a variable group marked secret or backed by Key Vault.
${{ variables.buildTag }} template expression comes out empty during a run, yet a later script step reads the very same variable as $(buildTag) and gets the right value. What is going on?${{ }} while it compiles the YAML into a run plan, before any agent picks up work. $(var) macros are swapped in far later, moments before the task executes, so they see the runtime value.echo away from a build log.