Learnixo
Back to blog
Backend Systemsintermediate

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.

LearnixoJune 3, 20266 min read
.NETC#GitHub ActionsCI/CDDockerAzureDevOps
Share:š•

What You'll Build

A complete pipeline that:

  1. Builds the .NET solution on every push and PR
  2. Runs unit and integration tests
  3. Builds a Docker image
  4. Pushes to Azure Container Registry (or Docker Hub)
  5. Deploys to Azure Container Apps (or App Service)

Basic Build and Test

Create .github/workflows/ci.yml:

YAML
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:

YAML
      - 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 restore

Pipeline 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:

YAML
  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

YAML
  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=max

Dockerfile (multi-stage)

DOCKERFILE
# 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

YAML
  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 JSON

Repository Variables (Settings → Variables)

ACR_LOGIN_SERVER     # myregistry.azurecr.io
ACR_NAME             # myregistry

Environment-specific secrets

YAML
environment: production  # triggers manual approval + uses production secrets

Go to Settings → Environments → production → add required reviewers for manual approval gate.


Full Pipeline (Combined)

YAML
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:

YAML
      - 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
          fi

Interview 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?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.