| name | azure-pipelines |
| description | [Applies to: **/*] This guide provides opinionated, actionable best practices for writing secure, performant, and maintainable Azure Pipelines using YAML, focusing on modern CI/CD patterns and common pitfalls. |
| source | cursor_mdc |
azure-pipelines Best Practices
Azure Pipelines are the backbone of modern CI/CD. This guide enforces a YAML-first, security-conscious, and highly automated approach to pipeline development.
Code Organization and Structure
Always define pipelines using YAML for version control, reusability, and clarity. Structure your pipelines using multi-stage designs and leverage templates for common patterns.
1. Embrace Multi-Stage Pipelines
Separate distinct phases (Build, Test, Deploy) into stages. This provides clear logical separation, enables independent quality gates, and improves visibility.
❌ BAD: Monolithic pipeline with all steps in one job.
jobs:
- job: BuildAndDeploy
steps:
- script: dotnet build
- script: dotnet test
- script: az webapp deploy
✅ GOOD: Clearly defined stages for build, test, and deployment.
stages:
- stage: Build
jobs:
- job: BuildApp
steps:
- script: dotnet build
- publish: $(Build.ArtifactStagingDirectory)
artifact: drop
- stage: Test
dependsOn: Build
jobs:
- job: RunTests
steps:
- download: current
artifact: drop
- script: dotnet test --logger "trx;LogFileName=test-results.trx"
- task: PublishTestResults@2
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '**/*.trx'
- stage: Deploy
dependsOn: Test
condition: succeeded('Test')
2. Leverage Templates for Reusability
Define common stages, jobs, or steps in reusable templates. This keeps your pipelines DRY, consistent, and easier to maintain.
❌ BAD: Copy-pasting build steps across multiple azure-pipelines.yml files.
stages:
- stage: Build
jobs:
- job: BuildServiceA
steps:
- script: npm install
- script: npm run build
stages:
- stage: Build
jobs:
- job: BuildServiceB
steps:
- script: npm install
- script: npm run build
✅ GOOD: Centralize common build logic in a template.
parameters:
- name: appName
type: string
default: 'app'
jobs:
- job: BuildNodeApp
displayName: Build ${{ parameters.appName }}
steps:
- task: NodeTool@0
inputs:
versionSpec: '18.x'
displayName: 'Install Node.js'
- script: npm install
displayName: 'Install Dependencies'
- script: npm run build
displayName: 'Build App'
- publish: $(Build.ArtifactStagingDirectory)
artifact: drop-${{ parameters.appName }}
displayName: 'Publish Build Artifact'
stages:
- stage: Build
jobs:
- template: ../templates/build-node-app.yml
parameters:
appName: ServiceA
stages:
- stage: Build
jobs:
- template: ../templates/build-node-app.yml
parameters:
appName: ServiceB
Common Patterns and Anti-patterns
3. Secure Secrets with Azure Key Vault
Never hardcode secrets. Always use Azure Key Vault linked to Variable Groups for secure secret management.
❌ BAD: Storing secrets directly in Variable Groups or YAML.
variables:
MyApiKey: 'SuperSecretKey123'
✅ GOOD: Link Variable Group to Azure Key Vault.
variables:
- group: MySecrets
stages:
- stage: Deploy
jobs:
- job: DeployApp
steps:
- script: echo "Using API Key: $(MyApiKey)"
Note: $(MyApiKey) will be masked in logs if it's a secret variable.
4. Use Workload Identity Federation for Service Connections
Prefer Workload Identity Federation (OIDC) over Service Principals for Azure Service Connections. It eliminates the need for manual secret management.
❌ BAD: Service Connection using a Service Principal with a client secret.
✅ GOOD: Service Connection using Workload Identity Federation.
When creating a new Azure Resource Manager service connection, choose "Workload Identity Federation (automatic)" if available.
Performance Considerations
5. Cache Dependencies
Speed up builds by caching frequently used dependencies (e.g., node_modules, pip packages, dotnet NuGet packages).
❌ BAD: Installing dependencies from scratch on every build.
steps:
- script: npm install
displayName: 'Install Node Modules'
- script: dotnet restore
displayName: 'Restore NuGet Packages'
✅ GOOD: Use the Cache@2 task.
steps:
- task: Cache@2
inputs:
key: 'npm | "$(Agent.OS)" | **/package-lock.json'
path: '$(npm_config_cache)'
cacheHitVar: 'NPM_CACHE_RESTORED'
displayName: 'Cache npm packages'
- script: npm install
displayName: 'Install Node Modules'
condition: ne(variables.NPM_CACHE_RESTORED, 'true')
- task: Cache@2
inputs:
key: 'nuget | "$(Agent.OS)" | $(Build.SourcesDirectory)/**/*.csproj'
path: '$(NUGET_PACKAGES)'
cacheHitVar: 'NUGET_CACHE_RESTORED'
displayName: 'Cache NuGet packages'
- script: dotnet restore
displayName: 'Restore NuGet Packages'
condition: ne(variables.NUGET_CACHE_RESTORED, 'true')
6. Run Jobs in Parallel
For independent tasks, run jobs in parallel to reduce overall pipeline execution time.
❌ BAD: Sequential jobs when they could run concurrently.
jobs:
- job: Build
- job: Lint
dependsOn: Build
- job: UnitTests
dependsOn: Build
✅ GOOD: Parallelize independent jobs.
jobs:
- job: Build
- job: Lint
dependsOn: []
- job: UnitTests
dependsOn: Build
Common Pitfalls and Gotchas
7. Avoid Exposing Secrets to Fork Builds
When working with public repositories, prevent secrets from being exposed to builds from forks.
❌ BAD: Enabling "Make secrets available to builds of forks" in pipeline settings.
✅ GOOD: Keep "Make secrets available to builds of forks" disabled. Manually trigger fork builds after review if necessary.
8. Use Project-Scoped Build Identities
Restrict pipeline permissions to the project level to minimize lateral exposure.
❌ BAD: Using a collection-level build identity (Project Collection Build Service (YourCollectionName)).
✅ GOOD: Use the default project-level build identity (YourProjectName Build Service (YourOrganizationName)).
This is the default for new YAML pipelines. Verify Job authorization scope is set to Project Collection or Current project as appropriate for your needs, but generally Current project is more secure.
Configuration Management
9. Define Infrastructure as Code (IaC)
Manage your infrastructure (Azure resources, environments) as code (e.g., Bicep, ARM templates, Terraform) and deploy it via pipelines.
❌ BAD: Manually provisioning environments or using UI-driven deployments only.
✅ GOOD: Deploy infrastructure using a dedicated IaC stage.
stages:
- stage: DeployInfrastructure
jobs:
- deployment: DeployBicep
environment: 'DevEnvironment'
strategy:
runOnce:
deploy:
steps:
- task: AzureCLI@2
inputs:
azureSubscription: 'MyAzureSubscription'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az group create --name $(ResourceGroupName) --location $(Location)
az deployment group create \
--resource-group $(ResourceGroupName) \
--template-file ./infra/main.bicep \
--parameters environmentType=dev
Environment Variables
10. Use Predefined Variables Correctly
Leverage Azure Pipelines' predefined variables for consistent and reliable access to build, release, and agent information.
❌ BAD: Hardcoding paths or trying to guess build numbers.
steps:
- script: echo "Build number: 1.0.0.$(date +%s)"
- script: cp ./dist/* /home/vsts/work/1/a/
✅ GOOD: Use predefined variables.
steps:
- script: echo "Build number: $(Build.BuildNumber)"
- script: cp $(Build.SourcesDirectory)/dist/* $(Build.ArtifactStagingDirectory)/
Logging
11. Mask Sensitive Information in Logs
Ensure secrets are never printed to logs. Azure Pipelines automatically masks variables marked as secret.
❌ BAD: Printing a secret directly.
steps:
- script: echo "My secret key is $(MyApiKey)"
✅ GOOD: Access secret variables, which are automatically masked.
steps:
- script: echo "Attempting to use API Key..."
- script: az login --service-principal -u $(ServicePrincipalId) -p $(ServicePrincipalKey) --tenant $(TenantId)
Testing Approaches
12. Integrate Comprehensive Testing Early
Run unit tests and static analysis (SAST) as early as possible in the CI stage. Dedicate separate stages for integration and end-to-end tests.
❌ BAD: Only running manual tests or basic compilation.
✅ GOOD: Multi-layered testing strategy.
stages:
- stage: Build
jobs:
- job: BuildAndScan
steps:
- script: dotnet build
- task: SonarQubePrepare@5
- task: SonarQubeAnalyze@5
- task: SonarQubePublish@5
- stage: Test
dependsOn: Build
jobs:
- job: UnitAndIntegrationTests
steps:
- script: dotnet test --filter "Category=Unit"
- script: dotnet test --filter "Category=Integration"
- task: PublishTestResults@2
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '**/*.trx'
- stage: E2ETest
dependsOn: