GitHub Actions for .NET: Build, Test, and Deploy Your API
Set up a complete CI/CD pipeline for a .NET API with GitHub Actions. Covers build, test, Docker image build, push to registry, deploy to Azure, environment secrets, and caching for fast pipelines.
What You'll Build
A complete pipeline that:
- Builds the .NET solution on every push and PR
- Runs unit and integration tests
- Builds a Docker image
- Pushes to Azure Container Registry (or Docker Hub)
- Deploys to Azure Container Apps (or App Service)
Basic Build and Test
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build-test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Run tests
run: dotnet test --no-build --configuration Release --verbosity normal \
--logger "trx;LogFileName=test-results.trx" \
--collect:"XPlat Code Coverage"
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: .NET Tests
path: '**/test-results.trx'
reporter: dotnet-trx
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: '**/coverage.cobertura.xml'Caching NuGet Packages
Without caching, dotnet restore downloads packages on every run. Cache them:
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore dependencies
run: dotnet restorePipeline time typically drops from 2ā3 minutes to 30 seconds on cache hits.
Running Integration Tests with Services
Use Docker services in the job to run SQL Server, Redis, or RabbitMQ:
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
env:
ACCEPT_EULA: Y
SA_PASSWORD: YourStrong@Passw0rd1
ports:
- 1433:1433
options: >-
--health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P YourStrong@Passw0rd1 -Q 'SELECT 1' -C"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.x'
- name: Run integration tests
run: dotnet test tests/OrderFlow.IntegrationTests
env:
ConnectionStrings__Default: "Server=localhost,1433;Database=TestDb;User Id=sa;Password=YourStrong@Passw0rd1;TrustServerCertificate=true"
ConnectionStrings__Redis: "localhost:6379"Build and Push Docker Image
docker:
name: Docker Build & Push
runs-on: ubuntu-latest
needs: [build-test]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Login to Azure Container Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.ACR_LOGIN_SERVER }}
username: ${{ secrets.ACR_USERNAME }}
password: ${{ secrets.ACR_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ vars.ACR_LOGIN_SERVER }}/orderflow-orders
tags: |
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./src/OrderFlow.Orders
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ vars.ACR_LOGIN_SERVER }}/orderflow-orders:buildcache
cache-to: type=registry,ref=${{ vars.ACR_LOGIN_SERVER }}/orderflow-orders:buildcache,mode=maxDockerfile (multi-stage)
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["OrderFlow.Orders.csproj", "./"]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish
# Runtime stage ā minimal image
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
# Non-root user for security
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "OrderFlow.Orders.dll"]Deploy to Azure Container Apps
deploy:
name: Deploy to Azure
runs-on: ubuntu-latest
needs: [docker]
environment: production # requires manual approval
steps:
- name: Login to Azure
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Deploy to Container Apps
uses: azure/container-apps-deploy-action@v1
with:
appSourcePath: ${{ github.workspace }}
acrName: ${{ vars.ACR_NAME }}
containerAppName: orderflow-orders
resourceGroup: orderflow-rg
imageToDeploy: ${{ vars.ACR_LOGIN_SERVER }}/orderflow-orders:sha-${{ github.sha }}Environment Protection and Secrets
Repository Secrets (Settings ā Secrets)
ACR_USERNAME # Azure Container Registry username
ACR_PASSWORD # Azure Container Registry password
AZURE_CREDENTIALS # Azure service principal JSONRepository Variables (Settings ā Variables)
ACR_LOGIN_SERVER # myregistry.azurecr.io
ACR_NAME # myregistryEnvironment-specific secrets
environment: production # triggers manual approval + uses production secretsGo to Settings ā Environments ā production ā add required reviewers for manual approval gate.
Full Pipeline (Combined)
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.x'
- uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
- run: dotnet restore
- run: dotnet build --no-restore -c Release
- run: dotnet test --no-build -c Release
docker:
name: Docker
needs: build-test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
image-tag: sha-${{ github.sha }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ vars.ACR_LOGIN_SERVER }}
username: ${{ secrets.ACR_USERNAME }}
password: ${{ secrets.ACR_PASSWORD }}
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ vars.ACR_LOGIN_SERVER }}/orders:sha-${{ github.sha }}
deploy-staging:
name: Deploy ā Staging
needs: docker
runs-on: ubuntu-latest
environment: staging
steps:
- uses: azure/login@v2
with: { creds: '${{ secrets.AZURE_CREDENTIALS }}' }
- uses: azure/container-apps-deploy-action@v1
with:
containerAppName: orders-staging
resourceGroup: orderflow-rg
imageToDeploy: ${{ vars.ACR_LOGIN_SERVER }}/orders:${{ needs.docker.outputs.image-tag }}
deploy-production:
name: Deploy ā Production
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # manual approval required
steps:
- uses: azure/login@v2
with: { creds: '${{ secrets.AZURE_CREDENTIALS }}' }
- uses: azure/container-apps-deploy-action@v1
with:
containerAppName: orders-production
resourceGroup: orderflow-rg
imageToDeploy: ${{ vars.ACR_LOGIN_SERVER }}/orders:${{ needs.docker.outputs.image-tag }}Code Coverage Gate
Fail the build if coverage drops below a threshold:
- name: Run tests with coverage
run: |
dotnet test --collect:"XPlat Code Coverage" \
--results-directory ./coverage
- name: Coverage gate
run: |
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator \
-reports:"./coverage/**/coverage.cobertura.xml" \
-targetdir:"./coverage-report" \
-reporttypes:TextSummary
COVERAGE=$(grep "Line coverage" ./coverage-report/Summary.txt | grep -o '[0-9.]*%' | head -1 | tr -d '%')
echo "Coverage: $COVERAGE%"
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage $COVERAGE% is below threshold 80%"
exit 1
fiInterview Questions
Q: What is the difference between a GitHub Actions secret and a variable? Secrets are encrypted and masked in logs ā use for credentials, tokens, passwords. Variables are plaintext ā use for non-sensitive configuration like registry URLs, app names, environment labels.
Q: Why use a multi-stage Dockerfile? The build stage uses the large SDK image (~800MB) to compile the app. The runtime stage uses the small ASP.NET runtime image (~200MB) and copies only the compiled output. The final image doesn't contain source code, build tools, or SDK ā smaller, faster, and more secure.
Q: How do you prevent a production deployment from running automatically?
Add an environment: production key to the job and configure required reviewers in GitHub Settings ā Environments. The workflow pauses and sends an approval request before that job runs.
Q: How do you pass data between jobs in GitHub Actions?
Use outputs ā define them with outputs: in the producing job, set them with echo "name=value" >> $GITHUB_OUTPUT, and read them in consuming jobs with ${{ needs.job-name.outputs.name }}.
Q: Why cache NuGet packages in CI?
dotnet restore downloads packages from the internet on every run ā typically 1ā3 minutes. Caching the ~/.nuget/packages directory keyed by a hash of .csproj files reduces this to seconds on cache hits. Pipelines run many times per day ā the time savings compound.
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.