GitHub Actions: CI/CD for .NET Applications
Master GitHub Actions workflows for .NET: CI pipelines, Docker builds, Azure deployments, matrix builds, and secrets management.
GitHub Actions: CI/CD for .NET Applications
GitHub Actions is GitHub's built-in automation platform. It lets you run workflows triggered by repository events — pushes, pull requests, schedules, manual triggers — directly inside your GitHub repository. No external CI server to manage.
This guide walks through everything you need to build production-grade CI/CD for a .NET 8 application: automated builds and tests, Docker image publishing, Azure deployments, matrix builds, and secrets management.
How GitHub Actions Works
Every workflow lives in .github/workflows/ as a YAML file. GitHub reads those files and executes them on managed virtual machines called runners.
Key vocabulary:
| Term | What it is |
|------|-----------|
| Workflow | A YAML file defining automation |
| Event | What triggers the workflow (push, PR, schedule, etc.) |
| Job | A group of steps that run on one runner |
| Step | A single shell command or action |
| Action | A reusable unit of automation (uses:) |
| Runner | The VM that executes your jobs |
.github/
workflows/
ci.yml ← runs on every push/PR
cd.yml ← deploys on merge to main
nightly.yml ← scheduled nightly runYour First Workflow: Basic .NET CI
Create .github/workflows/ci.yml:
name: CI
# Triggers
on:
push:
branches: [ "main", "develop" ]
pull_request:
branches: [ "main" ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
# 1. Check out the repository
- name: Checkout code
uses: actions/checkout@v4
# 2. Set up .NET SDK
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
# 3. Restore NuGet packages
- name: Restore dependencies
run: dotnet restore
# 4. Build the solution
- name: Build
run: dotnet build --no-restore --configuration Release
# 5. Run tests
- name: Test
run: dotnet test --no-build --configuration Release --verbosity normalThis is the minimal viable CI pipeline. Every push to main or develop, and every PR targeting main, triggers a build and test run.
Understanding on: — Events
on:
# Push to specific branches
push:
branches: [ "main", "release/**" ]
paths:
- 'src/**' # Only trigger if src/ changed
- '*.csproj'
# Pull requests targeting main
pull_request:
branches: [ "main" ]
types: [ opened, synchronize, reopened ]
# Scheduled: every day at 2am UTC
schedule:
- cron: '0 2 * * *'
# Manual trigger with inputs
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: 'staging'
type: choice
options:
- staging
- productionUnderstanding Runners
jobs:
job1:
runs-on: ubuntu-latest # Linux (most common, cheapest)
job2:
runs-on: windows-latest # Windows Server
job3:
runs-on: macos-latest # macOS
# Self-hosted runner (your own machine)
job4:
runs-on: self-hostedFor .NET on Linux, ubuntu-latest is the right choice — it's fast and the dotnet CLI works perfectly.
Full .NET CI Workflow with Code Coverage
name: CI — Build, Test, Coverage
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
DOTNET_VERSION: '8.0.x'
SOLUTION_PATH: 'MyApp.sln'
jobs:
build:
name: Build & Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for SonarQube/GitVersion
- name: Setup .NET ${{ env.DOTNET_VERSION }}
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
# Cache NuGet packages to speed up restore
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/packages.lock.json') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore
run: dotnet restore ${{ env.SOLUTION_PATH }}
- name: Build
run: |
dotnet build ${{ env.SOLUTION_PATH }} \
--no-restore \
--configuration Release \
-p:Version=${{ github.run_number }}
- name: Test with coverage
run: |
dotnet test ${{ env.SOLUTION_PATH }} \
--no-build \
--configuration Release \
--collect:"XPlat Code Coverage" \
--results-directory ./coverage \
--logger "trx;LogFileName=test-results.trx"
# Upload test results
- name: Upload test results
uses: actions/upload-artifact@v4
if: always() # Upload even if tests fail
with:
name: test-results
path: ./coverage/**/*.trx
# Upload coverage report
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: ./coverage/**/coverage.cobertura.xml
# Optional: publish coverage to Codecov
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/**/coverage.cobertura.xml
token: ${{ secrets.CODECOV_TOKEN }}Secrets and Environment Variables
Three Layers of Configuration
1. Workflow-level env (hardcoded, not secret)
env:
DOTNET_VERSION: '8.0.x'
APP_NAME: 'myapp'2. Repository secrets (encrypted, for tokens/passwords)
Set these in GitHub: Settings → Secrets and variables → Actions → New repository secret.
steps:
- name: Deploy
env:
AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
echo "$REGISTRY_PASSWORD" | docker login -u myuser --password-stdin3. Environment secrets (per-environment, with protection rules)
jobs:
deploy-prod:
environment: production # uses production secrets + requires approval
runs-on: ubuntu-latest
steps:
- name: Deploy to prod
env:
DB_CONNECTION: ${{ secrets.DB_CONNECTION }} # from production env
run: ./deploy.shWhat NOT to do with secrets
# BAD — secrets appear in logs
- run: echo "Token is ${{ secrets.MY_TOKEN }}"
# BAD — string interpolation leaks values
- run: dotnet run --token=${{ secrets.MY_TOKEN }}
# GOOD — pass via environment variable
- name: Run app
env:
MY_TOKEN: ${{ secrets.MY_TOKEN }}
run: dotnet runGitHub automatically masks secret values in logs, but only if you pass them correctly.
Matrix Builds: Test Multiple .NET Versions
Matrix builds let you run the same job across combinations of configurations:
name: Matrix CI
on: [push, pull_request]
jobs:
test:
name: Test .NET ${{ matrix.dotnet }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
dotnet: ['6.0.x', '7.0.x', '8.0.x']
os: [ubuntu-latest, windows-latest]
fail-fast: false # Don't cancel other jobs if one fails
steps:
- uses: actions/checkout@v4
- name: Setup .NET ${{ matrix.dotnet }}
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ matrix.dotnet }}
- name: Restore & Build
run: dotnet build --configuration Release
- name: Test
run: dotnet test --no-build --configuration Release
# Job that only runs if all matrix jobs pass
all-tests-pass:
needs: test
runs-on: ubuntu-latest
steps:
- run: echo "All matrix jobs passed"This creates 6 jobs: 3 .NET versions × 2 operating systems.
Matrix with exclusions and additions
strategy:
matrix:
dotnet: ['6.0.x', '7.0.x', '8.0.x']
os: [ubuntu-latest, windows-latest]
exclude:
# Don't test .NET 6 on Windows (not needed for our use case)
- dotnet: '6.0.x'
os: windows-latest
include:
# Add a special macOS + .NET 8 combination
- dotnet: '8.0.x'
os: macos-latestCD Workflow: Build Docker Image and Push to Registry
name: CD — Docker Build & Push
on:
push:
branches: [ "main" ]
tags: [ 'v*.*.*' ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # needed to push to ghcr.io
steps:
- name: Checkout
uses: actions/checkout@v4
# Log in to GitHub Container Registry
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # auto-generated, no setup needed
# Extract metadata for Docker tags and labels
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
# Set up Docker Buildx for multi-platform builds
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Build and push Docker image
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64Multi-stage Dockerfile for .NET
# Dockerfile
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy csproj and restore (layer caching)
COPY ["src/MyApp.Api/MyApp.Api.csproj", "src/MyApp.Api/"]
COPY ["src/MyApp.Core/MyApp.Core.csproj", "src/MyApp.Core/"]
RUN dotnet restore "src/MyApp.Api/MyApp.Api.csproj"
# Copy everything else and build
COPY . .
WORKDIR "/src/src/MyApp.Api"
RUN dotnet build "MyApp.Api.csproj" -c Release -o /app/build
# Stage 2: Publish
FROM build AS publish
RUN dotnet publish "MyApp.Api.csproj" -c Release -o /app/publish \
/p:UseAppHost=false
# Stage 3: Final runtime image (much smaller)
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
# Security: run as non-root
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser /app
USER appuser
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENV DOTNET_RUNNING_IN_CONTAINER=true
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApp.Api.dll"]CD Workflow: Deploy to Azure App Service
name: CD — Deploy to Azure
on:
workflow_run:
workflows: ["CD — Docker Build & Push"]
types: [completed]
branches: [main]
jobs:
deploy-staging:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
environment:
name: staging
url: https://myapp-staging.azurewebsites.net
steps:
- name: Login to Azure
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Deploy to App Service (staging slot)
uses: azure/webapps-deploy@v3
with:
app-name: 'myapp'
slot-name: 'staging'
images: ghcr.io/${{ github.repository }}:main
- name: Run smoke tests
run: |
sleep 30 # wait for app to start
curl -f https://myapp-staging.azurewebsites.net/health || exit 1
deploy-prod:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production # requires manual approval
url: https://myapp.azurewebsites.net
steps:
- name: Login to Azure
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
# Swap staging slot to production (zero-downtime)
- name: Swap slots
uses: azure/CLI@v2
with:
inlineScript: |
az webapp deployment slot swap \
--resource-group myapp-rg \
--name myapp \
--slot staging \
--target-slot productionSetting up AZURE_CREDENTIALS secret
# Create a service principal for GitHub Actions
az ad sp create-for-rbac \
--name "github-actions-myapp" \
--role contributor \
--scopes /subscriptions/{subscription-id}/resourceGroups/myapp-rg \
--sdk-auth
# Copy the JSON output and save it as AZURE_CREDENTIALS secret in GitHubArtifact Upload and Download
Artifacts let you share files between jobs:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Publish
run: dotnet publish src/MyApp.Api -c Release -o ./publish
# Upload publish output as artifact
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: publish-output
path: ./publish
retention-days: 5 # auto-delete after 5 days
deploy:
needs: build
runs-on: ubuntu-latest
steps:
# Download artifact from build job
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: publish-output
path: ./publish
- name: Deploy
run: |
ls ./publish
# deploy files...Conditional Steps and Job Dependencies
jobs:
test:
runs-on: ubuntu-latest
outputs:
tests-passed: ${{ steps.test.outcome }}
steps:
- uses: actions/checkout@v4
- id: test
run: dotnet test
continue-on-error: true # Don't fail the job, capture outcome
notify:
needs: test
runs-on: ubuntu-latest
if: always() # Run even if test job failed
steps:
- name: Notify on failure
if: needs.test.result == 'failure'
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Tests failed on ${{ github.ref }}!"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Notify on success
if: needs.test.result == 'success'
run: echo "All good!"Step-level conditions
steps:
- name: Only on main branch
if: github.ref == 'refs/heads/main'
run: echo "This is main"
- name: Only on tags
if: startsWith(github.ref, 'refs/tags/')
run: echo "This is a release tag"
- name: Only on PRs
if: github.event_name == 'pull_request'
run: echo "This is a PR"
- name: After failure (cleanup)
if: failure()
run: ./cleanup.shReusable Workflows
Extract common logic into reusable workflows:
# .github/workflows/reusable-dotnet-build.yml
name: Reusable .NET Build
on:
workflow_call:
inputs:
dotnet-version:
required: false
type: string
default: '8.0.x'
configuration:
required: false
type: string
default: 'Release'
secrets:
nuget-token:
required: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ inputs.dotnet-version }}
- name: Restore
run: dotnet restore
env:
NUGET_AUTH_TOKEN: ${{ secrets.nuget-token }}
- name: Build
run: dotnet build -c ${{ inputs.configuration }}
- name: Test
run: dotnet test -c ${{ inputs.configuration }}# .github/workflows/ci.yml — calls the reusable workflow
name: CI
on: [push, pull_request]
jobs:
build:
uses: ./.github/workflows/reusable-dotnet-build.yml
with:
dotnet-version: '8.0.x'
configuration: Release
secrets:
nuget-token: ${{ secrets.NUGET_AUTH_TOKEN }}Complete Production CI/CD Pipeline
Here is a complete pipeline tying everything together:
# .github/workflows/pipeline.yml
name: Full Pipeline
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
DOTNET_VERSION: '8.0.x'
jobs:
# ─── Stage 1: Code Quality ─────────────────────────────────
lint:
name: Lint & Format Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Check formatting
run: dotnet format --verify-no-changes --severity warn
# ─── Stage 2: Build & Test ─────────────────────────────────
test:
name: Build & Test
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache NuGet
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore -c Release
- name: Test
run: |
dotnet test --no-build -c Release \
--collect:"XPlat Code Coverage" \
--results-directory ./coverage \
--logger "trx;LogFileName=results.trx"
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-${{ github.run_id }}
path: ./coverage
# ─── Stage 3: Docker Build (only on main) ──────────────────
docker:
name: Docker Build & Push
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest,enable=true
- uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ─── Stage 4: Deploy to Staging ────────────────────────────
deploy-staging:
name: Deploy → Staging
runs-on: ubuntu-latest
needs: docker
environment:
name: staging
url: https://myapp-staging.azurewebsites.net
steps:
- name: Login to Azure
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS_STAGING }}
- name: Deploy
uses: azure/webapps-deploy@v3
with:
app-name: 'myapp-staging'
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Health check
run: |
for i in {1..10}; do
if curl -sf https://myapp-staging.azurewebsites.net/health; then
echo "Health check passed"
exit 0
fi
sleep 10
done
echo "Health check failed"
exit 1
# ─── Stage 5: Deploy to Production (manual approval) ───────
deploy-prod:
name: Deploy → Production
runs-on: ubuntu-latest
needs: deploy-staging
environment:
name: production # Set up required reviewers in GitHub Settings
url: https://myapp.azurewebsites.net
steps:
- name: Login to Azure
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS_PROD }}
- name: Deploy
uses: azure/webapps-deploy@v3
with:
app-name: 'myapp'
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Verify deployment
run: |
curl -sf https://myapp.azurewebsites.net/health
echo "Production deployment successful"Common Patterns and Tips
Skip CI for docs changes
on:
push:
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/ISSUE_TEMPLATE/**'Concurrency: cancel outdated runs
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # Cancel previous run if new push comes inThis prevents multiple parallel deploys when you push twice quickly.
Timeouts
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30 # Fail the job if it takes more than 30 min
steps:
- name: Long running step
timeout-minutes: 10 # Step-level timeout
run: ./long-script.shUsing outputs between steps
steps:
- name: Get version
id: version
run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Use version
run: echo "Deploying version ${{ steps.version.outputs.version }}"Caching for faster builds
# .NET NuGet cache
- uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.lock.json') }}
restore-keys: ${{ runner.os }}-nuget-
# Node.js npm cache
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}Summary
You now have the building blocks for a complete GitHub Actions CI/CD pipeline:
- Events: push, pull_request, schedule, workflow_dispatch
- CI: checkout → setup-dotnet → cache → restore → build → test → upload artifacts
- Docker: login to registry → build → push with metadata tags
- CD: deploy to staging → health check → manual approval → deploy to production
- Matrix builds: test across multiple .NET versions and OS combinations
- Secrets: repository secrets, environment secrets, never log them
- Reusable workflows: extract common logic, call from multiple pipelines
- Concurrency: cancel outdated runs, set timeouts
The pattern scales from a small project to a large microservices system — just add more jobs and stages.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.