RYDIR

CI/CD for .NET on Azure: From Zero to Production

Feb 15, 2026

Setting up CI/CD for a .NET application on Azure means automating the entire path from code commit to production deployment — build, test, and release — using either Azure DevOps Pipelines or GitHub Actions. After five years of building and maintaining these pipelines at HUSS B.V., I can tell you the gap between a tutorial pipeline and a production-ready one is significant. This guide covers what that gap looks like and how to close it.

Why CI/CD Matters for .NET Projects

Manual deployments are a liability. Every time someone right-clicks “Publish” in Visual Studio, you are rolling the dice on consistency. CI/CD eliminates that risk by making every deployment identical, traceable, and reversible. It also forces you to write testable code — because if your pipeline runs tests on every commit, you actually have to write them.

For .NET on Azure specifically, the tooling is mature. Microsoft has invested heavily in making this path smooth, whether you pick Azure DevOps or GitHub Actions. The question is not if you should automate, it is how to do it properly.

Azure DevOps vs GitHub Actions for .NET

Both platforms can get the job done. The choice usually comes down to your organization’s existing tooling and preferences rather than a fundamental technical gap. Here is how they compare in practice:

Feature Azure DevOps Pipelines GitHub Actions
YAML pipeline support Yes Yes
Built-in .NET SDK tasks Rich task library (DotNetCoreCLI@2) Community actions + dotnet CLI directly
Azure integration Native service connections Azure Login action + service principals
Deployment slots First-class swap task Supported via azure/webapps-deploy
Artifact management Azure Artifacts built-in GitHub Packages or external feeds
Self-hosted agents Supported Supported (self-hosted runners)
Environments & approvals Environments with gates and checks Environments with protection rules
Pricing for private repos 1 free parallel job (1800 min/month) 2000 min/month free
Learning curve Steeper — more concepts to learn Lower — simpler YAML structure

My recommendation: if your organization already uses Azure DevOps for boards and repos, stick with Azure DevOps Pipelines. If your code lives on GitHub, use GitHub Actions. Migrating between the two is straightforward — the pipeline logic is nearly identical, just different YAML syntax.

How to Set Up CI/CD for .NET on Azure

A production-ready pipeline follows the same core flow regardless of platform: restore, build, test, publish, deploy. Below are working examples for both Azure DevOps and GitHub Actions that deploy a .NET 8 web application to Azure App Service.

Azure DevOps Pipeline

trigger:
  branches:
    include:
      - main

pool:
  vmImage: 'ubuntu-latest'

variables:
  buildConfiguration: 'Release'
  dotnetVersion: '8.x'
  azureSubscription: 'MyAzureServiceConnection'
  appName: 'my-app-production'

stages:
  - stage: Build
    displayName: 'Build & Test'
    jobs:
      - job: BuildJob
        steps:
          - task: UseDotNet@2
            inputs:
              packageType: 'sdk'
              version: '$(dotnetVersion)'

          - task: DotNetCoreCLI@2
            displayName: 'Restore'
            inputs:
              command: 'restore'
              projects: '**/*.csproj'

          - task: DotNetCoreCLI@2
            displayName: 'Build'
            inputs:
              command: 'build'
              arguments: '--configuration $(buildConfiguration) --no-restore'

          - task: DotNetCoreCLI@2
            displayName: 'Test'
            inputs:
              command: 'test'
              arguments: '--configuration $(buildConfiguration) --no-build --collect:"XPlat Code Coverage"'

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

          - publish: $(Build.ArtifactStagingDirectory)
            artifact: 'webapp'

  - stage: DeployStaging
    displayName: 'Deploy to Staging'
    dependsOn: Build
    jobs:
      - deployment: DeployStaging
        environment: 'staging'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: '$(azureSubscription)'
                    appType: 'webAppLinux'
                    appName: '$(appName)'
                    deployToSlotOrASE: true
                    slotName: 'staging'
                    package: '$(Pipeline.Workspace)/webapp/**/*.zip'

  - stage: DeployProduction
    displayName: 'Deploy to Production'
    dependsOn: DeployStaging
    jobs:
      - deployment: DeployProduction
        environment: 'production'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureAppServiceManage@0
                  inputs:
                    azureSubscription: '$(azureSubscription)'
                    action: 'Swap Slots'
                    webAppName: '$(appName)'
                    sourceSlot: 'staging'
                    targetSlot: 'production'

GitHub Actions Workflow

name: Build and Deploy

on:
  push:
    branches: [main]

env:
  DOTNET_VERSION: '8.x'
  AZURE_WEBAPP_NAME: 'my-app-production'

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

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --configuration Release --no-restore

      - name: Test
        run: dotnet test --configuration Release --no-build --verbosity normal

      - name: Publish
        run: dotnet publish --configuration Release --output ./publish

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

  deploy-staging:
    needs: build-and-test
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: webapp

      - name: Login to Azure
        uses: azure/login@v2
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: Deploy to staging slot
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ env.AZURE_WEBAPP_NAME }}
          slot-name: staging

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Login to Azure
        uses: azure/login@v2
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: Swap staging to production
        run: |
          az webapp deployment slot swap 
            --name ${{ env.AZURE_WEBAPP_NAME }} 
            --resource-group my-resource-group 
            --slot staging 
            --target-slot production

Deployment Slots and Zero-Downtime Releases

Deployment slots are the single most important feature for production .NET deployments on Azure App Service. The strategy is simple: deploy to a staging slot, verify it works, then swap staging into production. The swap is near-instant because Azure just flips the virtual IP routing — your app is already warmed up in the staging slot.

A few things I have learned the hard way:

  • Always configure slot-sticky settings. Connection strings and app settings that differ between staging and production — like database connections — need to be marked as “slot sticky” so they do not follow the swap.
  • Use health checks. Configure the App Service health check endpoint so Azure can verify your app is healthy before completing the swap.
  • Test the swap path, not just the deployment. I have seen pipelines where the deploy-to-slot step worked perfectly, but the swap failed because of configuration mismatches. Automate the full flow.

Infrastructure as Code: Provision Before You Deploy

Your pipeline should not assume the infrastructure already exists. Use Bicep or ARM templates to provision your Azure resources as part of the pipeline — or at least in a separate infrastructure pipeline that runs first.

At HUSS, I used Bicep templates to define the App Service Plan, App Service, deployment slots, Application Insights, and Key Vault references. This meant spinning up a new environment was a single pipeline run, not a day of clicking through the Azure portal.

A minimal Bicep snippet for an App Service with a staging slot:

resource appService 'Microsoft.Web/sites@2023-01-01' = {
  name: appName
  location: location
  properties: {
    serverFarmId: appServicePlan.id
    siteConfig: {
      netFrameworkVersion: 'v8.0'
    }
  }
}

resource stagingSlot 'Microsoft.Web/sites/slots@2023-01-01' = {
  parent: appService
  name: 'staging'
  location: location
  properties: {
    serverFarmId: appServicePlan.id
  }
}

Common Mistakes to Avoid

Not pinning your .NET SDK version. If you rely on whatever version is pre-installed on the build agent, your builds will break randomly when the agent image updates. Always specify the exact SDK version.

Skipping tests in the pipeline. I get it — tests slow things down. But a pipeline without tests is just automated risk. At minimum, run your unit tests. Add integration tests against a test database if you can.

Using the same service principal for everything. Create separate service principals for staging and production with appropriately scoped permissions. Least privilege is not optional.

Not caching NuGet packages. Both platforms support dependency caching. Use it. It cuts build times significantly on larger solutions.

Deploying directly to production. Even if you are a solo developer, use a staging slot. The five minutes it adds to your pipeline saves hours of debugging production incidents.

Frequently Asked Questions

Do I need Azure DevOps if my code is on GitHub?

No. GitHub Actions has excellent Azure integration through official actions like azure/login and azure/webapps-deploy. You only need Azure DevOps if you want its other features like Boards, Test Plans, or Azure Artifacts. For pure CI/CD, GitHub Actions is perfectly capable.

Can I deploy .NET Framework (not .NET Core) with these pipelines?

Yes, but with caveats. .NET Framework requires Windows build agents (windows-latest instead of ubuntu-latest), and you will use MSBuild tasks instead of dotnet CLI commands. The overall pipeline structure stays the same — build, test, publish, deploy — but the specific commands differ.

How do I handle database migrations in the pipeline?

Run EF Core migrations as a step between deployment and slot swap. Deploy your code to the staging slot, run dotnet ef database update targeting the staging database, verify everything works, then swap. Never run migrations directly against production during the swap — do them before.

What is the cost of deployment slots on Azure App Service?

Deployment slots are available on Standard tier and above at no extra cost for the slot itself. However, the staging slot consumes resources from your App Service Plan when it is running. You can stop the staging slot after the swap to reclaim those resources if cost is a concern.

Should I use YAML pipelines or the classic editor in Azure DevOps?

Always use YAML pipelines. The classic editor is being phased out, and YAML pipelines live in your repository alongside your code. This means your pipeline definition is version-controlled, reviewable in pull requests, and portable. There is no good reason to use the classic editor for new projects.