Learnixo
Back to blog
Backend Systemsintermediate

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.

LearnixoJune 3, 20266 min read
.NETC#Azure DevOpsCI/CDPipelinesAzureDevOps
Share:š•

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:

YAML
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 commands

Basic Build Pipeline

Create azure-pipelines.yml at the repo root:

YAML
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)

YAML
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:

YAML
# Reference a variable group
variables:
  - group: OrderFlow-Shared        # contains acrLoginServer, acrName
  - group: OrderFlow-Production    # contains connectionStrings, apiKeys (secret)
  - name: buildConfiguration
    value: Release

Creating a variable group:

  1. Azure DevOps → Pipelines → Library → + Variable group
  2. Add variables (check "lock" icon for secrets)
  3. Can link to Azure Key Vault for automatic sync
YAML
# Key Vault-linked variable group — values come from Key Vault automatically
variables:
  - group: OrderFlow-KeyVault       # linked to Azure Key Vault

Environments and Approvals

  1. Go to Pipelines → Environments → New environment
  2. Name it "production"
  3. Click "Approvals and checks" → Add → Approvals
  4. Add required reviewers

Now any stage using environment: production pauses for approval before running.

Pre-deployment Gates

YAML
      - deployment: Production
        environment: 'production'
        # Checks can include:
        # - Manual approvals
        # - Work item queries (all linked items resolved)
        # - REST API call returning success
        # - Business hours gate

Service Connections

Service connections authenticate to external services:

Azure subscription:

  1. Project Settings → Service connections → New service connection → Azure Resource Manager
  2. Choose "Service principal (automatic)" for simplest setup
  3. Name it "OrderFlow-Azure" — use this name in azureSubscription

Azure Container Registry:

  1. New service connection → Docker Registry
  2. Select Azure Container Registry
  3. Name it "OrderFlow-ACR"

Running Tests with Docker Services

YAML
    - 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:

YAML
# 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
YAML
# 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?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.