DevOpsil
Azure
90%
Needs Review

Azure DevOps Pipelines: YAML CI/CD from Build to Production

Sarah ChenSarah Chen5 min read

Classic vs YAML Pipelines

Azure DevOps offers two pipeline types. Classic (GUI) pipelines are being deprecated — the YAML format is the right choice for any new pipeline. Benefits:

  • Stored in version control alongside code
  • Reviewable via pull requests
  • Reusable templates across projects
  • Full audit trail of pipeline changes

Basic YAML Pipeline Structure

# azure-pipelines.yml (at repo root)
trigger:
  branches:
    include:
      - main
      - develop

pr:
  branches:
    include:
      - main

pool:
  vmImage: ubuntu-latest

variables:
  imageName: 'myapp'
  containerRegistry: 'productionregistry.azurecr.io'

stages:
  - stage: Build
    jobs:
      - job: BuildAndTest
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: '20.x'

          - script: npm ci
            displayName: Install dependencies

          - script: npm test
            displayName: Run tests

          - script: npm run build
            displayName: Build application

Multi-Stage Pipeline

Real pipelines have multiple stages. Each stage can have deployment approvals:

stages:
  - stage: CI
    displayName: Build & Test
    jobs:
      - job: Build
        pool:
          vmImage: ubuntu-latest
        steps:
          - script: npm ci && npm test && npm run build
          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: './dist'
              artifact: 'dist'

  - stage: BuildImage
    displayName: Build Docker Image
    dependsOn: CI
    jobs:
      - job: DockerBuild
        steps:
          - task: Docker@2
            displayName: Build and push image
            inputs:
              command: buildAndPush
              repository: $(imageName)
              dockerfile: Dockerfile
              containerRegistry: 'production-acr-service-connection'
              tags: |
                $(Build.BuildId)
                latest

  - stage: DeployStaging
    displayName: Deploy to Staging
    dependsOn: BuildImage
    jobs:
      - deployment: DeployStaging
        environment: staging
        strategy:
          runOnce:
            deploy:
              steps:
                - task: KubernetesManifest@1
                  inputs:
                    action: deploy
                    kubernetesServiceConnection: 'staging-aks'
                    manifests: k8s/*.yaml
                    containers: |
                      $(containerRegistry)/$(imageName):$(Build.BuildId)

  - stage: DeployProduction
    displayName: Deploy to Production
    dependsOn: DeployStaging
    jobs:
      - deployment: DeployProduction
        environment: production   # Has approval gate configured
        strategy:
          runOnce:
            deploy:
              steps:
                - task: KubernetesManifest@1
                  inputs:
                    action: deploy
                    kubernetesServiceConnection: 'production-aks'
                    manifests: k8s/*.yaml
                    containers: |
                      $(containerRegistry)/$(imageName):$(Build.BuildId)

Environments and Approvals

Environments track deployments and optionally require approvals before a deployment runs.

  1. In Azure DevOps, go to Pipelines → Environments → New environment
  2. Name it production
  3. Click Approvals and checks → + → Approvals
  4. Add approvers (individuals or groups) and set timeout

When a pipeline hits a deployment job targeting production, it pauses and sends approval notifications to the configured approvers. No YAML changes needed.


Variable Groups and Key Vault Integration

Store reusable variables in Pipelines → Library → Variable Groups:

variables:
  - group: production-secrets     # Links to a Library variable group
  - name: imageName
    value: myapp

Link a variable group directly to Azure Key Vault for automatic secret sync:

  1. Library → New variable group → Link secrets from Azure Key Vault
  2. Select subscription and Key Vault
  3. Add the secrets you want available as variables

In the pipeline, use them like any variable: $(db-password).


Container Jobs

Run your pipeline steps inside a Docker container — eliminates "works on my machine" issues:

jobs:
  - job: Test
    container:
      image: node:20-alpine
    steps:
      - script: npm ci
      - script: npm test

  - job: BuildGo
    container:
      image: golang:1.22-alpine
    steps:
      - script: go build ./...
      - script: go test ./...

Use a matrix for multi-version testing:

jobs:
  - job: TestMatrix
    strategy:
      matrix:
        Node18:
          nodeVersion: '18.x'
        Node20:
          nodeVersion: '20.x'
        Node22:
          nodeVersion: '22.x'
    steps:
      - task: NodeTool@0
        inputs:
          versionSpec: $(nodeVersion)
      - script: npm ci && npm test

Reusable Templates

Templates let you share pipeline logic across repos. Create a templates/ folder:

# templates/build-node.yml
parameters:
  - name: nodeVersion
    type: string
    default: '20.x'
  - name: testCommand
    type: string
    default: 'npm test'

steps:
  - task: NodeTool@0
    inputs:
      versionSpec: ${{ parameters.nodeVersion }}
    displayName: Use Node.js ${{ parameters.nodeVersion }}

  - script: npm ci
    displayName: Install dependencies

  - script: ${{ parameters.testCommand }}
    displayName: Run tests

  - script: npm run build
    displayName: Build

  - task: PublishTestResults@2
    inputs:
      testResultsFormat: JUnit
      testResultsFiles: '**/test-results.xml'

Reference the template:

# azure-pipelines.yml
stages:
  - stage: CI
    jobs:
      - job: Build
        steps:
          - template: templates/build-node.yml
            parameters:
              nodeVersion: '20.x'
              testCommand: 'npm run test:ci'

For templates in another repository:

resources:
  repositories:
    - repository: templates
      type: git
      name: DevOps/pipeline-templates
      ref: refs/heads/main

stages:
  - stage: CI
    jobs:
      - template: node/build.yml@templates

Deployment Strategies

Rolling Update

strategy:
  rolling:
    maxParallel: 2
    deploy:
      steps:
        - script: ./deploy.sh

Blue-Green

strategy:
  runOnce:
    preDeploy:
      steps:
        - script: ./switch-to-blue.sh
    deploy:
      steps:
        - script: ./deploy-to-inactive.sh
    routeTraffic:
      steps:
        - script: ./switch-traffic.sh
    postRouteTraffic:
      steps:
        - script: ./smoke-tests.sh
    on:
      failure:
        steps:
          - script: ./rollback.sh
      success:
        steps:
          - script: ./cleanup-old-slot.sh

Canary

strategy:
  canary:
    increments: [10, 20, 50, 100]   # Deploy to 10%, then 20%, etc.
    preDeploy:
      steps:
        - script: echo "Pre-deploy checks"
    deploy:
      steps:
        - task: KubernetesManifest@1
          inputs:
            action: deploy
            strategy: canary
            percentage: $(System.StageAttempt)
    postRouteTraffic:
      steps:
        - script: ./run-smoke-tests.sh

Service Connections

Service connections are how Azure DevOps authenticates to external services. Common types:

Azure Resource Manager (for Azure deployments):

  1. Project Settings → Service connections → New → Azure Resource Manager
  2. Use "Workload Identity federation" (no secret to rotate) or "Service principal"
  3. Scope to subscription or resource group

Docker Registry (for ACR or Docker Hub):

  1. New → Docker Registry
  2. Select Azure Container Registry and subscription — no credentials needed

Kubernetes (for kubectl tasks):

  1. New → Kubernetes
  2. Select Azure subscription and AKS cluster — uses kubeconfig

Publish Test Results and Code Coverage

steps:
  - script: npm run test:ci -- --reporter=junit --coverage
    displayName: Run tests with coverage

  - task: PublishTestResults@2
    condition: succeededOrFailed()
    inputs:
      testResultsFormat: JUnit
      testResultsFiles: '**/junit-report.xml'
      testRunTitle: 'Unit Tests - Node $(nodeVersion)'
      failTaskOnFailedTests: true

  - task: PublishCodeCoverageResults@2
    inputs:
      codeCoverageTool: Cobertura
      summaryFileLocation: coverage/cobertura-coverage.xml
      reportDirectory: coverage/lcov-report

Results appear in the pipeline run under Tests and Code Coverage tabs.


Caching Dependencies

variables:
  npm_config_cache: $(Pipeline.Workspace)/.npm

steps:
  - task: Cache@2
    inputs:
      key: 'npm | "$(Agent.OS)" | package-lock.json'
      restoreKeys: |
        npm | "$(Agent.OS)"
      path: $(npm_config_cache)
    displayName: Cache npm packages

  - script: npm ci
    displayName: Install (uses cache if hit)

Cache hit skips the npm ci download from npm registry, reducing build time from minutes to seconds on warm runs.

Share:

Was this article helpful?

Sarah Chen
Sarah Chen

CI/CD Engineering Lead

Automation evangelist who believes no deployment should require a human. I write pipelines, break pipelines, and write about both. Code-first, always.

Related Articles

More in Azure

View all →

Discussion