CI/CD Intermediate 18 min

GitHub Actions CI/CD for .NET Applications

Build robust CI/CD pipelines with GitHub Actions for .NET applications including testing, Docker builds, and Kubernetes deployment.

By Victor Robin Updated:

When I first set up our CI/CD pipeline with GitHub Actions for our .NET microservices, I naively assumed it would be as simple as “build, test, push.” The first pipeline ran for over 25 minutes because I was not caching NuGet packages or Docker layers, and the integration tests would intermittently time out waiting for the PostgreSQL service container to become ready. It took several iterations to get the pipeline reliable — adding proper health checks for service containers, implementing registry-based Docker build caching, and separating the CI and CD workflows so that a formatting issue would not block a critical hotfix deploy. The pipeline we have now runs in under 8 minutes end-to-end and has not had a flaky failure in months.

Introduction

GitHub Actions provides powerful CI/CD capabilities integrated directly with your repository. This guide covers building comprehensive pipelines for .NET applications with Docker and Kubernetes deployment.

[GitHub Actions Documentation] — GitHub , 2024-10-01 [Building and testing .NET with GitHub Actions] — GitHub , 2024-08-15

Architecture Overview

flowchart TB
    subgraph Pipeline["⚡ CI/CD Pipeline Flow"]
        Trigger["🚀 Push/PR Trigger"]
        
        subgraph CI["🔄 CI Pipeline"]
            Build["🔨 Build"] --> Test["🧪 Test"] --> Lint["✨ Lint"] --> Scan["🔍 Scan"] --> Quality["📊 Code Quality"]
        end
        
        Trigger --> CI
        
        CI -->|"main branch only"| CD
        
        subgraph CD["🚢 CD Pipeline"]
            Docker["🐳 Docker Build"] --> Push["📤 Push Registry"] --> Manifest["📝 Update Manifest"] --> Deploy["☸️ Deploy (Flux)"]
        end
    end

    classDef primary fill:#7c3aed,color:#fff
    classDef secondary fill:#06b6d4,color:#fff
    classDef db fill:#f43f5e,color:#fff
    classDef warning fill:#fbbf24,color:#000

    class Pipeline primary
    class CI,CD secondary

Implementation

CI Workflow

Build and Test

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
    paths:
      - 'src/**'
      - 'tests/**'
      - '*.sln'
      - 'Directory.Build.props'
  pull_request:
    branches: [main]

env:
  DOTNET_VERSION: '10.0.x'
  DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
  DOTNET_CLI_TELEMETRY_OPTOUT: true

jobs:
  build:
    name: Build & Test
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: test_user
          POSTGRES_PASSWORD: test_password
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}
      
      - name: Restore dependencies
        run: dotnet restore
      
      - name: Build
        run: dotnet build --no-restore --configuration Release
      
      - name: Test
        run: |
          dotnet test \
            --no-build \
            --configuration Release \
            --logger "trx;LogFileName=test-results.trx" \
            --collect:"XPlat Code Coverage" \
            --results-directory ./coverage
        env:
          ConnectionStrings__AppDb: "Host=localhost;Port=5432;Database=test_db;Username=test_user;Password=test_password"
      
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: ./coverage/**/*.trx
      
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          directory: ./coverage
          fail_ci_if_error: false

  lint:
    name: Code Quality
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}
      
      - name: Restore
        run: dotnet restore
      
      - name: Format check
        run: dotnet format --verify-no-changes --verbosity diagnostic
      
      - name: Analyze
        run: dotnet build /p:TreatWarningsAsErrors=true

  security:
    name: Security Scan
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}
      
      - name: Restore
        run: dotnet restore
      
      - name: Security scan
        run: |
          dotnet list package --vulnerable --include-transitive 2>&1 | tee vulnerabilities.txt
          if grep -q "has the following vulnerable packages" vulnerabilities.txt; then
            echo "::warning::Vulnerable packages detected"
          fi
      
      - name: Secret scan
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}
          head: HEAD

CD Workflow

Docker Build and Deploy

# .github/workflows/cd.yml
name: CD

on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'Dockerfile'
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deployment environment'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - prod

env:
  REGISTRY: registry.example.local
  IMAGE_NAME: myapp-api

jobs:
  build-and-push:
    name: Build & Push Docker Image
    runs-on: [self-hosted, linux, arm64]
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
      version: ${{ steps.version.outputs.version }}
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      
      - name: Generate version
        id: version
        run: |
          VERSION=$(date +%Y%m%d)-$(git rev-parse --short HEAD)
          echo "version=$VERSION" >> $GITHUB_OUTPUT
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Login to Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}
      
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=raw,value=${{ steps.version.outputs.version }}
            type=raw,value=latest,enable={{is_default_branch}}
      
      - 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=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
          build-args: |
            VERSION=${{ steps.version.outputs.version }}
  
  update-manifests:
    name: Update Kubernetes Manifests
    needs: build-and-push
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout infra repo
        uses: actions/checkout@v4
        with:
          repository: your-org/my-infra
          token: ${{ secrets.INFRA_REPO_TOKEN }}
          path: infra
      
      - name: Update image tag
        run: |
          cd infra
          ENVIRONMENT=${{ inputs.environment || 'staging' }}
          yq eval -i ".spec.template.spec.containers[0].image = \"${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-and-push.outputs.version }}\"" \
            apps/myapp-api/overlays/${ENVIRONMENT}/deployment-patch.yaml
      
      - name: Commit and push
        run: |
          cd infra
          git config user.name "GitHub Actions"
          git config user.email "actions@github.com"
          git add .
          git commit -m "chore: update ${{ env.IMAGE_NAME }} to ${{ needs.build-and-push.outputs.version }}" || exit 0
          git push

  notify:
    name: Notify
    needs: [build-and-push, update-manifests]
    runs-on: ubuntu-latest
    if: always()
    
    steps:
      - name: Notify on success
        if: ${{ needs.update-manifests.result == 'success' }}
        run: |
          echo "::notice::Deployment triggered for version ${{ needs.build-and-push.outputs.version }}"
      
      - name: Notify on failure
        if: ${{ needs.update-manifests.result == 'failure' }}
        run: |
          echo "::error::Deployment failed"

Dockerfile

Multi-Stage Build

# Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG VERSION=0.0.0

WORKDIR /src

# Copy csproj files and restore
COPY *.sln Directory.Build.props ./
COPY src/MyApp.Api/*.csproj src/MyApp.Api/
COPY src/MyApp.Application/*.csproj src/MyApp.Application/
COPY src/MyApp.Core/*.csproj src/MyApp.Core/
COPY src/MyApp.Infrastructure/*.csproj src/MyApp.Infrastructure/

RUN dotnet restore

# Copy source and build
COPY src/ src/
RUN dotnet publish src/MyApp.Api/MyApp.Api.csproj \
    -c Release \
    -o /app/publish \
    /p:Version=${VERSION} \
    /p:UseAppHost=false

# Runtime image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app

# Create non-root user
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
USER appuser

COPY --from=build /app/publish .

ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080

ENTRYPOINT ["dotnet", "MyApp.Api.dll"]

Reusable Workflows

Shared Test Workflow

# .github/workflows/test-reusable.yml
name: Reusable Test Workflow

on:
  workflow_call:
    inputs:
      dotnet-version:
        required: false
        type: string
        default: '10.0.x'
      configuration:
        required: false
        type: string
        default: 'Release'

jobs:
  test:
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: test_user
          POSTGRES_PASSWORD: test_password
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ inputs.dotnet-version }}
      
      - run: dotnet restore
      
      - run: dotnet test --configuration ${{ inputs.configuration }}
        env:
          ConnectionStrings__AppDb: "Host=localhost;Port=5432;Database=test_db;Username=test_user;Password=test_password"

Calling Reusable Workflow

# .github/workflows/pr.yml
name: PR Checks

on:
  pull_request:
    branches: [main]

jobs:
  test:
    uses: ./.github/workflows/test-reusable.yml
    with:
      configuration: Debug

Summary

GitHub Actions CI/CD provides:

FeatureImplementation
Parallel JobsBuild, lint, security run concurrently
Service ContainersPostgreSQL for integration tests
Docker Build CacheRegistry-based layer caching
GitOps DeployUpdate manifests, Flux reconciles
Self-Hosted RunnersARM64 architecture support

Combined with Flux CD, this enables automated deployments triggered by Git commits.

[GitHub Actions Documentation] — GitHub