Cloud & DevOps

CI/CD for .NET with GitHub Actions: Build, Test, Containerize and Deploy

CI/CD for .NET with GitHub Actions: Build, Test, Containerize and Deploy

Most .NET teams already have a GitHub Actions .NET workflow. It’s usually the one from the template: check out, set up the SDK, dotnet build, dotnet test, done. That’s a start, but it’s not a pipeline you can trust with production. It doesn’t cache anything, it lets warnings and unformatted code through, it never produces an artifact you can deploy, and when someone finally adds a deploy step it tends to come with a long-lived cloud key pasted into the repository secrets.

This post builds a complete .NET CI/CD pipeline for an ASP.NET Core API, one piece at a time: fast feedback on pull requests, quality gates that actually block merges, a Docker image pushed to GitHub Container Registry, and a deployment from main that needs an approval and uses OIDC instead of stored credentials. The last section has the whole ci.yml in one block, ready to copy.

The examples assume this layout, the same one I used in Dockerizing an ASP.NET Core API the Right Way:

.
├── Api.sln
├── Directory.Build.props
├── Dockerfile
├── src/Api/Api.csproj
└── tests/Api.Tests/Api.Tests.csproj

I ran every dotnet command below locally against the .NET 8 and .NET 10 SDKs, and the action versions are the current major releases as of October 2026.

What a good .NET pipeline does

Before writing YAML, decide what the pipeline is for. A useful .NET CI/CD pipeline has two jobs, and they have different goals.

On pull requests, give fast, reliable feedback. A developer should know within a few minutes whether their change builds, passes tests, follows the code style and keeps coverage where it was. Nothing is deployed and nothing is pushed.

On main, produce a trusted artifact and ship it. Every merge builds one immutable artifact (here, a Docker image tagged with the commit SHA), and that exact image is what gets deployed. You never rebuild “the same” code for production on a different machine.

From that, a few rules follow:

  • The same commands run locally and in CI. If CI runs dotnet format --verify-no-changes, so can a developer. No pipeline-only magic.
  • Fail early and loudly. Cheap checks (restore, build, format) run before expensive ones (container builds, deployments).
  • Required checks gate the merge. A red pipeline that nobody is forced to look at is just decoration.
  • Deployment is boring. It takes an artifact that already passed everything, needs a human approval for production, and holds no permanent secrets.

Your first workflow: triggers, runners, setup-dotnet, restore, build, test

Workflows live in .github/workflows/. Here is a minimal but correct GitHub Actions ASP.NET Core workflow:

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

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.0.x

      - name: Restore
        run: dotnet restore

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

      - name: Test
        run: dotnet test --configuration Release --no-build

A few details matter here:

  • Triggers. pull_request runs on every PR into main; push to main runs after the merge; workflow_dispatch adds a “Run workflow” button for manual runs. Don’t trigger on every push to every branch, or each PR runs twice.
  • Runners. ubuntu-latest is the cheapest and fastest GitHub-hosted runner for .NET. Use windows-latest only if you really need Windows (WPF, .NET Framework, Windows-only APIs).
  • actions/setup-dotnet. The runner image already has some SDKs installed, but which ones changes over time. Pin the version you need. If your repository has a global.json, use global-json-file: global.json instead, so local builds and CI agree on the SDK.
  • --no-restore and --no-build. Each step reuses the output of the previous one. Without these flags dotnet test silently restores and builds again, which costs time and can hide a broken step.
  • permissions: contents: read. The default GITHUB_TOKEN can have write access depending on repository settings. Start read-only and grant more per job.

That workflow works. It’s also slower than it needs to be and doesn’t block much, so let’s fix both.

Making it fast: NuGet caching, test reports and a matrix for .NET 8 and 10

Cache NuGet packages with a lock file

Restore is often the slowest step on a cold runner. actions/setup-dotnet can cache the global NuGet packages folder for you, but it keys the cache on packages.lock.json files and fails if there are none. That’s a good thing: lock files make restores reproducible as well as cacheable.

Turn them on for every project in Directory.Build.props:

<!-- Directory.Build.props -->
<Project>
  <PropertyGroup>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>
</Project>

Run dotnet restore once locally and commit the generated packages.lock.json files. Then enable the cache and restore in locked mode, which fails if the lock file and the project files disagree instead of quietly resolving different versions:

      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.0.x
          cache: true
          cache-dependency-path: '**/packages.lock.json'

      - name: Restore
        run: dotnet restore --locked-mode

When you add or update a package, run dotnet restore locally and commit the updated lock file with the change. Dependabot and Renovate both understand NuGet lock files.

Publish test results where people will see them

When a test fails, nobody wants to scroll through raw logs. Write results as TRX and turn them into a check with a readable summary:

      - name: Test
        run: >
          dotnet test --configuration Release --no-build
          --logger "trx;LogFileName=test-results.trx"
          --results-directory TestResults

      - name: Test report
        if: ${{ !cancelled() }}
        uses: dorny/test-reporter@v3
        with:
          name: Tests
          path: 'TestResults/**/*.trx'
          reporter: dotnet-trx

      - name: Upload test results
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v7
        with:
          name: test-results
          path: TestResults

if: ${{ !cancelled() }} runs these steps even when tests fail, which is exactly when you need them. The reporter creates a check run, so the job needs checks: write permission (the complete workflow grants it to this job only). For pull requests from forks the token is read-only and the check can’t be created; the reporter’s README describes a two-workflow setup for public repositories.

Run a matrix for .NET 8 and .NET 10

.NET 8 support ends on November 10, 2026, so many teams are mid-migration to .NET 10 right now. While you move, multi-target the projects and test both frameworks in parallel:

<!-- src/Api/Api.csproj and tests/Api.Tests/Api.Tests.csproj -->
<PropertyGroup>
  <TargetFrameworks>net8.0;net10.0</TargetFrameworks>
</PropertyGroup>
jobs:
  build-test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        tfm: [net8.0, net10.0]
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: |
            8.0.x
            10.0.x
          cache: true
          cache-dependency-path: '**/packages.lock.json'

      - run: dotnet restore --locked-mode
      - run: dotnet build --configuration Release --no-restore --framework ${{ matrix.tfm }}
      - run: >
          dotnet test --configuration Release --no-build --framework ${{ matrix.tfm }}
          --logger "trx;LogFileName=test-results.trx"
          --results-directory TestResults/${{ matrix.tfm }}

Both SDKs are installed because the .NET 8 test host needs the .NET 8 runtime; the .NET 10 SDK builds both targets. fail-fast: false keeps the other leg running when one fails, so you see whether a failure is framework-specific. When the migration is done, drop net8.0 from TargetFrameworks and the matrix.

Quality gates: dotnet format, warnings as errors, coverage and required checks

Fast is useless if bad code still gets through. These gates are cheap and catch real problems.

Warnings as errors and analyzers

Put the rules in Directory.Build.props so they apply locally and in CI alike:

<Project>
  <PropertyGroup>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <AnalysisLevel>latest-recommended</AnalysisLevel>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>
</Project>

With AnalysisLevel set to latest-recommended, the template test method Addition_Works already fails the build with CA1707 (no underscores in member names). That’s the point: you find out on day one, not after 400 warnings have piled up. If your team prefers underscores in test names, turn that rule off for test projects in .editorconfig instead of disabling warnings as errors.

On an existing codebase with hundreds of warnings, enable TreatWarningsAsErrors per project as you clean them up, or use <WarningsAsErrors> to promote specific rules first, such as nullable warnings (nullable).

dotnet format

dotnet format ships with the SDK. With --verify-no-changes it exits with a non-zero code if any file would change, which makes it a perfect CI check:

  format:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: |
            8.0.x
            10.0.x
          cache: true
          cache-dependency-path: '**/packages.lock.json'
      - run: dotnet restore --locked-mode
      - run: dotnet format --verify-no-changes --no-restore --verbosity diagnostic

Developers fix failures by running dotnet format locally. Put the style rules in a committed .editorconfig so everyone formats the same way.

Code coverage with a minimum

The xunit template already references coverlet.collector. Collect coverage during the test run, merge the reports with ReportGenerator, show a summary on the run page and fail below a threshold:

      - name: Test with coverage
        run: >
          dotnet test --configuration Release --no-build
          --collect:"XPlat Code Coverage"
          --results-directory TestResults

      - name: Coverage report
        run: |
          dotnet tool install --global dotnet-reportgenerator-globaltool
          reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" \
            -targetdir:coverage -reporttypes:"MarkdownSummaryGithub;JsonSummary"
          cat coverage/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"

      - name: Enforce minimum coverage
        env:
          MIN_LINE_COVERAGE: 80
        run: |
          line=$(jq '.summary.linecoverage // 0' coverage/Summary.json)
          echo "Line coverage: ${line}%"
          if awk -v c="$line" -v min="$MIN_LINE_COVERAGE" 'BEGIN { exit !(c < min) }'; then
            echo "::error::Line coverage ${line}% is below ${MIN_LINE_COVERAGE}%"
            exit 1
          fi

jq is preinstalled on GitHub-hosted Ubuntu runners. Pick a threshold close to what you have today and raise it over time; a number you can’t meet just gets disabled. Coverage tells you which code no test runs, not whether your tests check anything useful, so treat it as a floor rather than a goal.

Make the checks required

None of this blocks a merge until you say so. In the repository settings, add a branch ruleset (or branch protection rule) for main that:

  • requires a pull request before merging,
  • requires the status checks to pass, for example build-test (net8.0), build-test (net10.0) and format,
  • requires branches to be up to date before merging, and
  • blocks force pushes.

The check names are the job names (plus the matrix values), so keep job names stable. Renaming a job silently drops it from the required list.

Containerize: build and push the Docker image to GHCR

The Dockerfile is the multi-stage one from my Docker post, adjusted for lock files and the multi-targeted project. Restore runs in its own layer so that BuildKit can reuse it until a dependency changes:

# Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

COPY Directory.Build.props ./
COPY src/Api/Api.csproj src/Api/packages.lock.json src/Api/
RUN dotnet restore src/Api/Api.csproj --locked-mode

COPY src/ src/
RUN dotnet publish src/Api/Api.csproj -c Release -f net10.0 -o /app/publish \
    --no-restore -p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app/publish .
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Api.dll"]

Keep the .dockerignore from that post too, so bin/, obj/ and local secrets never reach the build context.

GitHub Container Registry (ghcr.io) is the natural registry here: it uses the workflow’s own GITHUB_TOKEN, so there’s no registry password to store. The job needs packages: write:

  image:
    needs: [build-test, format]
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v7

      - uses: docker/setup-buildx-action@v4

      - uses: docker/login-action@v4
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v6
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,format=long
            type=raw,value=latest,enable={{is_default_branch}}

      - id: build
        uses: docker/build-push-action@v7
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

What each piece does:

  • Pull requests build the image but don’t push it. A broken Dockerfile fails the PR, but unreviewed code never lands in the registry.
  • docker/metadata-action generates tags and OCI labels. type=sha,format=long tags each image with the full commit SHA, which is what you deploy; latest is only a convenience for humans on the default branch.
  • cache-from/cache-to: type=gha stores BuildKit layers in the Actions cache, so the restore layer is reused across runs.
  • The digest output (sha256:...) identifies the exact image. Deploying by digest guarantees the image can’t change under you, even if someone re-pushes a tag.

The image: name must be lowercase. github.repository keeps the owner’s capitalisation, so if your account or organisation name has capitals, hardcode a lowercase name instead.

Deploying safely: environments, approvals, secrets and OIDC

Environments and manual approvals

A GitHub environment (Settings → Environments) groups deployment rules and secrets. Create one called production and configure:

  • Required reviewers: one or more people who must approve each deployment.
  • Deployment branches and tags: only main can deploy.
  • Environment secrets and variables: values only jobs targeting this environment can read.

A job that targets the environment pauses until a reviewer approves it in the Actions UI:

  deploy:
    needs: image
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://api.example.com
    concurrency:
      group: deploy-production
      cancel-in-progress: false

The concurrency group makes deployments run one at a time, and cancel-in-progress: false means a newer deployment waits instead of killing one that’s halfway through.

Required reviewers on private repositories depend on your GitHub plan, so check that before you rely on them.

OIDC instead of stored cloud keys

The classic setup stores a service principal secret or an access key in repository secrets. That key lives for months, works from anywhere, and anyone who can edit a workflow can try to use it.

With OpenID Connect, the job asks GitHub for a short-lived signed token that says “repository X, environment production, commit Y”. Your cloud provider trusts GitHub’s issuer, checks those claims and exchanges the token for credentials that expire within the hour. There’s no secret to leak or rotate.

For Azure: create an app registration (or a user-assigned managed identity), add a federated credential with issuer https://token.actions.githubusercontent.com and subject repo:<owner>/<repo>:environment:production, and give it the least role it needs on the target resource. Store its client ID, tenant ID and subscription ID as environment variables; they are identifiers, not secrets. Then:

    permissions:
      id-token: write   # allow the job to request an OIDC token
      contents: read
    steps:
      - uses: azure/login@v3
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - uses: azure/webapps-deploy@v2
        with:
          app-name: ${{ vars.AZURE_WEBAPP_NAME }}
          images: ghcr.io/${{ github.repository }}@${{ needs.image.outputs.digest }}

Because the subject includes environment:production, the credential only works for jobs that passed the environment’s approval and branch rules. AWS (aws-actions/configure-aws-credentials with role-to-assume) and Google Cloud (google-github-actions/auth with Workload Identity Federation) follow the same pattern.

If the GHCR package is private, the App Service also needs credentials to pull it. Either make the package public for open-source projects, or configure the web app with a GitHub token that has only read:packages, or mirror the image to Azure Container Registry and let the web app’s managed identity pull it.

Secrets that remain

Some values can’t be replaced by OIDC, such as a third-party API key. Store them as environment secrets rather than repository secrets, pass them to steps through env:, and never echo them. GitHub masks known secrets in logs, but it can’t mask a value you’ve transformed, for example base64-encoded. Application secrets such as connection strings belong in the platform’s configuration or Key Vault, not in the image.

Reusable workflows and common pitfalls

Reusable workflows

Once you have several services, copy-pasted YAML drifts. Move the shared build into a reusable workflow with on: workflow_call:

# .github/workflows/dotnet-build.yml
name: dotnet-build

on:
  workflow_call:
    inputs:
      dotnet-version:
        type: string
        default: 10.0.x

permissions:
  contents: read

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: ${{ inputs.dotnet-version }}
          cache: true
          cache-dependency-path: '**/packages.lock.json'
      - run: dotnet restore --locked-mode
      - run: dotnet build --configuration Release --no-restore
      - run: dotnet test --configuration Release --no-build

Callers use it as a job:

jobs:
  build:
    uses: ./.github/workflows/dotnet-build.yml
    with:
      dotnet-version: 10.0.x

To share it across repositories, reference it as my-org/shared-workflows/.github/workflows/dotnet-build.yml@<tag-or-sha>. Use composite actions instead when you want to share a few steps rather than whole jobs.

Pitfalls I see most often

Unpinned or loosely pinned actions. @v7 is a moving tag; the action’s owner (or an attacker who compromises their account) can point it at new code. For first-party actions/* and well-known publishers, a major tag is a reasonable trade-off. For third-party actions, especially any that see secrets, pin the full commit SHA and keep the version as a comment:

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Dependabot can still update SHA-pinned actions when you add a github-actions entry to .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
  - package-ecosystem: nuget
    directory: /
    schedule:
      interval: weekly

Over-privileged tokens. Set permissions: contents: read at the top of every workflow, then grant packages: write, checks: write or id-token: write only to the job that needs it. In the repository’s Actions settings, set the default workflow permissions to read-only as well.

pull_request_target with a checkout of the PR code. It runs with secrets and a write token in the context of the base branch. Checking out and building untrusted PR code there hands your secrets to anyone who opens a PR. Use plain pull_request unless you fully understand the difference.

Script injection. Never put ${{ github.event.pull_request.title }} or other user-controlled values directly inside run:. Pass them through env: and reference them as shell variables, "$PR_TITLE".

Rebuilding for production. Deploy the digest that passed CI. A separate “release” build from the same commit is a different artifact.

No timeouts. A hung test can burn up to six hours of runner minutes, the default job limit. Set timeout-minutes on every job.

The complete ci.yml, copy-paste ready

Everything above in one file. It needs the Directory.Build.props, committed lock files and the Dockerfile shown earlier, a production environment with required reviewers, and the AZURE_* variables on that environment. If you deploy somewhere other than Azure, replace the two steps in the deploy job.

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

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  DOTNET_NOLOGO: true
  DOTNET_CLI_TELEMETRY_OPTOUT: true

jobs:
  build-test:
    name: build-test (${{ matrix.tfm }})
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: read
      checks: write
    strategy:
      fail-fast: false
      matrix:
        tfm: [net8.0, net10.0]
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: |
            8.0.x
            10.0.x
          cache: true
          cache-dependency-path: '**/packages.lock.json'

      - name: Restore
        run: dotnet restore --locked-mode

      - name: Build
        run: dotnet build --configuration Release --no-restore --framework ${{ matrix.tfm }}

      - name: Test
        run: >
          dotnet test --configuration Release --no-build --framework ${{ matrix.tfm }}
          --logger "trx;LogFileName=test-results.trx"
          --collect:"XPlat Code Coverage"
          --results-directory TestResults

      - name: Test report
        if: ${{ !cancelled() }}
        uses: dorny/test-reporter@v3
        with:
          name: Tests (${{ matrix.tfm }})
          path: 'TestResults/**/*.trx'
          reporter: dotnet-trx

      - name: Coverage report
        if: ${{ !cancelled() }}
        run: |
          dotnet tool install --global dotnet-reportgenerator-globaltool
          reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" \
            -targetdir:coverage -reporttypes:"MarkdownSummaryGithub;JsonSummary"
          cat coverage/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"

      - name: Enforce minimum coverage
        env:
          MIN_LINE_COVERAGE: 80
        run: |
          line=$(jq '.summary.linecoverage // 0' coverage/Summary.json)
          echo "Line coverage: ${line}%"
          if awk -v c="$line" -v min="$MIN_LINE_COVERAGE" 'BEGIN { exit !(c < min) }'; then
            echo "::error::Line coverage ${line}% is below ${MIN_LINE_COVERAGE}%"
            exit 1
          fi

      - name: Upload test results
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v7
        with:
          name: test-results-${{ matrix.tfm }}
          path: |
            TestResults
            coverage

  format:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: |
            8.0.x
            10.0.x
          cache: true
          cache-dependency-path: '**/packages.lock.json'

      - run: dotnet restore --locked-mode
      - run: dotnet format --verify-no-changes --no-restore --verbosity diagnostic

  image:
    needs: [build-test, format]
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: read
      packages: write
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v7

      - uses: docker/setup-buildx-action@v4

      - uses: docker/login-action@v4
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v6
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,format=long
            type=raw,value=latest,enable={{is_default_branch}}

      - id: build
        uses: docker/build-push-action@v7
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: image
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: read
      id-token: write
    environment:
      name: production
      url: ${{ vars.PRODUCTION_URL }}
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    steps:
      - uses: azure/login@v3
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - uses: azure/webapps-deploy@v2
        with:
          app-name: ${{ vars.AZURE_WEBAPP_NAME }}
          images: ghcr.io/${{ github.repository }}@${{ needs.image.outputs.digest }}

Before you rely on it, run through this list once:

  1. dotnet restore, dotnet build -c Release, dotnet format --verify-no-changes and dotnet test all pass locally.
  2. packages.lock.json files are committed.
  3. A test PR shows build-test (net8.0), build-test (net10.0) and format as checks; add them as required checks on main.
  4. After the first merge, the image appears under the repository’s Packages with a SHA tag.
  5. The deploy job waits for approval, logs in without any stored cloud secret, and the app’s health endpoint responds after deployment.

Conclusion

A good GitHub Actions .NET pipeline isn’t long, but every line has a job: lock files and caching make it fast, analyzers, formatting and coverage make it strict, a SHA-tagged image makes the artifact trustworthy, and environments with OIDC make deployment safe without a single long-lived cloud key. Start with the first workflow, add one section at a time, and make each gate a required check as soon as it’s green.

If you haven’t containerized your API yet, start with Dockerizing an ASP.NET Core API the Right Way; this pipeline builds directly on that Dockerfile.

Newsletter

Get the next chapter

One email when a new chapter or deep-dive is published. Practical backend and API design notes. No spam, unsubscribe any time.

Leave a Reply

Your email address will not be published. Required fields are marked *

Keep reading

Related articles