Tuesday, 3 November 2026

How do you implement CI/CD for a .NET application in Azure?

CI/CD for a .NET Application in Azure

CI (continuous integration) builds and tests every change automatically. CD (continuous delivery/deployment) ships the tested build through environments to production in a repeatable, low-risk way. The two common tool choices are GitHub Actions and Azure Pipelines (Azure DevOps). The structure is the same in both.

The pipeline shape

Commit/PR ─► Build ─► Test ─► Publish artifact ─► Deploy Dev ─► Deploy Staging ─► (approval) ─► Production
                                  (build once)                    (slot / test)                  (swap)

Principles:

  • Build once, deploy the same artifact everywhere. Don't rebuild per environment. Differences come from configuration, not code.
  • Run tests on every PR and block merges when they fail (branch protection).
  • No stored credentials. Authenticate to Azure with OIDC (workload identity federation).
  • Gate production with approvals and automated checks.

Option 1: GitHub Actions

One-time setup: passwordless Azure login

Create an app registration (or user-assigned managed identity), add a federated credential trusting your repo, and grant it a role on the target scope.

# App registration + service principal
az ad app create --display-name gh-deploy-my-app
az ad sp create --id <appId>

# Trust GitHub Actions for the main branch
az ad app federated-credential create --id <appId> --parameters '{
  "name": "gh-main",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:my-org/my-repo:environment:production",
  "audiences": ["api://AzureADTokenExchange"]
}'

# Least-privilege role at resource group scope
az role assignment create --assignee <appId> --role "Website Contributor" \
  --scope /subscriptions/<sub-id>/resourceGroups/my-rg

The subject must match how the workflow runs. For jobs using a GitHub environment, use environment:<name>. For branch-based runs, use ref:refs/heads/main. You need one federated credential per subject.

Store AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID as repository secrets or variables. They're identifiers, not credentials.

Workflow

name: ci-cd

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  id-token: write   # needed for OIDC
  contents: read

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - run: dotnet restore
      - run: dotnet build --no-restore -c Release
      - run: dotnet test --no-build -c Release --logger trx --collect:"XPlat Code Coverage"

      - run: dotnet publish src/MyApi/MyApi.csproj -c Release -o ./publish --no-build

      - uses: actions/upload-artifact@v4
        with:
          name: api
          path: ./publish

  deploy-staging:
    needs: build-test
    if: github.ref == 'refs/heads/main'   # PRs build and test only
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/download-artifact@v4
        with: { name: api, path: ./publish }

      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - uses: azure/webapps-deploy@v3
        with:
          app-name: my-api
          slot-name: staging
          package: ./publish

      - name: Smoke test
        run: curl --fail --retry 5 --retry-delay 5 https://my-api-staging.azurewebsites.net/health

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production        # configure required reviewers on this environment
    steps:
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Swap staging into production
        run: |
          az webapp deployment slot swap \
            --name my-api --resource-group my-rg \
            --slot staging --target-slot production

Notes:

  • GitHub Environments (staging, production) give you required reviewers, wait timers, and environment-scoped secrets.
  • Deployment slots require Standard tier or above. Slot swaps warm up the staging instance first, so production sees no cold start.
  • To roll back, swap again. The previous version is still sitting in the staging slot.

Option 2: Azure Pipelines (YAML)

Create a service connection to Azure using workload identity federation (the recommended option over secret-based connections).

trigger:
  branches:
    include: [main]

pool:
  vmImage: ubuntu-latest

variables:
  buildConfiguration: Release

stages:
- stage: Build
  jobs:
  - job: BuildTest
    steps:
    - task: UseDotNet@2
      inputs:
        packageType: sdk
        version: 8.0.x

    - script: dotnet restore
    - script: dotnet build --no-restore -c $(buildConfiguration)

    - task: DotNetCoreCLI@2
      displayName: Test
      inputs:
        command: test
        projects: '**/*Tests.csproj'
        arguments: '-c $(buildConfiguration) --no-build'

    - task: DotNetCoreCLI@2
      displayName: Publish
      inputs:
        command: publish
        publishWebProjects: true
        arguments: '-c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory)'
        zipAfterPublish: true

    - publish: $(Build.ArtifactStagingDirectory)
      artifact: drop

- stage: Staging
  dependsOn: Build
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  jobs:
  - deployment: DeployStaging
    environment: staging
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureWebApp@1
            inputs:
              azureSubscription: my-service-connection
              appType: webAppLinux
              appName: my-api
              deployToSlotOrASE: true
              resourceGroupName: my-rg
              slotName: staging
              package: $(Pipeline.Workspace)/drop/**/*.zip

- stage: Production
  dependsOn: Staging
  jobs:
  - deployment: DeployProd
    environment: production      # add Approvals and checks on this environment
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureAppServiceManage@0
            inputs:
              azureSubscription: my-service-connection
              action: Swap Slots
              webAppName: my-api
              resourceGroupName: my-rg
              sourceSlot: staging
              targetSlot: production

Azure DevOps Environments provide approvals, checks, and deployment history, the same role that GitHub Environments play.

Infrastructure as Code

Don't create Azure resources by hand. Define them in Bicep (or Terraform) and deploy them through the pipeline, so environments stay consistent and reviewable.

- uses: azure/arm-deploy@v2
  with:
    scope: resourcegroup
    resourceGroupName: my-rg
    template: ./infra/main.bicep
    parameters: ./infra/prod.bicepparam
    failOnStdErr: false

A common pattern is to deploy infrastructure first and the app second. Use what-if on pull requests to preview infrastructure changes before merging.

Database migrations

Don't let the app run migrations on startup in production. Prefer an explicit pipeline step:

# Idempotent SQL script, reviewable and safe to re-run
dotnet ef migrations script --idempotent -o migrate.sql \
  --project src/MyApi --startup-project src/MyApi

# Or a self-contained migration bundle
dotnet ef migrations bundle -o efbundle --project src/MyApi
./efbundle --connection "<connection-string>"

Authenticate to SQL with the pipeline's Entra identity rather than a password. Write migrations in an expand/contract style (add new columns and tables first, remove old ones later) so the previous version keeps working during a slot swap or rollback.

Configuration and secrets per environment

  • Keep non-secret settings in app settings, applied per slot or environment. Mark settings that must not move during a swap as slot settings (sticky).
  • Store secrets in Key Vault and use Key Vault references. Give the app a managed identity to read them.
  • Never put secrets in the repo or the build artifact. Use environment-scoped secrets only for pipeline identifiers.
  • Use Azure App Configuration with feature flags if you want to release code dark and enable features separately.

Containerized apps

If you deploy containers (Container Apps, AKS, App Service for Containers):

- run: |
    az acr login --name myacr
    docker build -t myacr.azurecr.io/my-api:${{ github.sha }} .
    docker push myacr.azurecr.io/my-api:${{ github.sha }}

- run: |
    az containerapp update --name my-api --resource-group my-rg \
      --image myacr.azurecr.io/my-api:${{ github.sha }}

Tag images with the commit SHA (never rely on latest), and give the app a managed identity with the AcrPull role. Container Apps revisions let you shift traffic gradually (blue/green or canary). For AKS, use Helm or GitOps (Flux/Argo CD).

Quality and safety gates

Add these as the pipeline matures:

  • Static analysis and security: Roslyn analyzers, dotnet list package --vulnerable, Dependabot or Renovate, CodeQL, secret scanning, and container image scanning (Defender for Cloud).
  • Integration tests against ephemeral resources (Testcontainers or a dedicated test environment).
  • Post-deploy smoke tests and health checks, which block the swap if they fail.
  • Load or performance checks on staging for critical paths.
  • Automated rollback if post-release metrics or alerts degrade.
  • Branch protection: required reviews, required status checks, and no direct pushes to main.

Good practices

  • Keep pipelines in the repo (YAML), versioned and reviewed like code.
  • Cache NuGet packages and use --no-restore and --no-build flags to speed up runs.
  • Use reusable workflows or templates to share logic across services.
  • Separate subscriptions or resource groups per environment, and use separate identities with least privilege for each.
  • Version your builds (for example, with MinVer or the commit SHA) and surface the version in a /health or /version endpoint, so you can confirm what's running.
  • Mark deployments in Application Insights (release annotations) so you can correlate regressions with releases.
  • Prefer small, frequent releases over large batches. They're easier to test and roll back.

Minimal starting point:

  1. Create a workflow that restores, builds, and tests on every PR.
  2. Add OIDC-based Azure login and deploy to a staging slot on merge to main.
  3. Add a smoke test and a manual approval on a production environment, then swap slots.
  4. Move infrastructure into Bicep and deploy it from the pipeline.
  5. Add security scanning, migrations as a controlled step, and alerts tied to releases.


No comments:

Post a Comment

Note: only a member of this blog may post a comment.