.NET Core

.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

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:

  1. Restore dependencies and build with deterministic settings
  2. Run unit and integration tests
  3. Execute static analysis and security scanning
  4. Produce signed, versioned artifacts
  5. 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://+: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://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