Skip to main content
SEMastery
DevOpsbeginner

How to Build a CI/CD Pipeline With GitHub Actions and .NET

A beginner-friendly guide to building a CI/CD pipeline for .NET with GitHub Actions: build, test, cache, publish, and deploy your app automatically.

13 min readUpdated November 18, 2025

A tiffin service that checks every box

Picture a home tiffin service in your neighbourhood. Every morning the cook prepares lunch boxes. But before any box leaves the kitchen, three things happen, like clockwork. First, someone tastes the food to make sure it is cooked properly. Second, someone checks that the lid closes and nothing leaks. Third, only after both checks pass does the delivery boy pick up the box and ride out to customers.

Now imagine doing all of that by hand, for every single box, every single day. It would be slow and tiring, and one tired afternoon someone would forget to taste the food. A leaky or half-cooked box would reach a customer.

A CI/CD pipeline is the same idea, but for your code. Every time you make a change, a tireless robot tastes your code (runs your tests), checks the lid (builds it cleanly), and then ships the box (publishes and deploys it). It never gets tired and never forgets a step.

In this guide we will build exactly that robot for a .NET app using GitHub Actions. By the end you will have a pipeline that builds, tests, caches, publishes, and deploys your app on every push. We will use .NET 10, which is the current LTS release, but the same ideas work on .NET 8 and 9 too.

What do CI and CD actually mean?

Let us slow down and name the two halves clearly.

CI means Continuous Integration. Every time you push code, a machine pulls your latest changes, builds them, and runs your tests. If anything breaks, you find out in minutes, not days. Small problems stay small.

CD means Continuous Delivery (or Continuous Deployment). Once the build and tests pass, the same machine packages your app and ships it. With delivery, the package is ready and a human clicks one button to release. With deployment, even that click is automatic.

Figure 1: The flow of one code change through a CI/CD pipeline, from your laptop to a live server.

The key word in both is continuous. The robot does its job again and again, without you asking, so quality stays steady.

How GitHub Actions is put together

Before we write anything, let us learn the four words that GitHub Actions uses. Once these click, the YAML file reads like plain English.

TermWhat it meansTiffin analogy
WorkflowThe whole automated process, written in one YAML fileThe full kitchen routine for the day
JobA group of steps that run on one machineOne station, like the cooking station
StepA single command or actionOne task, like "taste the food"
RunnerThe machine that runs a jobThe cook who does the work

A workflow lives in a file inside the .github/workflows folder in your repository. You can have many workflows. Each one is triggered by an event, such as a push or a pull request. When the event fires, GitHub spins up a fresh runner (a clean virtual machine), and that runner walks through your jobs and steps one by one.

Anatomy of a workflow file

Workflow
Trigger
Job
Steps

Steps

1

Workflow

One YAML file in .github/workflows

2

Trigger

on: push or pull_request

3

Job

Runs on a fresh runner (e.g. ubuntu-latest)

4

Steps

Checkout, setup .NET, build, test, publish

How the pieces of a GitHub Actions workflow nest inside each other.

The lovely part is that you install nothing. GitHub already has runners with the tools you need. You just describe the steps, and the runner does them.

Step 1: Create your first workflow file

Inside your repository, create a folder path .github/workflows. Then add a file called ci.yml. The name does not matter, but the .yml ending does.

Here is a small but complete starter workflow. Read the comments to follow along.

// File: .github/workflows/ci.yml
// (This is YAML, shown here so the comments stand out.)
 
name: CI
 
// Run this whenever code is pushed, or a pull request is opened.
on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
 
jobs:
  build-and-test:
    runs-on: ubuntu-latest   // The runner: a fresh Ubuntu machine.
    steps:
      - name: Check out the code
        uses: actions/checkout@v4
 
      - name: Set up .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'
 
      - name: Restore dependencies
        run: dotnet restore
 
      - name: Build
        run: dotnet build --no-restore --configuration Release
 
      - name: Test
        run: dotnet test --no-build --configuration Release

Let us read this like a story. When someone pushes to main, GitHub starts a job on a clean Ubuntu machine. The first step, actions/checkout@v4, copies your code onto that machine. The second step, actions/setup-dotnet@v4, installs the .NET 10 SDK. Then we restore NuGet packages, build in Release mode, and run the tests.

Notice the two small flags. --no-restore on the build step says "do not restore again, I already did." --no-build on the test step says "do not rebuild, just run the tests on what was built." These avoid repeating work and make it crystal clear which step failed if something breaks.

Step 2: Add a tiny .NET project to test it

A pipeline is only useful if it has something to build. Here is a minimal class and a test so the dotnet test step has real work to do.

// File: src/Calculator/Calculator.cs
namespace Calculator;
 
public static class PriceCalculator
{
    // Adds GST (tax) to a price. A simple, testable function.
    public static decimal AddTax(decimal price, decimal taxPercent)
    {
        if (price < 0)
            throw new ArgumentOutOfRangeException(nameof(price));
 
        return price + (price * taxPercent / 100m);
    }
}

And a test that the robot will run on every push:

// File: tests/Calculator.Tests/PriceCalculatorTests.cs
using Xunit;
using Calculator;
 
public class PriceCalculatorTests
{
    [Fact]
    public void AddTax_AddsCorrectAmount()
    {
        decimal result = PriceCalculator.AddTax(100m, 18m);
        Assert.Equal(118m, result);
    }
 
    [Theory]
    [InlineData(0, 18, 0)]
    [InlineData(200, 5, 210)]
    public void AddTax_WorksForManyInputs(decimal price, decimal tax, decimal expected)
    {
        Assert.Equal(expected, PriceCalculator.AddTax(price, tax));
    }
}

Now, every time you push, the robot proves these still pass. If a teammate breaks AddTax, the pipeline turns red and tells everyone at once.

Step 3: Make it fast with caching

The first time you run, the pipeline downloads every NuGet package fresh. That can take a while. We can teach GitHub to remember the packages between runs, the same way your browser remembers images so a page loads faster the second time.

To do this we turn on caching in the setup step. Caching keys off a file called packages.lock.json, which records the exact versions of your packages. Create it once by running dotnet restore --use-lock-file locally and committing the file.

// In .github/workflows/ci.yml, update the setup step:
 
      - name: Set up .NET (with NuGet cache)
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'
          cache: true
          cache-dependency-path: '**/packages.lock.json'
 
      - name: Restore (locked mode for safety)
        run: dotnet restore --locked-mode

With cache: true, the runner saves your downloaded packages after a run and restores them at the start of the next run, as long as packages.lock.json has not changed. The --locked-mode flag makes restore fail loudly if the lock file and your project ever disagree, which keeps every build using the exact same versions.

Figure 2: How caching skips the slow download step when packages have not changed.

Step 4: Publish a ready-to-ship package

Building is not the same as shipping. dotnet build compiles your code, but the files are spread out and tuned for debugging. dotnet publish gathers everything your app needs into one neat folder you can copy anywhere.

We will add a second job that runs only after the build-and-test job passes. It publishes the app and saves the folder as an artifact, which is a file bundle GitHub stores for you to download or hand to the next job.

// Add this job below build-and-test in ci.yml:
 
  publish:
    needs: build-and-test    // Wait for tests to pass first.
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v4
 
      - name: Set up .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'
 
      - name: Publish
        run: dotnet publish src/WebApp/WebApp.csproj -c Release -o ./publish
 
      - name: Upload the published app
        uses: actions/upload-artifact@v4
        with:
          name: webapp
          path: ./publish

The needs: build-and-test line is important. It means this job sits and waits. If the tests fail, this job never runs, so a broken box never gets packed. That is the whole safety idea of a pipeline in one small word.

How the jobs connect

Once you have more than one job, it helps to see how they depend on each other. Jobs without a needs line run at the same time. Jobs with needs wait for their parent.

Job order in our pipeline

Build & Test
Publish
Deploy

Steps

1

Build & Test

Runs first on every push

2

Publish

needs: build-and-test

3

Deploy

needs: publish, often only on main

The publish and deploy jobs wait for earlier jobs to go green before they start.

Step 5: Deploy, and keep secrets safe

The final step is shipping the box to a real server. Where you deploy depends on your host, for example Azure App Service, a container registry, or your own server over SSH. The shape is always the same: download the artifact, then push it to the host.

Deployment needs passwords or tokens. Never type these into the YAML file, because anyone who can read your repository would see them. Instead, store them in your repository under Settings, Secrets and variables, Actions. Then read them with the secrets context.

// A deploy job that reads a secret safely:
 
  deploy:
    needs: publish
    runs-on: ubuntu-latest
    // Only deploy from the main branch, not from pull requests.
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Download the published app
        uses: actions/download-artifact@v4
        with:
          name: webapp
          path: ./publish
 
      - name: Deploy to Azure Web App
        uses: azure/webapps-deploy@v3
        with:
          app-name: 'my-dotnet-app'
          // The secret is hidden in logs by GitHub.
          publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }}
          package: ./publish

The if: github.ref == 'refs/heads/main' line is a gentle guard. It means "only deploy when the change is on the main branch." Pull requests still build and test, but they do not deploy. This stops half-finished work from ever reaching customers.

A safety checklist for real pipelines

As your project grows, a few habits keep the pipeline trustworthy. Here is a quick table you can come back to.

HabitWhy it matters
Build in Release modeMatches what real users run, not debug builds
Run tests on every pushCatches broken code in minutes, not days
Cache NuGet packagesCuts minutes off every run
Use a lock file with --locked-modeEvery build uses the exact same package versions
Keep secrets in GitHub SecretsPasswords never leak into logs or code
Guard deploy with a branch checkOnly finished work on main reaches users
Use needs to chain jobsA broken step blocks everything after it

Testing on more than one .NET version

Sometimes you want to be sure your library works on several .NET versions. A matrix lets you run the same job many times with different inputs, all in parallel, without copying the job.

// A matrix that tests on three .NET versions at once:
 
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        dotnet: [ '8.0.x', '9.0.x', '10.0.x' ]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ matrix.dotnet }}
      - run: dotnet test --configuration Release

GitHub runs three copies of this job side by side, one per version. If your code breaks on .NET 8 but works on 10, you will see exactly which one turned red.

Figure 3: A matrix runs the same job in parallel across several .NET versions.

A note on libraries and licences

Many .NET tutorials reach for popular libraries like MediatR and MassTransit. These are great tools, but as of recent versions they have moved to commercial licences for many uses. That does not affect your CI/CD pipeline at all, but it is worth knowing before you add them to a real project, since you may need a paid licence. For a simple build-test-deploy pipeline like the one here, you do not need either of them.

Common mistakes and easy fixes

A few small slips trip up almost everyone the first time. Here is how to spot and fix them.

If your build passes locally but fails on the runner, check that you set up the right .NET version with actions/setup-dotnet. The runner does not magically know which version you want.

If restore is slow on every run, you probably forgot to commit packages.lock.json or to set cache: true. Without the lock file, the cache has no stable key.

If your deploy runs on pull requests by accident, add the if: github.ref == 'refs/heads/main' guard so only main deploys.

If a secret shows up empty, check the spelling. ${{ secrets.MY_TOKEN }} must match the name you saved in repository settings exactly, including case.

Quick recap

  • A CI/CD pipeline is a tireless robot that builds, tests, and ships your code on every change, like a tiffin kitchen that checks every box before it goes out.
  • GitHub Actions runs your pipeline on GitHub's own machines. You add one YAML file in .github/workflows and install nothing.
  • Learn four words: a workflow holds jobs, each job runs steps on a runner.
  • A good .NET pipeline checks out code, sets up .NET with actions/setup-dotnet@v4, restores, builds in Release, and runs tests.
  • Use --no-restore and --no-build to skip repeated work and make failures clear.
  • Turn on cache: true with a packages.lock.json file to make runs much faster.
  • Use dotnet publish to make a ready-to-ship folder, save it as an artifact, and deploy only from main.
  • Keep every password and token in GitHub Secrets, never in the YAML file.
  • Use a matrix to test across .NET 8, 9, and 10 at the same time.

References and further reading

Related Posts