How to Build an Azure Pipelines YAML Pipeline: Step-by-Step (2026)

Nov 10, 2026 · 13 min · Ajay Kumar

This guide is for engineers who have to write or inherit an azure-pipelines.yml and want one that is structured properly from the start, rather than a 600-line file nobody dares touch. By the end you will have a multi-stage YAML pipeline that builds and tests a Node.js service, builds a container image in Azure Container Registry, deploys to AKS through a staging and an approval-gated production environment, pulls secrets from Key Vault at runtime, and authenticates to Azure with workload identity federation instead of a stored secret. Everything here was checked against Microsoft Learn as of November 2026: Microsoft-hosted ubuntu-24.04 agents, KubernetesManifest@1, AzureKeyVault@2, AzureCLI@2 and AzureWebApp@1. You need an Azure DevOps project, an Azure subscription where you can create a managed identity and assign roles, an existing ACR and AKS cluster, and a repository in Azure Repos or GitHub.

If you are still deciding whether to stay on Azure DevOps at all, read Azure DevOps vs GitHub in 2026 first. Nothing below is wasted if you later move repositories to GitHub, because Azure Pipelines builds GitHub repos too.

Step 1: Understand the azure-pipelines.yml structure

A YAML pipeline is a tree: a pipeline has stages, a stage has jobs, a job runs on one agent and has steps, and a step is a script or a task. Each level can carry variables, pool and condition. If you omit stages, you get one implicit stage; if you omit jobs, one implicit job. That is fine for a hello-world and painful later, so start with explicit stages.

Here is the smallest useful pipeline. Commit it as azure-pipelines.yml at the repo root, then create the pipeline in Pipelines > New pipeline and point it at the file.

# azure-pipelines.yml (minimal)
trigger:
  branches:
    include: [main]
  paths:
    exclude: [docs/*, '*.md']

pool:
  vmImage: ubuntu-24.04      # pin the image; ubuntu-latest moves when Microsoft says so

steps:
- checkout: self
  fetchDepth: 1
- script: |
    node --version
    npm ci
    npm test
  displayName: Install and test

Two details trip people up. First, pr: triggers only apply to GitHub and Bitbucket repositories; for Azure Repos Git you get PR builds by adding the pipeline as a build validation branch policy on main. Second, the hosted image matters: the Microsoft-hosted agents page lists ubuntu-24.04 (also ubuntu-latest) and ubuntu-26.04, with ubuntu-22.04 deprecated and scheduled for removal on 17 April 2027. Each standard hosted agent is a 2-core VM with 7 GB of RAM and about 10 GB of free disk, and a free-tier job stops at 60 minutes.

Step 2: Create the workload identity federation service connection

Every Azure-facing task in this pipeline uses one Azure Resource Manager service connection. Microsoft's service connection docs recommend workload identity federation with either an app registration or a managed identity: the pipeline gets a short-lived OIDC token and exchanges it for an Entra access token, so there is no client secret to leak or rotate. This is the same idea as GitHub Actions OIDC, and the background is in workload identity federation explained.

If you own the subscription, the easy path is Project settings > Service connections > New service connection > Azure Resource Manager > App registration (automatic) with the Workload identity federation credential, scoped to a resource group. If you cannot create app registrations, use a user-assigned managed identity. Create it and give it roles from your own workstation:

# Run from your workstation with Azure CLI logged in to the right tenant.
RG_CICD=rg-cicd
RG_APP=rg-payments
SUB_ID=$(az account show --query id -o tsv)

az identity create --name id-ado-payments --resource-group "$RG_CICD" --location westeurope
PRINCIPAL_ID=$(az identity show -n id-ado-payments -g "$RG_CICD" --query principalId -o tsv)
CLIENT_ID=$(az identity show -n id-ado-payments -g "$RG_CICD" --query clientId -o tsv)

# Least privilege: scope to the app's resource group, not the subscription.
az role assignment create --assignee-object-id "$PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --role Contributor --scope "/subscriptions/$SUB_ID/resourceGroups/$RG_APP"
echo "Client ID for the service connection: $CLIENT_ID"

Then in Azure DevOps choose App registration or Managed identity (manual), credential Workload identity federation, enter the tenant ID and the client ID, and select Keep as draft. Azure DevOps shows you an Issuer and a Subject identifier. Copy them exactly and add the federated credential to the identity:

# Paste the two values Azure DevOps generated. Do not hand-type them.
az identity federated-credential create --name ado-payments \
  --identity-name id-ado-payments --resource-group "$RG_CICD" \
  --issuer "<issuer from Azure DevOps>" \
  --subject "<subject identifier from Azure DevOps>" \
  --audiences api://AzureADTokenExchange

Back in Azure DevOps, select Finish setup and Verify and save. Name the connection sc-payments-prod; the YAML refers to it by name. Leave Grant access permission to all pipelines unticked and authorise pipelines one at a time.

Three dates and limits to know. New federated connections use the Entra issuer (https://login.microsoftonline.com/<tenant>/v2.0); connections on the older Azure DevOps issuer (https://vstoken.dev.azure.com) are deprecated and retire on 1 July 2027, and Azure DevOps flags them with an Update button. Existing secret-based connections can be converted in place, with a seven-day revert window. And Azure Pipelines may automatically disable Azure Resource Manager connections unused for 100 days, which surprises teams with quarterly release pipelines.

Step 3: Add variables, a variable group and Key Vault secrets

Plain variables live in YAML. Secrets do not. Create a key vault, add a secret such as db-connection-string, and grant the service connection's identity read access: the Key Vault Secrets User role for an RBAC vault, or Get and List secret permissions for an access-policy vault. Then choose one of two patterns.

The first is a variable group linked to the vault. In Pipelines > Library > + Variable group, enable Link secrets from an Azure key vault as variables, pick sc-payments-prod and the vault, and add the secrets you want. Per the variable group docs, only secret names are stored in the group and values are fetched at run time; secrets added to the vault later must be added to the group by hand, and RBAC vaults behind a private endpoint are not supported. The second pattern is the AzureKeyVault@2 task, which fetches secrets inside a job and works with any vault the agent can reach.

variables:
- group: payments-prod-kv          # Key Vault linked variable group
- name: acrName
  value: contosoacr
- name: imageRepo
  value: payments-api
- name: imageTag
  value: $(Build.BuildId)

steps:
# Alternative to the variable group: fetch at job start.
- task: AzureKeyVault@2
  inputs:
    azureSubscription: sc-payments-prod
    KeyVaultName: kv-payments-prod
    SecretsFilter: 'db-connection-string'   # comma-separated names, or * for all
    RunAsPreJob: true                       # make secrets available to every step

# Secrets are NOT exported to scripts automatically. Map them explicitly.
- script: ./scripts/migrate.sh
  env:
    DB_CONNECTION_STRING: $(db-connection-string)

That last block is the most common "my secret is empty" bug. Secret variables are masked in logs and are not placed in the environment of script steps unless you map them with env:.

Step 4: Move repeated steps into templates

Templates are how a YAML pipeline stays readable once there are five services. A steps template takes typed parameters and is inserted where you reference it. Put it in templates/node-build.yml:

# templates/node-build.yml
parameters:
- name: nodeVersion
  type: string
  default: '24.x'
- name: workingDirectory
  type: string
  default: '.'

steps:
- task: NodeTool@0
  inputs:
    versionSpec: ${{ parameters.nodeVersion }}
- script: |
    npm ci
    npm test   # assumes your test script writes JUnit XML to test-results.xml
  workingDirectory: ${{ parameters.workingDirectory }}
  displayName: npm ci and test
- task: PublishTestResults@2
  condition: succeededOrFailed()
  inputs:
    testResultsFormat: JUnit
    testResultsFiles: '**/test-results.xml'

For platform teams, the stronger pattern is an extends template kept in a separate repository and pinned to a tag. The templates for security docs show the shape:

# In the application repo
resources:
  repositories:
  - repository: templates
    type: git
    name: Platform/pipeline-templates
    ref: refs/tags/v3.2.0          # pin; never track main from a production pipeline

extends:
  template: service.yml@templates
  parameters:
    serviceName: payments-api

You can then add a Required template check to the production service connection or environment, so a pipeline that does not extend service.yml@templates cannot use it at all. That one control does more for pipeline security than most scanners.

Step 5: Build the image and publish artifacts

The build stage runs tests, builds the container image with ACR Tasks (so the agent needs no Docker daemon or registry password), and publishes the Kubernetes manifests as a pipeline artifact for later stages.

stages:
- stage: Build
  jobs:
  - job: BuildAndTest
    pool:
      vmImage: ubuntu-24.04
    steps:
    - template: templates/node-build.yml
      parameters:
        nodeVersion: '24.x'

    - task: AzureCLI@2
      displayName: Build image in ACR
      inputs:
        azureSubscription: sc-payments-prod
        scriptType: bash
        scriptLocation: inlineScript
        inlineScript: |
          set -euo pipefail
          az acr build --registry "$(acrName)" \
            --image "$(imageRepo):$(imageTag)" --file Dockerfile .

    - publish: $(Build.SourcesDirectory)/k8s     # shortcut for PublishPipelineArtifact
      artifact: manifests

The identity behind sc-payments-prod needs permission to run ACR Tasks on the registry (Contributor on its resource group covers it), and the AKS kubelet identity needs AcrPull, which az aks update --attach-acr sets up. If the cluster cannot pull, the pods sit in ImagePullBackOff and the pipeline times out waiting for the rollout.

Step 6: Deploy to AKS through environments and approvals

Create two environments under Pipelines > Environments: staging and production. On production, open Approvals and checks and add an Approvals check with your release approvers, plus Exclusive lock so two runs cannot deploy at once. Checks live on the environment, not in YAML, which is the point: whoever owns production controls the gate, and a pipeline author cannot delete it in a pull request.

The pipeline in this guide: one trigger, three stages, one gate 1. Trigger push to main PR: branch policy (Azure Repos) 2. Build stage npm ci, npm test az acr build publish manifests 3. Staging deployment job env: staging AKS ns: staging 4. Checks env: production approval, lock required template 5. Production KubernetesManifest@1 rollout status on failure: alert Identity and secrets Every Azure call uses one ARM service connection with workload identity federation: no stored secret. Secrets come from Key Vault at run time, via a linked variable group or AzureKeyVault@2.
The stages and checks built in steps 2 to 6. Approvals and locks are configured on the production environment in the Azure DevOps UI, not in the YAML.

Deployment jobs differ from normal jobs in two ways that matter, per the deployment jobs docs: they do not check out the repo automatically, and the deploy hook automatically downloads the current run's artifacts into $(Pipeline.Workspace). The azureResourceManager connection type for KubernetesManifest@1 gets a short-lived cluster credential per task and works with private clusters and clusters that have local accounts disabled, as long as the agent can reach the API server.

# Continues the stages: list from Step 5
- stage: DeployStaging
  dependsOn: Build
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  jobs:
  - deployment: DeployAKS
    pool:
      vmImage: ubuntu-24.04
    environment: staging
    strategy:
      runOnce:
        deploy:
          steps:
          - task: KubernetesManifest@1
            inputs:
              action: deploy
              connectionType: azureResourceManager
              azureSubscriptionConnection: sc-payments-prod
              azureResourceGroup: rg-payments
              kubernetesCluster: aks-payments
              namespace: staging
              manifests: |
                $(Pipeline.Workspace)/manifests/deployment.yaml
                $(Pipeline.Workspace)/manifests/service.yaml
              containers: $(acrName).azurecr.io/$(imageRepo):$(imageTag)
              rolloutStatusTimeout: '300'

- stage: DeployProd
  dependsOn: DeployStaging
  jobs:
  - deployment: DeployAKS
    pool:
      vmImage: ubuntu-24.04
    environment: production           # approvals and exclusive lock apply here
    strategy:
      runOnce:
        deploy:
          steps:
          - task: KubernetesManifest@1
            inputs:
              action: deploy
              connectionType: azureResourceManager
              azureSubscriptionConnection: sc-payments-prod
              azureResourceGroup: rg-payments
              kubernetesCluster: aks-payments
              namespace: production
              manifests: $(Pipeline.Workspace)/manifests/*.yaml
              containers: $(acrName).azurecr.io/$(imageRepo):$(imageTag)
              rolloutStatusTimeout: '600'
        on:
          failure:
            steps:
            - script: echo "Production rollout failed for $(imageTag)"   # replace with your alert hook

If the cluster uses Entra ID with Azure RBAC, give the identity Azure Kubernetes Service Cluster User Role on the cluster and Azure Kubernetes Service RBAC Writer scoped to each namespace, rather than useClusterAdmin: true. In a real setup I would use separate service connections and identities for staging and production; one is used here to keep the YAML short.

Step 7: Deploy to Azure App Service instead

Not everything belongs on Kubernetes. For a plain web app, swap the deploy steps for AzureWebApp@1, which on Linux uses zip deploy. Build a zip in the Build stage and publish it as an artifact named drop:

# In Build: package the app
- script: zip -r $(Build.ArtifactStagingDirectory)/app.zip . -x '.git/*'
- publish: $(Build.ArtifactStagingDirectory)/app.zip
  artifact: drop

# In the deploy stage's runOnce.deploy.steps
- task: AzureWebApp@1
  inputs:
    azureSubscription: sc-payments-prod
    appType: webAppLinux
    appName: app-payments-prod
    package: $(Pipeline.Workspace)/drop/app.zip
    runtimeStack: 'NODE|24-lts'
    deployToSlotOrASE: true            # deploy to a slot, then swap
    resourceGroupName: rg-payments
    slotName: staging

Deploying to a staging slot and swapping with AzureAppServiceManage@0 gives you a warm instance and a one-click rollback, which is the App Service equivalent of the rollout check above.

Step 8: Validate the YAML before you push

The pipeline editor in the web UI has a Validate option, but you can also render the final YAML, with every template expanded, without queuing a run. The Pipelines preview API takes previewRun: true and an optional yamlOverride and returns finalYaml:

# Uses an Entra token for Azure DevOps (resource ID 499b84ac-1321-427f-aa17-267ca6975798).
ORG=contoso; PROJECT=Payments; PIPELINE_ID=42
TOKEN=$(az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv)

jq -n --rawfile y azure-pipelines.yml '{previewRun: true, yamlOverride: $y}' |
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  --data @- \
  "https://dev.azure.com/$ORG/$PROJECT/_apis/pipelines/$PIPELINE_ID/preview?api-version=7.1" |
jq -r '.finalYaml' | head -40
# Expected: the fully expanded pipeline, starting with stages: and the inlined template steps.
# A template or syntax error comes back as a JSON error message instead of finalYaml.

I run this in a pre-push hook for template repos, because a broken extends template fails every pipeline that consumes it at once.

Troubleshooting

Symptom Likely cause Fix
"No hosted parallelism has been purchased or granted" New organisation without the free Microsoft-hosted grant Request the free grant through the link in the error, or buy a parallel job ($40 a month)
Run pauses with "This pipeline needs permission to access a resource" First use of a service connection, environment, variable group or repo Select View then Permit, or authorise the pipeline on the resource's security page
Service connection "could not be found" or "has not been authorized" Name typo, connection disabled after 100 days unused, or pipeline not authorised Check the exact name, re-enable it in Service connections, authorise the pipeline
AADSTS700213 or AADSTS70021, no matching federated identity record Subject or issuer on the identity does not match; project or connection renamed Copy the issuer and subject from the service connection again and update the federated credential
AADSTS700024, client assertion not within its valid time range Step requests a token after the OIDC assertion expired, often past about an hour Use the managed identity option, or move token-using commands to the start of the script
AuthorizationFailed from az acr build or a deploy task Missing role assignment, or it has not propagated yet Assign the role at the right scope and retry after a few minutes
Key Vault task returns Forbidden Identity lacks Get and List or Key Vault Secrets User; vault firewall blocks hosted agents Grant the role; allow the agent IP ranges or use a self-hosted agent in the VNet
Secret is empty inside a script Secret variables are not exported to the environment automatically Map it with env: NAME: $(secret-name) on the step
PR builds never start on Azure Repos pr: in YAML only applies to GitHub and Bitbucket Add the pipeline as a build validation branch policy on the target branch
AzureWebApp@1: No package found with specified pattern Artifact not published, or wrong path in a deployment job Publish it in Build and reference $(Pipeline.Workspace)/<artifact>/...

For federation errors the workload identity troubleshooting page has the full AADSTS table, including AADSTS700223 and AADSTS700238 for tenants that restrict federated credentials.

FAQ

What is the difference between stages, jobs and steps in Azure Pipelines?

A stage is a logical phase such as Build or DeployProd and is where environments and approvals usually gate progress. A job runs on a single agent and owns a fresh workspace; jobs in a stage run in parallel unless you set dependsOn. Steps are the scripts and tasks inside a job and run in order.

Should I use a variable group linked to Key Vault or the AzureKeyVault@2 task?

Use a linked variable group when several pipelines share the same secrets and you want them managed in the Library with its own permissions. Use AzureKeyVault@2 when a pipeline needs a vault the variable group cannot reach, such as an RBAC vault behind a private endpoint accessed from a self-hosted agent, or when you want the secret list in YAML.

Do I need to migrate service connections off the vstoken issuer?

Yes, if they use workload identity federation with the Azure DevOps issuer. Those connections retire on 1 July 2027; Azure DevOps flags them and offers an Update button that converts them to the Entra issuer. Connections that still use client secrets should be converted to federation first.

Can Azure Pipelines deploy to AKS without a kubeconfig secret?

Yes. Use KubernetesManifest@1 with connectionType: azureResourceManager, which gets a short-lived credential through the federated service connection on each run. It works with private clusters if the agent has network access, and with clusters where local accounts are disabled.

When a pipeline like this breaks in production, the first thing I check is identity, not YAML: which service connection the failing step used, whether it is federated, what role it holds at which scope, and whether someone renamed something. Most of the rest is in the run log if you read it top to bottom. I keep production behind an environment with an approval and an exclusive lock, pin every template to a tag, and never grant a service connection to all pipelines. Microsoft's references for everything above are the YAML schema, the ARM service connection docs and the KubernetesManifest@1 task reference.

Related: How to Build a CI/CD Pipeline With GitHub Actions: Step-by-Step (2026), How to Fix Kubernetes RBAC Forbidden: User Cannot List Resource (2026) and Kubernetes Secrets in GitOps (2026): SOPS vs Sealed Secrets vs ESO.

Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related