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.
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
Dockerfilein 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 Settings → Change 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/cachewith path~/.nuget/packagessaves one to two minutes of restore time per job on a typical project - Multi-arch images:
docker/setup-buildx-action+platforms: linux/amd64,linux/arm64for ARM-compatible images - Node.js projects: The same pattern works with
actions/setup-node@v4instead ofsetup-dotnet–npm ci,npm test, andnpm run buildfollow 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).