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.
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.