.NET CI/CD: Automated Deployment with GitHub Actions and Azure DevOps

14 min readFebruary 9, 2026Updated: Mar 9, 2026
.NET CI/CDGitHub Actions .NETAzure DevOps .NET.NET deploymentPipeline .NETAutomated deployment C#Docker .NET CIRelease automation

# .NET CI/CD: Automated Deployment with GitHub Actions and Azure DevOps

A mature CI/CD pipeline is essential for reliable .NET delivery. It reduces release risk, improves developer feedback loops, and standardizes deployment quality. In this article, I walk through the full anatomy of a production-grade pipeline, from the first commit to a successful deployment, complete with real configuration examples and hard-won lessons.

Why CI/CD Matters for .NET Projects

Manual deployments are error-prone. I have seen teams lose entire afternoons tracking down a deployment issue that turned out to be a missed build step or an environment variable that was set correctly on one developer's machine but nowhere else. CI/CD eliminates that entire category of problems.

A well-designed pipeline gives you:

  • **Reproducibility** -- every build runs in the same clean environment
  • **Speed** -- automated steps run in parallel, cutting release time from hours to minutes
  • **Confidence** -- tests and scans run on every commit, catching issues before they reach production
  • **Auditability** -- every deployment is traceable to a specific commit and pipeline run
  • Recommended Pipeline Flow

    A solid .NET CI/CD pipeline follows these stages in order:

  • Restore dependencies and build with deterministic settings
  • Run unit and integration tests
  • Execute static analysis and security scanning
  • Produce signed, versioned artifacts
  • Deploy progressively with a rollback strategy
  • Let me show you what each stage looks like in practice.

    GitHub Actions Workflow for .NET

    GitHub Actions is my go-to for most .NET projects. The YAML-based configuration lives in your repository, making it version-controlled and reviewable like any other code.

    Here is a complete workflow that covers build, test, publish, and deployment:

    yaml
    name: .NET CI/CD Pipeline
    
    on:
      push:
        branches: [main, develop]
      pull_request:
        branches: [main]
    
    env:
      DOTNET_VERSION: '8.0.x'
      REGISTRY: ghcr.io
      IMAGE_NAME: ${{ github.repository }}
    
    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 dependencies
            run: dotnet restore --locked-mode
    
          - name: Build
            run: dotnet build --no-restore --configuration Release
    
          - name: Run unit tests
            run: dotnet test --no-build --configuration Release --logger trx --results-directory TestResults/
    
          - name: Upload test results
            uses: actions/upload-artifact@v4
            if: always()
            with:
              name: test-results
              path: TestResults/
    
          - name: Publish
            run: dotnet publish src/MyApp/MyApp.csproj --no-build --configuration Release --output ./publish
    
          - name: Upload build artifact
            uses: actions/upload-artifact@v4
            with:
              name: app-artifact
              path: ./publish
    
      deploy-staging:
        needs: build-and-test
        runs-on: ubuntu-latest
        if: github.ref == 'refs/heads/develop'
        environment: staging
        steps:
          - name: Download artifact
            uses: actions/download-artifact@v4
            with:
              name: app-artifact
              path: ./publish
    
          - name: Deploy to Azure Web App (Staging)
            uses: azure/webapps-deploy@v3
            with:
              app-name: myapp-staging
              publish-profile: ${{ secrets.AZURE_STAGING_PUBLISH_PROFILE }}
              package: ./publish
    
      deploy-production:
        needs: build-and-test
        runs-on: ubuntu-latest
        if: github.ref == 'refs/heads/main'
        environment: production
        steps:
          - name: Download artifact
            uses: actions/download-artifact@v4
            with:
              name: app-artifact
              path: ./publish
    
          - name: Deploy to Azure Web App (Production)
            uses: azure/webapps-deploy@v3
            with:
              app-name: myapp-production
              publish-profile: ${{ secrets.AZURE_PROD_PUBLISH_PROFILE }}
              package: ./publish

    A few things worth noting here. The `--locked-mode` flag on restore ensures that your `packages.lock.json` file is respected -- no unexpected package version changes mid-pipeline. The `if: always()` on test result uploads means you get test reports even when tests fail, which is invaluable for debugging.

    Azure DevOps Pipelines

    For organizations already invested in the Microsoft ecosystem, Azure DevOps is a natural fit. It offers deeper integration with Azure services, built-in approval gates, and more granular access controls.

    yaml
    trigger:
      branches:
        include:
          - main
          - develop
    
    pool:
      vmImage: 'ubuntu-latest'
    
    variables:
      buildConfiguration: 'Release'
      dotnetVersion: '8.0.x'
    
    stages:
      - stage: Build
        jobs:
          - job: BuildAndTest
            steps:
              - task: UseDotNet@2
                inputs:
                  packageType: 'sdk'
                  version: $(dotnetVersion)
    
              - script: dotnet restore --locked-mode
                displayName: 'Restore dependencies'
    
              - script: dotnet build --configuration $(buildConfiguration) --no-restore
                displayName: 'Build solution'
    
              - script: dotnet test --configuration $(buildConfiguration) --no-build --collect:"XPlat Code Coverage"
                displayName: 'Run tests with coverage'
    
              - task: PublishCodeCoverageResults@2
                inputs:
                  summaryFileLocation: '**/coverage.cobertura.xml'
    
              - script: dotnet publish src/MyApp/MyApp.csproj --configuration $(buildConfiguration) --no-build --output $(Build.ArtifactStagingDirectory)
                displayName: 'Publish application'
    
              - task: PublishBuildArtifacts@1
                inputs:
                  pathToPublish: $(Build.ArtifactStagingDirectory)
                  artifactName: 'app-drop'
    
      - stage: DeployStaging
        dependsOn: Build
        condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop'))
        jobs:
          - deployment: DeployToStaging
            environment: 'staging'
            strategy:
              runOnce:
                deploy:
                  steps:
                    - task: AzureWebApp@1
                      inputs:
                        azureSubscription: 'MyAzureServiceConnection'
                        appName: 'myapp-staging'
                        package: '$(Pipeline.Workspace)/app-drop'

    In my deployment pipelines, I always set up approval gates between staging and production in Azure DevOps. The built-in environment approval feature is far more reliable than custom scripts. It also gives you a clean audit trail of who approved what and when.

    Docker Multi-Stage Build for .NET

    Containerization is the standard for .NET deployments today. A multi-stage Dockerfile keeps your images small and your build process clean by separating the SDK (build tools) from the runtime.

    dockerfile
    # Stage 1: Build
    FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
    WORKDIR /src
    
    # Copy project files and restore first for better layer caching
    COPY *.sln .
    COPY src/MyApp/MyApp.csproj src/MyApp/
    COPY src/MyApp.Domain/MyApp.Domain.csproj src/MyApp.Domain/
    COPY tests/MyApp.Tests/MyApp.Tests.csproj tests/MyApp.Tests/
    RUN dotnet restore
    
    # Copy everything else and build
    COPY . .
    RUN dotnet build --configuration Release --no-restore
    
    # Stage 2: Test
    FROM build AS test
    RUN dotnet test --configuration Release --no-build --logger trx --results-directory /TestResults
    # If tests fail, the build stops here
    
    # Stage 3: Publish
    FROM build AS publish
    RUN dotnet publish src/MyApp/MyApp.csproj --configuration Release --no-build --output /app/publish /p:UseAppHost=false
    
    # Stage 4: Runtime
    FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
    WORKDIR /app
    
    # Create a non-root user
    RUN adduser --disabled-password --gecos "" appuser
    USER appuser
    
    COPY --from=publish /app/publish .
    
    EXPOSE 8080
    ENV ASPNETCORE_URLS=http:"code-comment">//+:8080
    
    ENTRYPOINT ["dotnet", "MyApp.dll"]

    The key optimization here is copying `.csproj` files and running `dotnet restore` before copying the rest of the source code. Docker caches each layer, so if your project files have not changed, the restore step is skipped entirely. In my deployment pipelines, this single trick has cut build times by 40-60% on average.

    Running as a non-root user in the final stage is a security best practice. It limits the blast radius if your application is compromised.

    Integrating Docker Builds into CI

    Add the Docker build and push step to your GitHub Actions workflow:

    yaml
      build-and-push-image:
        needs: build-and-test
        runs-on: ubuntu-latest
        if: github.ref == 'refs/heads/main'
        permissions:
          contents: read
          packages: write
        steps:
          - uses: actions/checkout@v4
    
          - name: Log in to Container Registry
            uses: docker/login-action@v3
            with:
              registry: ${{ env.REGISTRY }}
              username: ${{ github.actor }}
              password: ${{ secrets.GITHUB_TOKEN }}
    
          - name: Build and push Docker image
            uses: docker/build-push-action@v5
            with:
              context: .
              push: true
              tags: |
                ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
                ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

    Always tag your images with the commit SHA in addition to `latest`. This makes rollbacks trivial -- you just redeploy the previous SHA tag.

    Deployment Strategies

    Choosing the right deployment strategy depends on your application's tolerance for downtime and the risk level of the release.

    Blue/Green Deployment

    Blue/green maintains two identical environments. One (blue) serves live traffic while the other (green) receives the new deployment. After verification, traffic switches to green.

    In Azure App Service, you get this for free with deployment slots:

    yaml
      - name: Deploy to staging slot
        uses: azure/webapps-deploy@v3
        with:
          app-name: myapp-production
          slot-name: staging
          package: ./publish
    
      - name: Run smoke tests against staging slot
        run: |
          response=$(curl -s -o /dev/null -w "%{http_code}" https:"code-comment">//myapp-production-staging.azurewebsites.net/health)
          if [ "$response" != "200" ]; then
            echo "Smoke test failed with status $response"
            exit 1
          fi
    
      - name: Swap slots
        uses: azure/cli@v2
        with:
          inlineScript: |
            az webapp deployment slot swap \
              --resource-group myapp-rg \
              --name myapp-production \
              --slot staging \
              --target-slot production

    The beauty of slot swapping is that if something goes wrong, you just swap back. The previous version is still running in the staging slot.

    Canary Deployment

    Canary releases route a small percentage of traffic to the new version and gradually increase it as confidence grows. This is ideal for high-traffic services where even a brief outage is unacceptable.

    With Azure Front Door or a service mesh like Istio, you can configure traffic splitting:

    yaml
    # Kubernetes canary with Istio VirtualService
    apiVersion: networking.istio.io/v1beta1
    kind: VirtualService
    metadata:
      name: myapp
    spec:
      hosts:
        - myapp.example.com
      http:
        - route:
            - destination:
                host: myapp-stable
                port:
                  number: 80
              weight: 90
            - destination:
                host: myapp-canary
                port:
                  number: 80
              weight: 10

    In my deployment pipelines, I start canary rollouts at 5% traffic and only promote after monitoring error rates and latency for at least 15 minutes. If any key metric degrades, the pipeline automatically rolls back.

    Rolling Deployment

    Rolling deployments update instances one at a time. In Kubernetes, this is the default strategy:

    yaml
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: myapp
    spec:
      replicas: 4
      strategy:
        type: RollingUpdate
        rollingUpdate:
          maxUnavailable: 1
          maxSurge: 1
      template:
        spec:
          containers:
            - name: myapp
              image: ghcr.io/myorg/myapp:latest
              readinessProbe:
                httpGet:
                  path: /health
                  port: 8080
                initialDelaySeconds: 10
                periodSeconds: 5

    The readiness probe is critical here. Without it, Kubernetes might route traffic to a pod that is still starting up.

    Security Scanning in CI

    Security cannot be an afterthought. Integrating scans into your pipeline catches vulnerabilities before they reach production.

    Dependency Vulnerability Scanning

    yaml
      - name: Check for known vulnerabilities
        run: dotnet list package --vulnerable --include-transitive
    
      - name: Run NuGet Audit
        run: dotnet restore --force-evaluate /p:NuGetAudit=true /p:NuGetAuditLevel=moderate

    Container Image Scanning with Trivy

    yaml
      - name: Scan Docker image for vulnerabilities
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
    
      - name: Upload Trivy scan results
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: 'trivy-results.sarif'

    Secret Scanning

    yaml
      - name: Scan for secrets in code
        uses: trufflesecurity/trufflehog@main
        with:
          extra_args: --only-verified

    In my deployment pipelines, I treat security scan failures as hard blockers. A pipeline that allows known critical vulnerabilities into production is worse than no pipeline at all -- it gives a false sense of security.

    Common CI/CD Mistakes

    Over the years, I have seen the same mistakes repeated across teams. Here are the ones that hurt the most:

    1. Not Pinning Action and Image Versions

    yaml
    # Bad -- this can break without warning
    uses: actions/checkout@main
    
    # Good -- pinned to a specific version
    uses: actions/checkout@v4

    Unpinned versions mean your pipeline can break on a random Tuesday because an upstream action pushed a breaking change. Always pin versions.

    2. Storing Secrets in the Repository

    It sounds obvious, but I still encounter connection strings and API keys committed to repos. Use your CI platform's secret management. In GitHub Actions, that means repository or environment secrets. In Azure DevOps, use Azure Key Vault integration.

    3. Skipping Tests to "Save Time"

    Some teams disable tests in the pipeline because they are slow. The correct fix is to make the tests faster -- parallelize them, separate unit from integration tests, or use test impact analysis. Never skip them.

    4. No Rollback Plan

    Every deployment should have a tested rollback mechanism. Whether it is a slot swap, a Kubernetes rollback, or a redeployment of the previous artifact, the rollback path should be automated and exercised regularly.

    5. Ignoring Pipeline Performance

    A pipeline that takes 45 minutes kills developer productivity. Track your pipeline execution time and optimize aggressively. Common wins include:

  • Caching NuGet packages between runs
  • Running independent jobs in parallel
  • Using incremental builds where possible
  • Choosing the right runner size
  • yaml
      - name: Cache NuGet packages
        uses: actions/cache@v4
        with:
          path: ~/.nuget/packages
          key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
          restore-keys: |
            ${{ runner.os }}-nuget-

    6. Same Pipeline for Every Branch

    Feature branches do not need to deploy to staging. Use conditional logic to run only the relevant stages:

    yaml
    deploy-staging:
      if: github.ref == 'refs/heads/develop'
    
    deploy-production:
      if: github.ref == 'refs/heads/main'

    Operational Guardrails

    Beyond the pipeline itself, you need observability into your release process:

  • **Deployment frequency** -- how often you ship. Higher is usually better, but only if your failure rate stays low.
  • **Change failure rate** -- what percentage of deployments cause incidents. Target below 5%.
  • **Mean time to recovery (MTTR)** -- how fast you recover from a failed deployment. Automated rollbacks bring this down dramatically.
  • **Lead time for changes** -- the time from commit to production. A healthy pipeline keeps this under an hour.
  • Track these metrics and review them monthly. They tell you more about your team's delivery health than any single tool or configuration.

    Conclusion

    Effective CI/CD is both technical automation and release governance. A well-built pipeline does more than run commands -- it enforces quality gates, enables safe deployments, and gives the entire team confidence that every release is production-ready. The investment in setting it up properly pays dividends on every single deployment.

    I can help design a production-focused CI/CD pipeline for your .NET services.

    Related Articles

    Have a Flutter Project?

    I build high-performance Flutter applications for iOS, Android, and web.

    Get in Touch