Skip to main content

GitHub Actions CI/CD for .NET: Build, Test & Deploy Guide

Learn GitHub Actions CI/CD for .NET with a complete YAML workflow to build, test and deploy your app to Azure. Copy the pipeline and ship today.

If you are still clicking "Publish" from Visual Studio to get your API into production, you are one tired Friday away from an outage. GitHub Actions .NET pipelines solve that problem: every push builds your solution, runs your tests, and — when the tests pass — ships the artifact to Azure, AWS, or your own server, without a human touching a button. In this tutorial you will build a complete GitHub Actions CI/CD workflow for a .NET 10 application from an empty YAML file to a production deployment with environment approvals, and you will understand why each block exists rather than just copying it.

Everything below is runnable. You will need a GitHub repository, a .NET solution with at least one test project, and (for the deployment half) an Azure App Service or any host that accepts a zip deploy.

Why GitHub Actions for a .NET CI/CD Pipeline?

Azure DevOps, TeamCity, and Jenkins all build .NET fine. GitHub Actions wins for most teams for three practical reasons:

  • The pipeline lives next to the code. A workflow is a YAML file in .github/workflows/. It gets code-reviewed in the same pull request as the change that needs it, and it branches and reverts with your code.
  • Zero infrastructure. GitHub-hosted runners come with the .NET SDK, Docker, and Node preinstalled. Public repos get unlimited minutes; private repos get a free monthly allowance.
  • Native pull request integration. Status checks, required reviewers, and deployment gates are built into the same permission model you already use.

The trade-off is honest: Actions has weaker built-in test analytics and release management than Azure DevOps. For most .NET teams that is a fair price for having the pipeline in the repo.

Anatomy of a GitHub Actions Workflow

Four concepts carry the whole system:

  • Workflow — one YAML file, triggered by an event.
  • Job — a set of steps running on one runner (a fresh VM). Jobs run in parallel unless you declare dependencies.
  • Step — either a shell command (run) or a reusable action (uses).
  • Artifact — files uploaded from one job so another job can download them. This is how you move a build's output to a deploy job, because jobs do not share a filesystem.

That last point is the one people trip over. A fresh runner means the deploy job cannot see what the build job compiled unless you explicitly hand it over.

Step 1: A Minimal Build and Test Workflow

Create .github/workflows/ci.yml. This is the smallest workflow worth having:

name: CI

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

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

      - name: Setup .NET
        uses: actions/setup-dotnet@v5
        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 --verbosity normal

Commit this and open a pull request — you will see the check run immediately. A few details are doing real work here:

Why ubuntu-latest? Linux runners are roughly twice as fast to start as Windows runners and cost 1× minutes versus 2× for Windows on private repos. Modern .NET is cross-platform, so use Linux unless you genuinely depend on Windows-only APIs (WPF, WinForms, or System.Drawing.Common against GDI+).

Why --no-restore and --no-build? By default dotnet build restores again and dotnet test builds again. On a large solution that is a minute of wasted time per run, and worse, it means your tests may execute against a binary that was compiled with different flags than the one you built. Explicit separation keeps each step doing exactly one thing.

Why pin action versions? actions/checkout@v5 pins a major version so a breaking change upstream does not break your pipeline at 2 a.m. Security-sensitive teams go further and pin the full commit SHA.

Step 2: Caching NuGet Packages (The Easiest Big Win)

dotnet restore on a cold runner downloads every package every run. Cache the global packages folder instead:

      - name: Cache NuGet packages
        uses: actions/cache@v4
        with:
          path: ~/.nuget/packages
          key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
          restore-keys: |
            ${{ runner.os }}-nuget-

Place this step immediately after setup-dotnet and before dotnet restore. The key is a content hash of your project files, so the cache invalidates precisely when dependencies change; restore-keys gives a partial hit when they do, so you only download the delta.

For a reproducible build, generate lock files (dotnet restore --use-lock-file), commit them, and restore with --locked-mode. That guarantees CI resolves the exact same package versions your laptop did — the cure for "works on my machine, fails in CI."

Step 3: Test Reporting and Code Coverage

A red X that just says "process exited with code 1" costs a developer five minutes of log scrolling. Publish structured results instead:

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

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

if: always() is essential and widely missed. Without it, the upload step is skipped when tests fail — exactly the case where you most want the results. Conditions in Actions default to "only if everything so far succeeded."

Here is a test worth running, using xUnit and a real assertion rather than a smoke test:

using Xunit;

public sealed class InvoiceCalculatorTests
{
    [Theory]
    [InlineData(100.00, 0.20, 120.00)]   // UK VAT
    [InlineData(100.00, 0.05, 105.00)]   // GST
    [InlineData(0.00,   0.20, 0.00)]
    public void AppliesTaxRate(decimal net, decimal rate, decimal expected)
    {
        var calculator = new InvoiceCalculator();

        var gross = calculator.ApplyTax(net, rate);

        Assert.Equal(expected, gross);
    }

    [Fact]
    public void RejectsNegativeTaxRate()
    {
        var calculator = new InvoiceCalculator();

        Assert.Throws<ArgumentOutOfRangeException>(
            () => calculator.ApplyTax(100m, -0.1m));
    }
}

Note decimal, not double, for money — a CI pipeline will happily deploy a rounding bug at high speed if your tests do not catch it.

Step 4: Publishing and Passing the Artifact

Separate "build it" from "ship it." Add publishing to the end of the build job:

      - name: Publish
        run: >
          dotnet publish ./src/MyApi/MyApi.csproj
          --configuration Release --no-build
          --output ${{ github.workspace }}/publish

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: webapp
          path: ${{ github.workspace }}/publish
          retention-days: 7

The rule that keeps deployments honest: build once, deploy many times. The binary you tested in CI is the exact binary that reaches staging and then production. If you rebuild per environment, you are deploying something no one tested, and environment-specific bugs become unreproducible.

Step 5: Deploying to Azure with OIDC (No Stored Secrets)

Now add a second job that deploys only from main:

  deploy:
    needs: build-and-test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: ${{ steps.deploy.outputs.webapp-url }}
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: webapp
          path: ./publish

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

      - name: Deploy to App Service
        id: deploy
        uses: azure/webapps-deploy@v3
        with:
          app-name: my-production-api
          package: ./publish

Three things here are worth understanding rather than copying blindly.

needs and if: the deployment gate

needs: build-and-test makes the deploy job wait for green tests. The if condition prevents pull requests from deploying — without it, anyone who opens a PR from a fork runs your deployment. This combination is the single most important safety property of the workflow.

OIDC instead of a publish profile

The old approach stored a publish profile or a service principal secret in GitHub. Those credentials are long-lived, and anyone with repo admin can exfiltrate them. With id-token: write, GitHub mints a short-lived token that Azure trusts via a federated credential, scoped to your repo and branch. Nothing secret is stored. Configure this once in Microsoft Entra ID under your app registration's Federated credentials, selecting GitHub Actions as the issuer. AWS supports the same pattern via aws-actions/configure-aws-credentials.

Environments give you approvals

The environment: production key connects to a GitHub Environment in repo settings, where you can require manual approval from named reviewers before the job proceeds, restrict the environment to specific branches, and store environment-scoped secrets. This is how you get a real change-control gate without a separate release tool.

GitHub Actions .NET Best Practices

The workflow above works. These practices keep it working as the team grows.

  • Set explicit permissions. Add permissions: contents: read at the workflow root and grant more only in the jobs that need it. The default token is broader than most workflows require.
  • Cancel superseded runs. Add a concurrency group so pushing twice to a PR does not run two builds: concurrency: { group: "${{ github.workflow }}-${{ github.ref }}", cancel-in-progress: true }. Never enable cancel-in-progress on a deployment job — a half-cancelled deploy is worse than a slow one.
  • Treat warnings as errors in CI. Set <TreatWarningsAsErrors>true</TreatWarningsAsErrors> in Directory.Build.props. Warnings that are tolerated locally accumulate forever.
  • Add a timeout. timeout-minutes: 15 on each job. A hung integration test otherwise burns the full six-hour default.
  • Use a matrix only where it pays. Testing across ubuntu-latest and windows-latest is valuable for a NuGet library, and wasted minutes for an internal API that only ever runs on Linux.
  • Deploy to staging first with a smoke test. A deploy slot swap on Azure plus a single HTTP health check catches configuration failures before real users do.

Common Pitfalls (And How to Fix Them)

"The build passes but the deployment has no files." You uploaded the wrong path. dotnet publish --output and the upload-artifact path must point at the same directory. Add a temporary run: ls -R ./publish step to see what the runner actually produced.

Integration tests fail with connection refused. Tests that need a database need one on the runner. Use a service container rather than mocking at the last minute:

    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: ci-only-password
        ports: [ '5432:5432' ]
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-retries 5

The health options matter: without them the job starts your tests before Postgres accepts connections, producing an intermittent failure that wastes an afternoon.

Secrets appear empty in a pull request. Secrets are deliberately withheld from pull_request runs triggered by forks. That is a security feature, not a bug. Structure your workflow so PRs only build and test; anything needing secrets runs on push to a protected branch.

The wrong SDK version is used. If you have a global.json, it wins over setup-dotnet and CI will fail with an SDK-not-found error. Either omit the dotnet-version input and let global.json drive it, or keep the two in sync.

Flaky tests erode trust fast. A pipeline that fails randomly gets ignored, and an ignored pipeline is worse than none. Quarantine a flaky test with a [Fact(Skip = "Flaky – tracked in #482")] and fix it deliberately rather than adding a blanket retry.

Advanced: Reusable Workflows for Multiple .NET Services

Once you have five microservices, copying YAML five times becomes a maintenance problem. Extract a reusable workflow with on: workflow_call and inputs for the project path and app name, then each service repo calls it in a handful of lines. You fix a bug in the pipeline once and every service inherits it. Composite actions serve the same purpose for a repeated group of steps within a job.

Conclusion: Key Takeaways

A solid GitHub Actions .NET pipeline is not complicated — it is roughly sixty lines of YAML — but the details are what separate a pipeline you trust from one you fight with:

  • Start with checkout, setup-dotnet, restore, build, test. Ship that on day one; add sophistication later.
  • Cache NuGet packages and use --no-restore / --no-build to cut minutes off every run.
  • Build once, upload the artifact, and deploy that same artifact everywhere. Never rebuild per environment.
  • Gate deployment with needs plus an if on the branch, so only tested code on main ever reaches production.
  • Use OIDC federated credentials instead of stored publish profiles, and scope the workflow token with explicit permissions.
  • Use GitHub Environments for manual approval on production, and always upload test results with if: always().

Commit the CI file today, get a green check on your next pull request, and add the deploy job once you trust the tests. The goal of a .NET CI/CD pipeline is not automation for its own sake — it is making a release boring enough to do on a Friday afternoon.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...