Azure DevOps for .NET: Pipelines, Releases, and Environments
Build a production CI/CD pipeline for .NET with Azure DevOps. Covers YAML pipelines, multi-stage build/test/deploy, variable groups, environments, approvals, artifact publishing, and Azure deployment.
Azure DevOps vs GitHub Actions
Both are mature CI/CD platforms. Azure DevOps is Microsoft's enterprise offering:
| | Azure DevOps | GitHub Actions | |---|---|---| | Pipeline format | YAML or classic | YAML only | | Integration | Azure-native, work items, boards | GitHub-native, PRs, issues | | Agents | Microsoft-hosted + self-hosted | GitHub-hosted + self-hosted | | Secrets | Variable groups (Library) | Secrets + environments | | Approvals | Gates + approvals | Environment protection rules | | Cost | Free for 5 users + parallel job | Free minutes per month |
Use Azure DevOps when your organisation already uses Azure Boards, Repos, or has existing Azure DevOps infrastructure.
Pipeline Anatomy
Azure DevOps YAML pipelines have this structure:
trigger: # what triggers the pipeline
pool: # which agent to use
variables: # pipeline-level variables
stages: # top-level grouping (optional)
jobs: # parallel or sequential work units
steps: # individual commandsBasic Build Pipeline
Create azure-pipelines.yml at the repo root:
trigger:
branches:
include:
- main
- develop
paths:
exclude:
- docs/**
- '*.md'
pr:
branches:
include:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
buildConfiguration: 'Release'
dotnetVersion: '9.x'
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK'
inputs:
version: $(dotnetVersion)
- task: Cache@2
displayName: 'Cache NuGet packages'
inputs:
key: 'nuget | "$(Agent.OS)" | **/packages.lock.json,!**/bin/**'
restoreKeys: 'nuget | "$(Agent.OS)"'
path: '$(NUGET_PACKAGES)'
- script: dotnet restore
displayName: 'Restore'
- script: dotnet build --no-restore -c $(buildConfiguration)
displayName: 'Build'
- script: |
dotnet test --no-build -c $(buildConfiguration) \
--logger trx \
--results-directory $(Agent.TempDirectory)/TestResults \
--collect "XPlat Code Coverage"
displayName: 'Test'
- task: PublishTestResults@2
displayName: 'Publish test results'
condition: always()
inputs:
testResultsFormat: VSTest
testResultsFiles: '$(Agent.TempDirectory)/TestResults/*.trx'
- task: PublishCodeCoverageResults@2
displayName: 'Publish coverage'
inputs:
summaryFileLocation: '$(Agent.TempDirectory)/TestResults/**/coverage.cobertura.xml'Multi-Stage Pipeline (Build ā Staging ā Production)
trigger:
branches:
include: [main]
variables:
- group: OrderFlow-Shared # variable group from Library
- name: imageTag
value: $(Build.BuildId)
stages:
# āāā Stage 1: Build & Test āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- stage: BuildTest
displayName: 'Build & Test'
jobs:
- job: Build
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UseDotNet@2
inputs:
version: '9.x'
- script: dotnet restore && dotnet build -c Release --no-restore
displayName: 'Build'
- script: dotnet test -c Release --no-build --logger trx
displayName: 'Test'
- task: PublishTestResults@2
condition: always()
inputs:
testResultsFormat: VSTest
testResultsFiles: '**/*.trx'
- task: DotNetCoreCLI@2
displayName: 'Publish'
inputs:
command: publish
publishWebProjects: true
arguments: '-c Release --output $(Build.ArtifactStagingDirectory)'
zipAfterPublish: true
- task: PublishBuildArtifacts@1
displayName: 'Publish artifact'
inputs:
PathtoPublish: $(Build.ArtifactStagingDirectory)
ArtifactName: drop
# āāā Stage 2: Docker āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- stage: Docker
displayName: 'Docker Build & Push'
dependsOn: BuildTest
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- job: DockerBuild
pool:
vmImage: 'ubuntu-latest'
steps:
- task: Docker@2
displayName: 'Build and push image'
inputs:
containerRegistry: 'OrderFlow-ACR' # service connection name
repository: 'orderflow-orders'
command: buildAndPush
Dockerfile: 'src/OrderFlow.Orders/Dockerfile'
tags: |
$(imageTag)
latest
# āāā Stage 3: Deploy Staging āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- stage: DeployStaging
displayName: 'Deploy ā Staging'
dependsOn: Docker
jobs:
- deployment: Staging
environment: 'staging' # Azure DevOps environment
pool:
vmImage: 'ubuntu-latest'
strategy:
runOnce:
deploy:
steps:
- task: AzureContainerApps@1
inputs:
azureSubscription: 'OrderFlow-Azure'
containerAppName: 'orders-staging'
resourceGroup: 'orderflow-staging-rg'
imageToDeploy: '$(acrLoginServer)/orderflow-orders:$(imageTag)'
# āāā Stage 4: Deploy Production āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- stage: DeployProduction
displayName: 'Deploy ā Production'
dependsOn: DeployStaging
jobs:
- deployment: Production
environment: 'production' # has approval gate configured
pool:
vmImage: 'ubuntu-latest'
strategy:
runOnce:
deploy:
steps:
- task: AzureContainerApps@1
inputs:
azureSubscription: 'OrderFlow-Azure'
containerAppName: 'orders-production'
resourceGroup: 'orderflow-production-rg'
imageToDeploy: '$(acrLoginServer)/orderflow-orders:$(imageTag)'Variable Groups
Store secrets and shared config in Library ā Variable Groups:
# Reference a variable group
variables:
- group: OrderFlow-Shared # contains acrLoginServer, acrName
- group: OrderFlow-Production # contains connectionStrings, apiKeys (secret)
- name: buildConfiguration
value: ReleaseCreating a variable group:
- Azure DevOps ā Pipelines ā Library ā + Variable group
- Add variables (check "lock" icon for secrets)
- Can link to Azure Key Vault for automatic sync
# Key Vault-linked variable group ā values come from Key Vault automatically
variables:
- group: OrderFlow-KeyVault # linked to Azure Key VaultEnvironments and Approvals
- Go to Pipelines ā Environments ā New environment
- Name it "production"
- Click "Approvals and checks" ā Add ā Approvals
- Add required reviewers
Now any stage using environment: production pauses for approval before running.
Pre-deployment Gates
- deployment: Production
environment: 'production'
# Checks can include:
# - Manual approvals
# - Work item queries (all linked items resolved)
# - REST API call returning success
# - Business hours gateService Connections
Service connections authenticate to external services:
Azure subscription:
- Project Settings ā Service connections ā New service connection ā Azure Resource Manager
- Choose "Service principal (automatic)" for simplest setup
- Name it "OrderFlow-Azure" ā use this name in
azureSubscription
Azure Container Registry:
- New service connection ā Docker Registry
- Select Azure Container Registry
- Name it "OrderFlow-ACR"
Running Tests with Docker Services
- job: IntegrationTests
services:
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
env:
ACCEPT_EULA: Y
SA_PASSWORD: TestPassw0rd!
ports:
- 1433:1433
steps:
- script: |
dotnet test tests/OrderFlow.IntegrationTests \
--logger trx
env:
ConnectionStrings__Default: "Server=localhost,1433;Database=TestDb;User Id=sa;Password=TestPassw0rd!;TrustServerCertificate=true"Templates ā Reusable Steps
Extract common steps into templates to avoid duplication:
# templates/dotnet-build.yml
parameters:
- name: configuration
default: Release
- name: projectPath
default: '**/*.csproj'
steps:
- task: UseDotNet@2
inputs:
version: '9.x'
- script: dotnet restore $(projectPath)
- script: dotnet build $(projectPath) -c ${{ parameters.configuration }} --no-restore
- script: dotnet test --no-build -c ${{ parameters.configuration }} --logger trx# Main pipeline
stages:
- stage: Build
jobs:
- job: BuildOrders
steps:
- template: templates/dotnet-build.yml
parameters:
projectPath: 'src/OrderFlow.Orders'Interview Questions
Q: What is the difference between a variable and a secret in Azure DevOps pipelines? Both are stored in variable groups or pipeline variables. Secrets are marked as locked ā they're encrypted, not shown in logs, and not passed to child processes by default. Regular variables are plaintext and visible in pipeline logs.
Q: What is an Azure DevOps environment and how do approvals work? An environment represents a deployment target (staging, production). You configure checks and approvals on it ā required reviewers, business hours, REST API gates. When a pipeline job targets an environment with an approval, the pipeline pauses and sends notifications. The deployment only proceeds when required approvers approve.
Q: What is a service connection and why is it needed? A service connection stores credentials for external services (Azure subscription, container registry, Kubernetes). The pipeline authenticates using the service connection without embedding credentials in the YAML file ā keeping secrets out of source control.
Q: What is the difference between dependsOn and condition in a pipeline?
dependsOn declares execution order ā a stage won't start until its dependencies complete. condition controls whether a stage runs at all ā e.g., eq(variables['Build.SourceBranch'], 'refs/heads/main') skips Docker push on PR builds.
Q: What are pipeline templates and when should you use them? Templates are reusable YAML files containing steps, jobs, or stages. Use them when the same pattern (build, test, security scan) appears in multiple pipelines ā keeps pipelines DRY, makes updates apply everywhere at once.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.