Contents
A .NET Docker image build in GitHub Actions without cache takes 5-7 minutes. With BuildKit cache properly set up — 1-1.5. The difference is hours of CI time saved per week and faster PR feedback.
Why regular Docker cache doesn't work in Actions
Each GitHub Actions job runs on a clean VM — image layers from the previous build don't persist between runs. docker build works "as if for the first time" — pulls the base image, restores all NuGet packages, rebuilds from scratch.
The standard actions/cache saves ~/.nuget/packages, but it won't speed up the build inside Docker — the cache lives on the host, not in the container.
The solution: BuildKit can export image layers to external storage and pull them back before building. Since 0.10+ there's a gha backend — the cache lives in native GitHub Actions cache (10 GB, free).
Full workflow for .NET
name: Build & Push
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
The key lines — cache-from: type=gha and cache-to: type=gha,mode=max. First run: cache is empty, full build. All subsequent runs: BuildKit compares layers against cache and reuses unchanged ones.
mode=max exports all intermediate layers, not just final ones — critical for multi-stage.
A Dockerfile that actually caches
Main rule — layers with rarely changing data go before layers with frequently changing data (layers and caching in detail are in the Dockerfile article). For most .NET projects:
# syntax=docker/dockerfile:1.7
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# 1. Copy only .csproj — this layer invalidates only when
# dependencies change (not every edited .cs file)
COPY *.sln .
COPY src/Api/Api.csproj src/Api/
COPY src/Domain/Domain.csproj src/Domain/
# 2. Restore — the slowest step (~1 min). Cached until .csproj changes
RUN dotnet restore --no-cache
# 3. Copy the rest of the code — this layer invalidates on every commit,
# but the restore above is already cached
COPY . .
# 4. Build + publish
RUN dotnet publish src/Api/Api.csproj \
-c Release \
-o /app/publish \
--no-restore
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app/publish .
USER 1000
ENTRYPOINT ["dotnet", "Api.dll"]
If you do COPY . . at the start — every .cs change invalidates the restore layer. All NuGet packages get re-downloaded. On a .NET project that's +2-3 minutes per build.
Real numbers
Project — a .NET 9 Api, ~30 NuGet packages, ~150 .cs files:
| Build | Time | What's cached |
|---|---|---|
| First (cache miss) | 5:42 | Nothing |
| README change | 0:48 | Everything (cache hit on final layer) |
.cs file change |
1:24 | Restore + base image |
| New NuGet package | 3:12 | Base image only |
| .NET SDK base image update | 5:30 | Almost nothing |
Average across real PRs — ~1:30. That's 4× faster than uncached builds.
Pitfalls
10 GB limit and eviction
GitHub Actions cache has a hard 10 GB per repo limit. Exceed it — old keys get evicted by LRU. Multi-stage with heavy layers (~1-2 GB) can "fill" the cache. Clean manually via gh cache delete or wait for natural eviction.
mode=max vs mode=min
min— caches only final layers of the final multi-stage stage. Small, but if you change the Dockerfile almost everything rebuilds.max— caches all intermediate layers. More space, but resumable from any point.
For .NET — always max. For a tiny 3-line Dockerfile — min is fine.
Cache miss due to .dockerignore
If .dockerignore is wrong, the context includes frequently-changing files (e.g., bin/, obj/). COPY . . invalidates on every build. Minimal .dockerignore:
bin/
obj/
.git/
.github/
.vs/
*.user
**/node_modules/
Run docker build --progress=plain . locally and check which files actually go into the context.
If you change a base image FROM (e.g., updated .NET SDK from 9.0.0 to 9.0.1) — cache is zero. That's normal. Don't try to "pin" via digest without a reason — you'll miss security patches.
Parallel jobs and race condition
If two PRs push to the same cache key at the same time — the last writer wins. Not critical, but sometimes shows up as "it cached for me, not for my colleague". For shared cache, use scope:
cache-from: type=gha,scope=${{ github.ref_name }}
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}
Each branch gets its own cache.
Alternatives — registry cache
If 10 GB GHA cache is not enough, store cache in an OCI registry (the same GHCR):
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
Pros — no size limit, accessible from other repos. Cons — slower (push/pull over the network), takes space in the registry.
Summary
- BuildKit cache
type=ghaspeeds up .NET builds 3-4×, for free. - Dockerfile structure is the key:
.csprojfirst →restore→ code →build. mode=maxfor multi-stage — mandatory.- A minimal
.dockerignore— without itCOPY . .invalidates for no reason. - 10 GB limit — keep in mind for big projects; per-branch scope helps.
- Changed a base image — cache is dropped, that's normal.
Discussion
No comments yet. Be the first.