Back to blog
Backend Systemsintermediate

GitHub Actions: CI/CD for .NET Applications

Master GitHub Actions workflows for .NET: CI pipelines, Docker builds, Azure deployments, matrix builds, and secrets management.

Asma HafeezApril 17, 202614 min read
github-actionsci-cddotnetdockerazuredevops
Share:𝕏

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 run

Your First Workflow: Basic .NET CI

Create .github/workflows/ci.yml:

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

This 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

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

Understanding Runners

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

For .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

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

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

YAML
steps:
  - name: Deploy
    env:
      AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }}
      REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
    run: |
      echo "$REGISTRY_PASSWORD" | docker login -u myuser --password-stdin

3. Environment secrets (per-environment, with protection rules)

YAML
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.sh

What NOT to do with secrets

YAML
# 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 run

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

YAML
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

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

CD Workflow: Build Docker Image and Push to Registry

YAML
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/arm64

Multi-stage Dockerfile for .NET

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

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

Setting up AZURE_CREDENTIALS secret

Bash
# 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 GitHub

Artifact Upload and Download

Artifacts let you share files between jobs:

YAML
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

YAML
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

YAML
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.sh

Reusable Workflows

Extract common logic into reusable workflows:

YAML
# .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 }}
YAML
# .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:

YAML
# .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

YAML
on:
  push:
    paths-ignore:
      - '**.md'
      - 'docs/**'
      - '.github/ISSUE_TEMPLATE/**'

Concurrency: cancel outdated runs

YAML
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true   # Cancel previous run if new push comes in

This prevents multiple parallel deploys when you push twice quickly.

Timeouts

YAML
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.sh

Using outputs between steps

YAML
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

YAML
# .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?

Share:𝕏

Leave a comment

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