A .NET application is not done when it builds on your laptop. It is done when a pipeline restores it reproducibly, builds it the same way every time, runs its tests, and ships an artifact or a container that a real environment can run, all without a human copying files by hand. This guide covers CI/CD for .NET on the two platforms most teams actually use: GitHub Actions, with setup-dotnet, NuGet caching, matrix builds and container publishing, and Azure Pipelines, with its YAML equivalents. You will also see how to deploy to Azure without long-lived secrets using OIDC federated credentials, gate deployments with environments and approvals, and harden a pipeline against the supply-chain risks that come with running untrusted code on every pull request.

What CI/CD for .NET Actually Buys You#

Continuous integration means every push and pull request triggers an automated build and test run, so a broken change is caught in minutes instead of at the next release. Continuous delivery extends that to producing a deployable artifact, whether that is a NuGet package, a set of published binaries, or a container image, on every successful build. Continuous deployment goes one step further and ships that artifact to an environment automatically, usually gated by approvals for anything beyond a development slot. Most .NET teams land somewhere between the second and third: CI on every commit, automatic deployment to a shared development or staging environment, and a manual or approval-gated promotion to production.

The pipeline is also where you enforce practices covered elsewhere on this site. A dotnet build with analyzers as errors, a dotnet test run with coverage, and a dotnet format --verify-no-changes check only matter if they block a merge on failure; a rule a developer can skip locally is not a rule. The code quality guide covers the analyzer and formatting side, and this guide assumes those checks run in the same workflow that builds and deploys your code.

How a .NET Pipeline Runs: Jobs, Runners and the Build Graph#

Both platforms share the same shape. A pipeline is defined in YAML checked into your repository (.github/workflows/*.yml for GitHub Actions, azure-pipelines.yml or a file under a pipelines/ folder for Azure DevOps), and it is triggered by an event: a push, a pull request, a schedule, or a manual run. That YAML describes one or more jobs, each of which runs on a fresh, isolated runner (a GitHub-hosted or self-hosted machine, or an Azure DevOps agent), with no state left over from a previous run unless you explicitly cache or restore it. Inside a job, steps run in order: check out the repository, install the .NET SDK, restore, build, test, and publish. Jobs can depend on each other (needs in GitHub Actions, dependsOn in Azure Pipelines' stages), which is how a build job and a deploy job stay separate, so a deployment only starts once the build and every test has actually passed.

The .NET SDK is often not preinstalled in the version you need, so almost every .NET workflow starts by installing it explicitly with actions/setup-dotnet (GitHub Actions) or the UseDotNet@2 task (Azure Pipelines). Both read a global.json if one is present, and both can install more than one SDK version side by side in the same job, which matters for multi-targeted libraries and matrix builds later in this guide.

Getting Started: A Minimal Build-and-Test Workflow#

This workflow restores, builds and tests a solution on every push and pull request against main. It is the shape almost every other example in this guide builds on:

YAML
name: build

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

permissions:
  contents: read

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

      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: '10.0.x'

      - name: Restore
        run: dotnet restore

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

      - name: Test
        run: dotnet test --configuration Release --no-build --logger trx

--no-restore and --no-build are not just micro-optimizations: they make each step fail loudly if an earlier step's output is somehow missing, instead of silently re-running restore or build with slightly different flags. Pin dotnet-version to the same feature band your team develops against; letting it float to latest means a pipeline can start failing the moment a new SDK ships, with no code change to explain why.

Caching NuGet Restores#

Restore is usually the slowest step in a cold pipeline, because it downloads every package in the graph from scratch. setup-dotnet has built-in caching through its cache input, which uses your packages.lock.json file (NuGet lock files) to compute the cache key:

YAML
- uses: actions/setup-dotnet@v6
  with:
    dotnet-version: '10.0.x'
    cache: true
    cache-dependency-path: '**/packages.lock.json'

- run: dotnet restore --locked-mode

cache: true requires a lock file; the action fails outright if it cannot find one. Generate lock files for every project with dotnet restore --use-lock-file once, commit the resulting packages.lock.json files, and pass --locked-mode in CI so restore fails fast if a dependency drifted instead of silently pulling a new transitive version. If you are not ready to adopt lock files repository-wide, fall back to a manually keyed actions/cache step instead, hashing every project and props file that can affect restore:

YAML
- uses: actions/cache@v4
  with:
    path: ~/.nuget/packages
    key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }}
    restore-keys: nuget-${{ runner.os }}-

Either approach turns a multi-minute cold restore into a cache hit that finishes in seconds on most subsequent runs. Azure Pipelines has an equivalent Cache@2 task keyed the same way, or you can point NUGET_PACKAGES at a cached folder on a self-hosted agent.

Matrix Builds: Testing Across .NET Versions and Operating Systems#

A library that claims to support multiple target frameworks or platforms should prove it in CI, not just in a TargetFrameworks element. GitHub Actions' strategy.matrix runs the same job once per combination:

YAML
jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
        dotnet: ['8.0.x', '10.0.x']
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: ${{ matrix.dotnet }}
      - run: dotnet test --configuration Release

fail-fast: false is worth setting deliberately: the default cancels every other matrix job the moment one fails, which is fine for a quick smoke check but hides whether a failure on .NET 8 is unrelated to a separate failure on .NET 10. For a library that multi-targets in a single project (see the MSBuild and project system guide for how TargetFrameworks and conditional compilation work), a single job with one dotnet test invocation already exercises every target framework the .csproj lists, and the OS matrix is what you add on top for platform-specific behavior.

Test Results and Code Coverage#

dotnet test can collect coverage without any extra package on .NET 8 and later using the built-in data collector, and publish both a machine-readable results file and a coverage report as workflow artifacts:

YAML
- name: Test with coverage
  run: >
    dotnet test --configuration Release --no-build
    --logger "trx;LogFileName=test-results.trx"
    --collect:"XPlat Code Coverage"
    --results-directory ./TestResults

- uses: actions/upload-artifact@v7
  if: always()
  with:
    name: test-results
    path: ./TestResults

if: always() matters here: without it, a failed test run skips the upload step, and you lose the exact evidence you need to diagnose the failure. Feed the Cobertura XML that XPlat Code Coverage produces into reportgenerator to render an HTML summary, or upload it to a coverage service, and consider failing the build below a coverage threshold once your suite is mature enough that the number is meaningful rather than noisy.

Publishing Build Artifacts#

Once a build passes, publish the deployable output as a workflow artifact so a later job, or a human, can pick it up without rebuilding:

YAML
- name: Publish
  run: dotnet publish src/Api/Api.csproj -c Release -o ./publish --no-restore

- uses: actions/upload-artifact@v7
  with:
    name: api-publish
    path: ./publish
    retention-days: 14

A separate deploy job then downloads that same artifact with actions/download-artifact rather than re-running dotnet publish, which guarantees the exact bits that passed CI are the ones that reach an environment, instead of a second build that happens to produce equivalent-but-not-identical output.

Building and Publishing Containers#

The .NET SDK can publish a container image directly, with no Dockerfile, using the built-in PublishContainer target:

YAML
- name: Publish container
  run: >
    dotnet publish src/Api/Api.csproj -c Release
    --os linux --arch x64 /t:PublishContainer
    -p ContainerRegistry=ghcr.io
    -p ContainerRepository=${{ github.repository }}
    -p ContainerImageTag=${{ github.sha }}

This pushes straight to the named registry without a running Docker daemon on the runner; set ContainerArchiveOutputPath instead of ContainerRegistry if you want a tarball to scan before pushing it anywhere. Teams with an existing Dockerfile, or that need multi-stage native builds, custom base images, or non-.NET tooling baked into the image, keep using Docker directly instead:

YAML
- uses: docker/setup-buildx-action@v4

- uses: docker/login-action@v4
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/build-push-action@v7
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha cache reuses Docker layer cache across workflow runs, which matters as much for container builds as NuGet caching does for restore. The containerizing .NET guide covers image design, multi-stage Dockerfiles and runtime-image choices in depth; this section is about getting a built image out of CI and into a registry reliably.

Deploying to Azure with OIDC Federated Credentials#

The single most important pipeline security change most teams still haven't made is replacing a stored Azure service principal secret with OpenID Connect (OIDC). Instead of a long-lived AZURE_CREDENTIALS secret sitting in your repository, GitHub issues a short-lived, workflow-scoped identity token, and Azure exchanges it for access after verifying it against a federated identity credential you configured once on an app registration or managed identity, trusting your specific repository, branch or environment. There is no secret to leak, rotate or accidentally log.

YAML
permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v7

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

      - name: Deploy to App Service
        run: az webapp deploy --resource-group rg-api --name my-api --src-path ./publish

permissions: id-token: write is required at the workflow or job level or the token is never issued; the Azure login action explicitly warns against feeding it values from github.event.* (pull request titles, branch names and similar attacker-influenced fields), since only secrets.* and vars.* are safe inputs for client-id, tenant-id and subscription-id. None of those three values grant access on their own without a matching federated credential, but treat them as configuration you control rather than something to publish carelessly. Managing genuine secrets, including Key Vault references, is covered in the secrets management guide; the Azure hosting options guide compares App Service, Container Apps and AKS as the deployment target itself.

GitHub Environments and Manual Approvals#

An environment on a job ties it to protection rules configured in your repository settings: required reviewers, a minimum wait timer, and which branches are even allowed to deploy to it. The workflow above already references environment: production; once that environment has required reviewers configured, the job pauses after the build succeeds and waits for one of them to approve before the deploy steps run. Environment-scoped secrets and variables are also the right place for per-environment values like the AZURE_CLIENT_ID above, since a staging environment can point at an entirely different app registration and subscription than production without any change to the workflow file itself.

YAML
jobs:
  deploy-staging:
    environment: staging
    # no required reviewers: deploys automatically after CI passes

  deploy-production:
    needs: deploy-staging
    environment: production
    # required reviewers configured in repo settings: pauses for approval

Azure Pipelines has the equivalent concept in its own environment resource, covered next, with approvals and checks configured the same way, outside the YAML, on the environment itself.

Azure Pipelines: The YAML Equivalent#

Azure DevOps organizes a pipeline into stages, each containing jobs, each containing steps, which maps naturally onto a build stage followed by a gated deploy stage:

YAML
trigger:
  branches:
    include: [main]

pool:
  vmImage: ubuntu-latest

stages:
  - stage: Build
    jobs:
      - job: BuildAndTest
        steps:
          - task: UseDotNet@2
            inputs:
              packageType: sdk
              version: '10.0.x'
          - script: dotnet restore
          - script: dotnet build --configuration Release --no-restore
          - task: DotNetCoreCLI@2
            inputs:
              command: test
              arguments: '--configuration Release --no-build --collect:"XPlat Code Coverage"'
          - task: PublishBuildArtifacts@1
            inputs:
              pathToPublish: '$(Build.SourcesDirectory)/publish'
              artifactName: api-publish

  - stage: DeployProduction
    dependsOn: Build
    jobs:
      - deployment: DeployApi
        environment: production
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: 'prod-oidc-connection'
                    appName: my-api
                    package: '$(Pipeline.Workspace)/api-publish'

A deployment job against an environment resource is Azure Pipelines' equivalent of GitHub's protected environments: approvals, checks and deployment history are configured on the environment itself in the Azure DevOps UI, not in this YAML. azureSubscription references a service connection; use a workload identity federation service connection rather than a service principal with a stored secret, which is Azure DevOps' equivalent of the GitHub OIDC flow above, for the same reason: no long-lived credential for an attacker to steal from pipeline logs or variable groups.

Hardening Your Pipeline#

A pipeline that builds and deploys your software can be tricked into running arbitrary code with your credentials, so treat it as a security boundary, not an implementation detail.

  • Pin third-party actions to a full commit SHA, not just a version tag like @v4, since a tag can be moved to point at different, potentially malicious, code. Use Dependabot to update the pinned SHA automatically while still reviewing every change as a pull request.
  • Set permissions explicitly at the workflow or job level, starting from contents: read, and add only what a specific job needs (id-token: write for OIDC, packages: write to push a container). Never rely on the broad, historically default GITHUB_TOKEN permissions.
  • Never trigger on pull_request_target while checking out and running the pull request's own code. That trigger runs with the base repository's secrets and elevated token, and combining it with untrusted code from a fork is a well-known way pipelines get compromised.
  • Prefer OIDC over stored cloud credentials everywhere it's supported, as shown above for Azure; the same pattern applies to AWS and Google Cloud through their own login actions.
  • Use environments and required reviewers as a real gate, not a formality, for any deployment target that can reach production data or traffic.
  • Set a concurrency group on deploy workflows so a rapid sequence of pushes cannot race two deploys against the same environment:
YAML
concurrency:
  group: deploy-production
  cancel-in-progress: false

Best Practices#

  • Fail fast on formatting and analyzers before running tests. A dotnet format --verify-no-changes and a strict build catch the cheapest mistakes first, saving minutes on a run that would fail anyway.
  • Keep CI fast enough that developers wait for it. Cache aggressively, parallelize independent jobs, and move a slow integration or end-to-end suite into a separate, less frequent workflow.
  • Build once, deploy the same artifact everywhere. Rebuilding per environment risks output that differs subtly from what was actually tested.
  • Version pipeline files like any other code, with review required, since a pipeline change can grant itself new permissions just as easily as application code can introduce a bug.
  • Centralize reusable steps with composite actions or reusable workflows, or Azure Pipelines templates, once several workflows share the same restore-build-test shape.

Common Pitfalls#

  • Relying on the default, unpinned SDK version. A runner's preinstalled .NET version changes over time; always pin dotnet-version or ship a global.json.
  • Caching ~/.nuget/packages without a key that changes when dependencies do. A stale cache silently serves old packages and hides restore failures a clean environment would have caught.
  • Storing a long-lived Azure service principal secret when OIDC federated credentials remove the need for one entirely.
  • Treating pull_request_target as a drop-in replacement for pull_request without understanding that it changes which secrets and token permissions the workflow runs with.
  • No required reviewers on the production environment, which turns a protection rule that exists in the UI into one that exists only on paper.
  • Rebuilding the artifact in the deploy job instead of reusing the one CI already built and tested.

GitHub Actions vs. Azure Pipelines for .NET#

ConcernGitHub ActionsAzure Pipelines
Pipeline location.github/workflows/*.yml in the repoazure-pipelines.yml, or pipeline defined in the UI
.NET SDK setupactions/setup-dotnetUseDotNet@2 task
Built-in NuGet cachingcache: true on setup-dotnet (needs lock files)Cache@2 task, keyed manually
Deployment gateRepository environment with required reviewersPipeline environment resource with approvals/checks
Keyless cloud authOIDC via id-token: write and azure/loginWorkload identity federation service connection
Container publishingdotnet publish /t:PublishContainer, or Docker actionsDocker@2 task, or the same dotnet publish command
Best fitGitHub-hosted repos, open source, GitHub-centric teamsOrganizations already standardized on Azure DevOps Boards/Repos

Most teams do not choose on features alone, since the two overlap heavily; they choose based on where their source already lives and what the organization uses for work tracking and approvals.

Frequently Asked Questions#

Should a new .NET project use GitHub Actions or Azure Pipelines?#

Match wherever your source code and issue tracking already live. If you are on GitHub, GitHub Actions avoids a second system and a second set of credentials; if your organization is standardized on Azure Boards and Repos, Azure Pipelines integrates more tightly with that workflow. Both cover the same core capabilities described in this guide, so the deciding factor is rarely a missing feature.

How do I cache NuGet packages without adding packages.lock.json files?#

Use a manually keyed actions/cache (or Cache@2 in Azure Pipelines) step, hashing every .csproj, .fsproj and Directory.Packages.props file with hashFiles() to build the cache key, instead of the cache: true input on setup-dotnet, which requires lock files and fails without them.

Why use OIDC instead of an Azure service principal secret?#

An OIDC token is short-lived, scoped to a specific workflow run, and never stored anywhere a leak could expose it. A service principal secret sits in your secret store indefinitely, must be rotated manually, and, if it leaks, grants an attacker access until someone notices and revokes it. OIDC removes the secret from the equation entirely.

Do I need a Dockerfile to publish a .NET app as a container from CI?#

No. dotnet publish /t:PublishContainer builds and pushes an OCI-compliant image directly from the SDK, with no Dockerfile and no Docker daemon required on the runner when pushing to a registry. Keep a Dockerfile for cases that need custom base images, multi-stage native builds, or non-.NET tooling baked into the image.

How do I stop every push to main from deploying straight to production?#

Put the deploy job behind a protected environment with required reviewers (GitHub Actions) or approvals configured on the environment resource (Azure Pipelines). CI still runs on every push, but the deploy step pauses for a human until that reviewer approves it, regardless of how many commits land on main.

What is the safest way to reference third-party GitHub Actions?#

Pin to the full commit SHA a tag currently points to, not the tag itself, and let Dependabot open a pull request for new versions, so every update is reviewed like any other dependency bump instead of applying silently on the next run.

Summary#

  • CI/CD for .NET means restoring, building and testing reproducibly, then shipping the exact artifact or container that was tested, not a rebuild of it.
  • Cache NuGet restores with lock files and setup-dotnet's cache: true, or a manually hashed actions/cache key when lock files are not yet adopted.
  • Use matrix builds to prove multi-targeted and cross-platform code actually works everywhere it claims to.
  • The .NET SDK can publish containers directly with dotnet publish /t:PublishContainer; keep a Dockerfile only when you need something the built-in support does not cover.
  • Deploy to Azure with OIDC federated credentials instead of stored service principal secrets, and gate production deploys with environments and required reviewers.
  • Treat the pipeline itself as a security boundary: pin actions to a commit SHA, scope permissions narrowly, and never combine pull_request_target with untrusted checked-out code.

Further Reading#