Skip to content
← All posts
· 5 min read· By

GitHub Actions CI Boilerplate: Build, Test, and Push a .NET App as a Docker Image to GHCR

A complete GitHub Actions workflow for .NET projects: automated build, test, and Docker image push to GHCR – in under 50 lines of YAML, no external services needed.

GitHub ActionsCI/CD.NETDockerGHCRDevOpsContainer

Continuous integration often starts with a simple question: how do I make sure my code compiles after every merge, all tests pass – and a ready Docker image is available? GitHub Actions answers this question directly inside the repository, without an external CI server, without additional logins. This boilerplate shows a complete workflow for a .NET application: automatic build and test on every push or pull request, and a Docker image that lands in the GitHub Container Registry (GHCR) on every successful merge to main.

GHCR is integrated into GitHub and requires no external credentials – the GITHUB_TOKEN that Actions provides automatically is sufficient for login and push. No separate registry configuration, no Docker Hub account, no third-party CI/CD platform. The workflow below runs entirely on a ubuntu-latest runner inside GitHub and produces an image at ghcr.io/<owner>/<repository> that is immediately reachable via docker pull.

Prerequisites

  • A GitHub repository containing a .NET application
  • A Dockerfile in the repository root (e.g. a multi-stage build)
  • No additional secrets or external services required

How the Workflow Works

Push to main / PR opened
          |
          v
+-------------------------------+
|  Job: build-test-push         |
|  ubuntu-latest                |
|                               |
|  1. actions/checkout@v7       |
|  2. actions/setup-dotnet@v5   |
|  3. dotnet restore            |
|  4. dotnet build              |
|  5. dotnet test               |
|  6. docker/login-action@v4    |
|  7. docker/build-push@v7      |
+-------------------------------+
          |
          v
 ghcr.io/<owner>/<repo>:<sha>

The image push in step 7 only occurs on direct pushes to main, not on pull requests. Build and test run in both cases and provide early feedback.

The Complete Workflow

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

name: CI

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

permissions:
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

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

    steps:
      - name: Checkout
        uses: actions/checkout@v7

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

      - name: Restore
        run: dotnet restore

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

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

      - name: Login to GHCR
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v7
        with:
          context: .
          push: ${{ github.ref == 'refs/heads/main' }}
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

A few details that matter in practice:

The permissions block: Not optional convenience – it is mandatory. Without packages: write, GHCR rejects every push. The error looks like a network problem but is actually a permission error, easy to miss if you treat the block as boilerplate you can skip.

push: ${{ github.ref == 'refs/heads/main' }}: Images only land in the registry on merges to main, not on every pull request. This keeps the registry free of intermediate states from feature branches, while build and test still trigger on every PR and give fast feedback.

--no-restore and --no-build: The build step skips a second restore, and test does not recompile. This avoids redundant work and saves 30–60 seconds of runtime on a typical project.

${{ github.sha }} as the tag: Every commit gets an immutable tag. No overwriting latest, full traceability, and a clear link between the Git commit and the running container. You can add latest as a second tag via a comma-separated tags list, but the SHA tag should always be present.

IMAGE_NAME: ${{ github.repository }}: This variable expands to owner/repository-name (with a slash) and serves as the image name in GHCR. The result is ghcr.io/owner/repository-name:sha. If you want a different image name, replace github.repository with a fixed string.

dotnet-version: '8.0.x': Replace this with the .NET version your project targets. 8.0.x is the current LTS version, supported until November 2026.

Three Common Pitfalls

1. GHCR images are private after the first push. New packages in GHCR default to private. If external systems – such as a deployment server or a Kubernetes cluster – need to pull the image, you must change visibility: GitHub page of the package → Package SettingsChange visibility → Public. Alternatively, you can grant access to specific repositories or users without making the image public.

2. permission_denied on the first push despite a correct permissions block. This happens when the package was previously created outside of Actions – for example with a local docker push using a personal access token – and is not linked to the repository. Fix: in the Package dashboard, go to Manage Actions access, add the repository, and set the permission to Write. The workflow will succeed after that.

3. Outdated action versions from tutorials. Many guides still show docker/login-action@v3, docker/build-push-action@v5, actions/setup-dotnet@v4, or actions/checkout@v4. The current major versions are v4, v7, v5, and v7 respectively – all running on the Node 24 runtime. A quick check of the release page for each action on GitHub Marketplace is worth it before copying snippets from articles.

What's Next

This boilerplate is an intentionally minimal starting point. Common extensions in practice:

  • Matrix strategy: Test multiple .NET versions in parallel with strategy: matrix: dotnet: ['8.0.x', '10.0.x']
  • NuGet cache: actions/cache with path ~/.nuget/packages saves one to two minutes of restore time per job on a typical project
  • Multi-arch images: docker/setup-buildx-action + platforms: linux/amd64,linux/arm64 for ARM-compatible images
  • Node.js projects: The same pattern works with actions/setup-node@v4 instead of setup-dotnetnpm ci, npm test, and npm run build follow the same logic

Official documentation:

Note: The articles on this blog are produced with the help of AI and are editorially reviewed before publication. Editorial responsibility lies with Emre Yurtbay (see the Impressum).

Discuss your project