bbabafemi
All posts
DevOps

Azure DevOps YAML pipelines: multi-stage patterns that scale

How I structure multi-stage YAML pipelines once a single-file pipeline gets unwieldy. Templates, environments, approvals, and the small things that make a big difference.

January 13, 2026 3 min readby Babafemi Bulugbe

Single-file azure-pipelines.yml is great. Until it isn't. Around the time you're past 200 lines and three environments, you need patterns. Here are the ones I use.

The structure I land on

pipelines/
├── azure-pipelines.yml          # Main entry — orchestrates stages
├── stages/
│   ├── build.yml                # Build stage template
│   ├── test.yml                 # Test stage template
│   └── deploy.yml               # Reusable deploy stage
├── jobs/
│   └── deploy-app.yml           # Reusable deployment job
└── steps/
    ├── npm-install.yml          # Step templates
    └── azure-login.yml

Three layers — stages, jobs, steps — each in its own file. The main pipeline becomes a 30-line composition.

The main pipeline

trigger:
  branches: { include: [main] }
  paths: { exclude: ['**/*.md', 'docs/**'] }

variables:
  - group: shared-config
  - name: imageName
    value: 'webapp'

stages:
  - template: stages/build.yml
    parameters:
      imageName: $(imageName)

  - template: stages/test.yml
    parameters:
      buildArtifact: 'app-bundle'
    dependsOn: Build

  - template: stages/deploy.yml
    parameters:
      environment: 'staging'
      appName: 'myapp-staging'
    dependsOn: Test

  - template: stages/deploy.yml
    parameters:
      environment: 'production'
      appName: 'myapp-prod'
      requireApproval: true
    dependsOn: deploy_staging

It reads almost like prose. Each stage is a template that takes parameters.

A reusable deploy stage

# stages/deploy.yml
parameters:
  - name: environment
    type: string
  - name: appName
    type: string
  - name: requireApproval
    type: boolean
    default: false

stages:
  - stage: deploy_${{ parameters.environment }}
    displayName: Deploy → ${{ parameters.environment }}
    jobs:
      - deployment: Deploy
        environment: ${{ parameters.environment }}
        strategy:
          runOnce:
            deploy:
              steps:
                - template: ../steps/azure-login.yml
                - task: AzureWebApp@1
                  inputs:
                    appName: ${{ parameters.appName }}
                    package: $(Pipeline.Workspace)/app-bundle/*.zip

Approvals are configured on the environment in Azure DevOps, not in YAML. The requireApproval parameter is a label here — the actual gate lives in the environment configuration.

Template parameters with type safety

YAML templates support typed parameters:

parameters:
  - name: targetSlot
    type: string
    values:
      - staging
      - production
  - name: enableMonitoring
    type: boolean
    default: true
  - name: tags
    type: object
    default:
      env: prod
      owner: platform-team

The values: constraint catches typos at queue time. The type: object parameter lets you pass structured data — useful for things like tag dictionaries.

Sharing variables, but not too much

Two patterns:

Variable groups for shared, semi-secret config. Connection strings, app insights keys, etc. Linked from Library, scoped per environment.

Pipeline variables for stage-internal stuff. Things you wouldn't want to expose to other pipelines.

Don't use a single mega variable group across all environments — it makes scoping a nightmare and you end up with prod- prefixes everywhere.

Conditional execution

- task: AzureWebApp@1
  condition: |
    and(
      succeeded(),
      eq(variables['Build.SourceBranch'], 'refs/heads/main'),
      ne(variables['Build.Reason'], 'Schedule')
    )

Use this sparingly. Heavy conditionals in YAML get hard to read. If you find yourself writing complex conditions, split into separate stages with dependsOn + condition: succeeded().

Things that bite people

dependsOn confusion across templates. The stage name in dependsOn must match the generated stage name, including any prefix from your template parameters. Use displayName for humans, dependsOn for the machine.

Service connections + OIDC. When you migrate service connections to OIDC, your azure/login@v2 step in YAML still works, but the connection itself needs federated trust set up in the Azure AD app — covered in my OIDC post.

Environment approvals don't apply to PR builds. Useful — your dev team isn't blocked. Easy to forget when designing your gating.

Pipeline caching is not pipeline artifacts. Cache for restoring node_modules; artifacts for passing build output between stages. Mixing them up is a common mistake.

The principle

The pipeline is application code. Treat it like application code: small files, clear names, parameters instead of magic strings, version-controlled. The 30-line orchestrator file at the top of a well-templated pipeline is easier to maintain than a 1000-line monolith — and your teammates can actually read it without crying.