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: readat 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 enablecancel-in-progresson a deployment job — a half-cancelled deploy is worse than a slow one. - Treat warnings as errors in CI. Set
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>inDirectory.Build.props. Warnings that are tolerated locally accumulate forever. - Add a timeout.
timeout-minutes: 15on each job. A hung integration test otherwise burns the full six-hour default. - Use a matrix only where it pays. Testing across
ubuntu-latestandwindows-latestis 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-buildto cut minutes off every run. - Build once, upload the artifact, and deploy that same artifact everywhere. Never rebuild per environment.
- Gate deployment with
needsplus anifon the branch, so only tested code onmainever 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.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment