DEV Community

Adrián Bailador
Adrián Bailador

Posted on

GitHub Actions for .NET: Build, Test, and Deploy Your API

A complete guide to CI/CD for .NET developers — automated builds, test runs, Docker images, and deployments to Azure using GitHub Actions from scratch.


Introduction: Why CI/CD Is Not Optional

Every time you push code manually, run tests by hand, or deploy by copying files to a server, you're introducing risk. Human steps fail. They get skipped under pressure. They're not reproducible.

CI/CD (Continuous Integration / Continuous Deployment) solves this by automating the entire path from a git push to a running application. Every commit is built. Every build is tested. Every successful build on main can be deployed automatically.

GitHub Actions is the most natural CI/CD tool for .NET developers on GitHub. It's free for public repositories, deeply integrated with the GitHub ecosystem, and has a growing marketplace of pre-built actions. You write workflows in YAML, they run on Microsoft-hosted runners with .NET pre-installed, and you pay nothing for most use cases.

In this article I'll walk you through building a complete pipeline: automated builds, test runs with coverage, Docker image creation, NuGet caching, and deployment to Azure App Service.


GitHub Actions Core Concepts

Before writing any YAML, understand the four building blocks:

Workflow — a YAML file in .github/workflows/ that defines your automation. You can have multiple workflows per repository.

Event — what triggers the workflow. A push, a pull request, a schedule, or a manual trigger.

Job — a unit of work that runs on a runner (a virtual machine). Jobs run in parallel by default; you can make them sequential with needs.

Step — a single command or action within a job. Steps run sequentially within a job.

Workflow
  └── Job A (runs on ubuntu-latest)
        ├── Step 1: Checkout code
        ├── Step 2: Setup .NET
        ├── Step 3: Restore packages
        ├── Step 4: Build
        └── Step 5: Test
  └── Job B (runs after Job A)
        ├── Step 1: Build Docker image
        └── Step 2: Deploy to Azure
Enter fullscreen mode Exit fullscreen mode

Your First .NET Workflow: Build + Test on Every Push

Create .github/workflows/ci.yml in your repository:

name: CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Restore dependencies
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore --configuration Release

      - name: Test
        run: dotnet test --no-build --configuration Release --verbosity normal
Enter fullscreen mode Exit fullscreen mode

This workflow runs on every push to main or develop, and on every pull request targeting main. It checks out your code, sets up .NET 8, restores NuGet packages, builds in Release mode, and runs all tests.

Push this file to your repository and you'll see it appear under the Actions tab immediately.


Caching NuGet Packages for Faster Builds

By default, NuGet packages are downloaded fresh on every run. For a project with many dependencies, this adds 30–90 seconds per build. Caching fixes this:

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - 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

      - name: Build
        run: dotnet build --no-restore --configuration Release

      - name: Test
        run: dotnet test --no-build --configuration Release
Enter fullscreen mode Exit fullscreen mode

The cache key is based on the hash of all .csproj files. When you add or update a package, the hash changes, the cache misses, and packages are downloaded fresh. Otherwise, the cached packages are used immediately.


Running Tests and Publishing Coverage Reports

Knowing tests pass is good. Knowing coverage isn't dropping is better. Add coverage reporting with Coverlet:

First, add the package to your test project:

dotnet add package coverlet.collector
Enter fullscreen mode Exit fullscreen mode

Then update your test step:

      - name: Test with coverage
        run: |
          dotnet test --no-build --configuration Release \
            --collect:"XPlat Code Coverage" \
            --results-directory ./coverage

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: ./coverage
Enter fullscreen mode Exit fullscreen mode

For a richer experience, use Codecov or the built-in GitHub summary:

      - name: Test with coverage summary
        run: |
          dotnet test --no-build --configuration Release \
            --logger "trx;LogFileName=test-results.trx" \
            --collect:"XPlat Code Coverage"

      - name: Publish test results
        uses: dorny/test-reporter@v1
        if: always()
        with:
          name: .NET Test Results
          path: '**/*.trx'
          reporter: dotnet-trx
Enter fullscreen mode Exit fullscreen mode

The if: always() ensures test results are published even when tests fail — so you can see which tests broke.


Matrix Builds: Testing on Multiple .NET Versions

If your library or API needs to support multiple .NET versions, matrix builds test all of them in parallel:

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        dotnet-version: [ '8.0.x', '9.0.x' ]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup .NET ${{ matrix.dotnet-version }}
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ matrix.dotnet-version }}

      - name: Restore, Build, Test
        run: |
          dotnet restore
          dotnet build --no-restore --configuration Release
          dotnet test --no-build --configuration Release
Enter fullscreen mode Exit fullscreen mode

This runs two parallel jobs — one for .NET 8, one for .NET 9 — and reports results for each independently.


Building and Pushing a Docker Image

Once tests pass, build a Docker image and push it to a container registry. First, make sure you have a Dockerfile in your project:

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore "MyApi/MyApi.csproj"
COPY . .
WORKDIR "/src/MyApi"
RUN dotnet build --configuration Release --output /app/build

FROM build AS publish
RUN dotnet publish --configuration Release --output /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
Enter fullscreen mode Exit fullscreen mode

Now add a Docker job to your workflow that runs after the tests pass:

  docker:
    runs-on: ubuntu-latest
    needs: build-and-test
    if: github.ref == 'refs/heads/main'

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            yourusername/myapi:latest
            yourusername/myapi:${{ github.sha }}
Enter fullscreen mode Exit fullscreen mode

The needs: build-and-test ensures Docker only runs if tests pass. The if: github.ref == 'refs/heads/main' ensures we only push images from the main branch, not from every feature branch.

Add your Docker Hub credentials as repository secrets under Settings → Secrets and variables → Actions.


Deploying to Azure App Service

For deploying to Azure App Service, use the official Azure action. You'll need an Azure publish profile stored as a secret:

  deploy:
    runs-on: ubuntu-latest
    needs: docker
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - name: Deploy to Azure App Service
        uses: azure/webapps-deploy@v3
        with:
          app-name: 'my-dotnet-api'
          publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
          images: 'yourusername/myapi:${{ github.sha }}'
Enter fullscreen mode Exit fullscreen mode

The environment: production adds a manual approval gate — someone must approve the deployment in the GitHub UI before it proceeds. Essential for production deployments.

To get the publish profile, go to your App Service in the Azure Portal → Get Publish Profile → download the file → paste the contents into a GitHub secret named AZURE_WEBAPP_PUBLISH_PROFILE.


Using Secrets and Environment Variables Safely

Never hardcode connection strings, API keys, or credentials in your workflow files. Use GitHub secrets for sensitive values and environment variables for non-sensitive configuration:

    steps:
      - name: Run integration tests
        env:
          # Non-sensitive — fine in the workflow file
          ASPNETCORE_ENVIRONMENT: Testing
          # Sensitive — always use secrets
          ConnectionStrings__DefaultConnection: ${{ secrets.TEST_DB_CONNECTION }}
          ExternalApi__ApiKey: ${{ secrets.EXTERNAL_API_KEY }}
        run: dotnet test --filter Category=Integration
Enter fullscreen mode Exit fullscreen mode

For environment-specific configuration across multiple jobs, use workflow-level environment variables:

env:
  DOTNET_VERSION: '8.0.x'
  BUILD_CONFIGURATION: Release
  AZURE_APP_NAME: 'my-dotnet-api'

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Build
        run: dotnet build --configuration ${{ env.BUILD_CONFIGURATION }}
Enter fullscreen mode Exit fullscreen mode

Complete Pipeline: Putting It All Together

Here's the full production-ready workflow combining everything above:

name: CI/CD Pipeline

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

env:
  DOTNET_VERSION: '8.0.x'
  BUILD_CONFIGURATION: Release
  DOCKER_IMAGE: yourusername/myapi

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - 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
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore --configuration ${{ env.BUILD_CONFIGURATION }}

      - name: Test
        run: |
          dotnet test --no-build \
            --configuration ${{ env.BUILD_CONFIGURATION }} \
            --collect:"XPlat Code Coverage" \
            --logger "trx;LogFileName=test-results.trx"

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: '**/*.trx'

  docker:
    runs-on: ubuntu-latest
    needs: build-and-test
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.DOCKER_IMAGE }}:latest
            ${{ env.DOCKER_IMAGE }}:${{ github.sha }}

  deploy:
    runs-on: ubuntu-latest
    needs: docker
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - name: Deploy to Azure App Service
        uses: azure/webapps-deploy@v3
        with:
          app-name: 'my-dotnet-api'
          publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
          images: '${{ env.DOCKER_IMAGE }}:${{ github.sha }}'
Enter fullscreen mode Exit fullscreen mode

This gives you three jobs in sequence: build and test → build Docker image → deploy to Azure, with manual approval on the final step.


Common Errors and How to Avoid Them

Error: Process completed with exit code 1 on dotnet test
This usually means a test failed, not a configuration problem. Check the test output in the Actions log. If tests pass locally but fail in CI, the most common cause is a missing environment variable or a test that depends on local state (file system, local database). Use --verbosity normal to see detailed output.


Docker build fails with COPY failed: file not found
The Docker build context in GitHub Actions is the repository root by default. If your Dockerfile references files with relative paths, make sure those paths are relative to the root, not to the Dockerfile's location. Set context: . explicitly in the docker/build-push-action step.


Secrets not available in pull requests from forks
For security, GitHub doesn't expose secrets to workflows triggered by pull requests from forks. This is intentional. Run integration tests that need secrets only on push events, not on pull_request from external contributors.


NuGet cache never hits
If the cache key changes on every run, check whether any .csproj file is being modified during the build (e.g. by a code generator). Use restore-keys as a fallback to get a partial cache hit even when the primary key misses.


Deployment runs even when tests fail
Always use needs: build-and-test on your deployment job. Without it, jobs run in parallel and a failing test won't stop a deployment. Add if: success() for extra safety.


azure/webapps-deploy fails with 403
The publish profile may be expired or from the wrong slot. Re-download it from the Azure Portal and update the secret. Also verify the app-name matches exactly — it's case-sensitive.


Best Practices

  • Never skip tests in CI. If tests are slow, fix the tests — don't disable them.
  • Pin action versions with a full SHA for security-critical workflows. actions/checkout@v4 is convenient; actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 is safer.
  • Use environments with required reviewers for production deployments. A manual approval gate prevents accidents.
  • Tag Docker images with the git SHA, not just latest. latest is a moving target that makes rollbacks painful.
  • Keep secrets minimal. Each secret should have the minimum permissions needed. Rotate them regularly.
  • Add a status badge to your README. It signals professionalism and shows the current build status at a glance.
  • Use concurrency groups to cancel in-progress runs when a new push arrives on the same branch:
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
Enter fullscreen mode Exit fullscreen mode

Conclusion

A CI/CD pipeline is one of the highest-ROI investments you can make in a .NET project. The first time it catches a broken build before it reaches production, or saves you from a failed manual deployment at 6pm on a Friday, it pays for itself.

The workflow in this article gives you everything you need for a real project: fast builds with NuGet caching, test results with coverage, Docker images tagged by commit SHA, and a deployment to Azure with a manual approval gate.

Start with just the build and test job. Add Docker and deployment once that's stable. Complexity can always be added later — the habit of always having CI running is what matters from day one.

In the next article in this series, we'll look at deploying .NET applications to Azure in depth — App Service, Container Apps, managed identity, and Application Insights for production monitoring.


Top comments (0)