Pipeline as code (YAML)

Versioned pipelines, variables, templates.

Intermediate30 min · lesson 2 of 15

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.

azure-pipelines.yml
# The whole delivery process, reviewed like any other code
trigger:
branches:
include: [ main ]
pool:
vmImage: 'ubuntu-latest' # Microsoft-hosted agent (next lesson)
resources:
repositories:
- repository: templates
type: git
name: Platform/pipeline-templates
ref: refs/tags/v2.4.0 # pin shared templates like any dependency
variables:
- group: prod-secrets # Key Vault-backed; no secret values in this file
- name: buildConfiguration
value: Release
stages:
- stage: Build
jobs:
- job: build_and_test
steps:
- task: UseDotNet@2
inputs: { 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@1
inputs: { targetPath: '$(Build.ArtifactStagingDirectory)', artifact: 'webshop' }
- stage: Deploy
dependsOn: Build
jobs:
- deployment: to_staging_slot
environment: webshop-prod # approvals & checks attach HERE, not in YAML
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: 'prod-connection' # workload identity federation
appName: 'webshop-prod'
deployToSlotOrASE: true
resourceGroupName: '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.

register-and-run.sh
# One-time: add the Azure DevOps extension and set defaults
az extension add --name azure-devops
az 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 it
az 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 manual
az 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 task
az 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.

infra/main.bicep
// App, plan, and a staging slot as declared state
param appName string
param location string = resourceGroup().location
resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
name: '${appName}-plan'
location: location
sku: { name: 'P1v3' } // Premium v3: slots supported
}
resource app 'Microsoft.Web/sites@2023-12-01' = {
name: appName
location: location
properties: { serverFarmId: plan.id, httpsOnly: true }
}
resource staging 'Microsoft.Web/sites/slots@2023-12-01' = {
parent: app
name: 'staging'
location: location
properties: { 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.

infra/backend.tf
# Remote state with locking — never on a laptop, never in the repo
terraform {
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.

slot-swap.sh
# Sticky ("slot") settings stay with the SLOT during a swap — production keeps
# its own connection strings and flags no matter which build is running there
az 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 traffic
az 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 staging
az 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.

Secrets do not belong in pipeline YAML, and masking is only a best effort
A password committed to 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.

Three syntaxes, three moments in time
Compile time, before any agent
${{ }} template expressions
expanded into the flat run plan
template: refs & parameters
fetched and flattened first
Runtime, as a job or stage begins
$[ ] runtime expressions
gate conditions and variable values
Task execution, right before a step
$(var) macros
substituted into the command
Most 'my variable is empty' bugs are a compile-time ${{ }} expression asking for a value that only shows up at runtime.

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.

terminal
cat > azure-pipelines.yml <<'YAML'
trigger:
- main
pool:
vmImage: ubuntu-latest
variables:
buildConfiguration: Release
steps:
- script: echo "Building with $(buildConfiguration)"
displayName: Show variable
YAML
# After push:
az pipelines run --name "yaml-lab" --branch main
# Sample: inspect run → "... downloaded YAML"
output
$ az pipelines run --name yaml-lab --branch main
Run 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.

Quick check
01A ${{ 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?
Correct — Azure Pipelines expands ${{ }} 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.
Incorrect — No. Both forms read the same variables. What differs is when they resolve, at compile time versus task execution, not which set they read from.
Incorrect — No. YAML is fussy about whitespace, but a bad indent fails the whole run at queue time. It does not quietly empty a single value while everything around it keeps working.
Incorrect — No. Masking hides secret values in logs, and even that is best-effort. It does not blank out what a non-secret variable actually resolved to.
02In an Azure DevOps YAML pipeline, where do deployment approvals and checks actually get configured?
Incorrect — No. There is no such YAML key, and if there were, anyone editing the pipeline could quietly drop the gate.
Incorrect — No. That is build validation for pull requests, a separate control that does nothing to gate a deployment stage.
Correct — Approvals and checks attach to environments and service connections rather than to the file, exactly so a rogue pipeline edit cannot delete them.
Incorrect — No. Variable groups carry configuration values and secret references, not approval gates.
03A 2026 pipeline has to authenticate to an Azure subscription before it can deploy. The security team wants nothing long-lived that could leak or get forgotten at rotation time. Which service connection setup fits best?
Incorrect — No. A stored client secret is a long-lived credential that somebody has to remember to rotate, and that can walk out the door in the meantime.
Correct — Workload identity federation is the 2026 recommendation: short-lived tokens issued per run, with nothing to hardcode, rotate, or leak.
Incorrect — No. A personal access token is still a long-lived credential. It can leak, and somebody has to replace it before it expires.
Incorrect — No. A secret committed to YAML lives in Git history forever and is one stray echo away from a build log.

Related