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.
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.
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.
| Term | What it means | Tiffin analogy |
|---|---|---|
| Workflow | The whole automated process, written in one YAML file | The full kitchen routine for the day |
| Job | A group of steps that run on one machine | One station, like the cooking station |
| Step | A single command or action | One task, like "taste the food" |
| Runner | The machine that runs a job | The 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
Steps
Workflow
One YAML file in .github/workflows
Trigger
on: push or pull_request
Job
Runs on a fresh runner (e.g. ubuntu-latest)
Steps
Checkout, setup .NET, build, test, publish
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 ReleaseLet 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-modeWith 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.
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: ./publishThe 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
Steps
Build & Test
Runs first on every push
Publish
needs: build-and-test
Deploy
needs: publish, often only on main
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: ./publishThe 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.
| Habit | Why it matters |
|---|---|
| Build in Release mode | Matches what real users run, not debug builds |
| Run tests on every push | Catches broken code in minutes, not days |
| Cache NuGet packages | Cuts minutes off every run |
| Use a lock file with --locked-mode | Every build uses the exact same package versions |
| Keep secrets in GitHub Secrets | Passwords never leak into logs or code |
| Guard deploy with a branch check | Only finished work on main reaches users |
| Use needs to chain jobs | A 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 ReleaseGitHub 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.
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/workflowsand 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-restoreand--no-buildto skip repeated work and make failures clear. - Turn on
cache: truewith apackages.lock.jsonfile to make runs much faster. - Use
dotnet publishto make a ready-to-ship folder, save it as an artifact, and deploy only frommain. - 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
- GitHub Docs — Building and testing .NET
- actions/setup-dotnet on GitHub
- .NET Blog — .NET loves GitHub Actions
- GitHub Docs — Dependency caching reference
- How To Build a CI/CD Pipeline With GitHub Actions And .NET (Milan Jovanović)
Related Posts
Containerize Your .NET Applications Without a Dockerfile
Learn how to build container images for your .NET apps using the SDK and dotnet publish, with no Dockerfile needed. Beginner-friendly guide for .NET 10.
.NET Aspire: A Game Changer for Cloud-Native Development
A beginner-friendly guide to .NET Aspire, the cloud-native stack that orchestrates your services, databases, and dashboards with one simple command.
5 Serilog Best Practices for Better Structured Logging in .NET
Learn 5 simple Serilog best practices for structured logging in .NET: message templates, enrichers, correlation IDs, hiding secrets, and async sinks.
YARP vs Nginx: A Quick Performance Comparison for .NET
A simple, friendly look at YARP vs Nginx as a reverse proxy: how each one works, real benchmark numbers, tuning tips, and how to pick the right one.
Monitoring .NET Applications With OpenTelemetry and Grafana
A beginner-friendly guide to monitoring .NET apps with OpenTelemetry and Grafana. Send metrics, traces, and logs to Prometheus, Tempo, and Loki step by step.
Retries and Resilience in .NET with Polly and Microsoft Resilience
Learn retries, timeouts, and circuit breakers in .NET using Polly v8 and Microsoft.Extensions.Http.Resilience, with simple examples a beginner can follow.