A .NET build can spend a surprising amount of time downloading NuGet packages, especially when a GitHub Actions workflow runs on a fresh hosted runner. Even if the source code has changed only slightly, the runner may need to restore the same dependencies again before compiling the application.
Caching NuGet packages can reduce this repeated work and make CI builds faster. The key is to cache the package files that NuGet downloads, use a reliable cache key, and make sure dependency changes invalidate the cache when necessary.
This article explains how to configure NuGet caching in GitHub Actions, how to handle multiple projects, and how to troubleshoot cache misses and slow restores.
Why NuGet Restore Takes Time in CI
When you run a .NET build, the SDK restores the project's dependencies before compilation if a restore is required.
For example:
dotnet restore
dotnet build --no-restore
The restore process reads project files and dependency information, resolves package versions, downloads missing packages, and prepares the assets needed for the build.
On a developer's machine, many packages may already exist in the local NuGet package cache. A GitHub-hosted runner is generally a fresh environment, so those packages may need to be downloaded again for each workflow run.
The NuGet global packages folder commonly contains restored packages. On many Linux environments, its default location is:
~/.nuget/packages
Caching this folder allows later workflow runs to reuse package files when the cache key matches. NuGet can still perform dependency resolution, but it may not need to download every package again.
Caching is particularly useful for solutions with multiple projects, large dependency trees, and workflows that run frequently.
Configure NuGet Caching with setup-dotnet
The actions/setup-dotnet action supports dependency caching. For many .NET repositories, this is the simplest approach because it integrates SDK setup and NuGet package caching into the workflow.
Consider a repository with this structure:
MyApplication/
├── MyApplication.sln
├── global.json
├── Directory.Packages.props
├── src/
│ ├── WebApp/
│ │ └── WebApp.csproj
│ └── Services/
│ └── Services.csproj
└── tests/
└── WebApp.Tests/
└── WebApp.Tests.csproj
Create a workflow at .github/workflows/dotnet.yml:
name: .NET CI
on:
push:
branches:
- main
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: Restore dependencies
run: dotnet restore MyApplication.sln
- name: Build
run: dotnet build MyApplication.sln --configuration Release --no-restore
- name: Run tests
run: dotnet test MyApplication.sln --configuration Release --no-build
This example uses .NET 8 and a fixed major SDK line. Adjust the SDK version to match the target framework and SDK requirements of your repository.
The cache: true setting enables NuGet caching. The cache-dependency-path setting tells the action which dependency files should influence the cache key.
There is one important detail in this example: the workflow uses packages.lock.json files. If your repository does not commit package lock files, use a dependency path that matches the files you actually maintain, or add lock files as described below. Do not point the cache configuration at a pattern that matches no files.
Choose the Right Cache Dependency File
A cache key should change when the dependencies that matter to the restore process change. Otherwise, the workflow may reuse a cache that does not reflect the current dependency configuration.
There are two common approaches.
Use packages.lock.json
NuGet lock files record resolved package versions. They are useful when you want repeatable dependency restoration across developer machines and CI environments.
To generate lock files, run restore with lock-file generation enabled:
dotnet restore MyApplication.sln --use-lock-file
Review the generated packages.lock.json files and commit them to source control. Afterward, the workflow can use:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
cache: true
cache-dependency-path: '**/packages.lock.json'
When dependency versions change and the lock files are updated, the cache key changes accordingly.
For repositories with multiple projects, confirm that the glob pattern includes all relevant lock files. A lock file in a different directory or an unexpected project layout can otherwise lead to an incomplete dependency fingerprint.
Use project and dependency configuration files
If the repository does not use lock files, you can base a custom cache key on project files and central dependency configuration.
Typical files include:
.csproj and .fsproj files.
Directory.Packages.props.
Directory.Build.props and Directory.Build.targets, when they affect restore behavior.
NuGet.config.
global.json, when SDK selection affects the workflow.
The exact set depends on how the repository manages dependencies. A lock file is generally a stronger input for repeatable dependency resolution, while project-file hashing can be appropriate for repositories that do not use lock files.
Configure a Custom NuGet Cache
Sometimes you need more control over the cache location or cache key. In that case, use actions/cache directly instead of enabling the built-in cache in setup-dotnet.
Do not enable both caching approaches for the same NuGet package folder without a clear reason. Two overlapping caches add complexity and can make troubleshooting harder.
Here is an example using a custom cache:
name: .NET CI
on:
push:
branches:
- main
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4
- name: Set up .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('**/packages.lock.json') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore dependencies
run: dotnet restore MyApplication.sln
- name: Build
run: dotnet build MyApplication.sln --configuration Release --no-restore
- name: Run tests
run: dotnet test MyApplication.sln --configuration Release --no-build
This workflow caches the global NuGet packages folder.
The primary cache key includes the operating system and a hash of the lock files. When the lock files change, GitHub Actions looks for a cache with the new key. If there is no exact match, it can try the restore key prefix and reuse a compatible older cache.
NuGet still checks which packages are required and downloads any missing packages. Reusing an older cache does not mean that NuGet must use outdated dependency versions.
If your repository does not contain lock files, the hash expression above may not provide a useful dependency-specific key. Update it to hash the actual dependency configuration files in your repository, or introduce committed lock files.
How Cache Keys Affect Build Performance
The cache key determines when GitHub Actions can reuse a previously saved cache.
A key that changes too often reduces cache hits. A key that ignores relevant dependency changes can make the cache less predictable.
For example, consider this key:
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
It changes when a matching lock file changes. It does not change for every source-code edit, which is desirable because ordinary C# changes usually do not require downloading a different set of NuGet packages.
The operating system is included because package caches should not be assumed to be interchangeable across different environments.
Restore keys can improve cache reuse when the exact key is missing:
restore-keys: |
${{ runner.os }}-nuget-
However, a restore key is a fallback, not a guarantee of a complete cache. NuGet must still resolve the current dependency graph and obtain any missing packages.
Handle Multiple Projects and Solutions
Large repositories often contain several solutions or projects with different dependency requirements. The cache configuration must reflect the files used by the restore commands.
For example, a repository might have:
Repository/
├── Services.sln
├── WebApp.sln
├── src/
│ ├── Services/
│ └── WebApp/
└── tests/
If the workflow restores both solutions, make sure the dependency path includes the lock files for both. A narrow pattern that includes only one solution's projects may cause cache keys to ignore changes elsewhere.
When multiple workflows build the same repository, they can often share the same package cache configuration if they use compatible environments and dependency files.
Avoid adding every source file to the cache key. If ordinary source-code changes invalidate the NuGet cache, the workflow will rebuild the cache unnecessarily and lose many opportunities for reuse.
Common Problems and How to Fix Them
The cache is always missed
Check the workflow logs for cache restoration messages. Confirm that the dependency path matches files that exist in the repository and that the cache key does not include changing values such as the commit SHA.
For example, including ${{ github.sha }} in every cache key makes each commit use a different key, which is usually counterproductive for dependency caching.
NuGet still downloads packages
A cache hit does not guarantee that every required package is present. The dependency graph may have changed, the cache may have been restored through a fallback key, or the package folder may not match the active NuGet configuration.
Check the actual package directory used by the runner and confirm that the workflow is caching that location. Also inspect the restore output to see whether packages are being downloaded because they are missing or because a different package version is required.
Dependency changes do not appear to update the cache
Review the files used to calculate the key. If the repository uses central package management, hashing only project files may not capture changes in Directory.Packages.props.
If lock files are used, make sure they are updated and committed when dependencies change. Run restore in locked mode when appropriate for the repository's dependency-management policy so unexpected lock-file changes are detected.
The cache is large or frequently evicted
GitHub Actions caches have storage and retention limits. Old or infrequently used caches can be removed to make room for other caches.
Cache only what provides measurable value. The global NuGet package folder is usually a sensible starting point; caching build output directories such as bin and obj introduces additional invalidation and correctness concerns and is not a substitute for NuGet package caching.
Measure Whether Caching Helps
Do not assume that enabling caching automatically makes every build faster. Compare representative workflow runs before and after the change.
Track these measurements:
Time spent in dependency restore.
Total workflow duration.
Cache hit and miss frequency.
Package download volume.
Time spent compiling and running tests.
If restore takes only a few seconds but compilation takes several minutes, NuGet caching may have a limited effect on total build time. In that case, build parallelism, incremental compilation, test execution, or project structure may offer greater improvements.
Also compare clean-cache and warm-cache runs. A warm cache can make restore much faster, but the first run still needs to download the dependencies and populate the cache.
Conclusion
Caching NuGet packages in GitHub Actions reduces repeated downloads and can make .NET CI builds faster, particularly for solutions with many dependencies.
For most repositories, start with the built-in NuGet caching support in actions/setup-dotnet. Use committed lock files when repeatable dependency resolution is important, and ensure the cache dependency path matches the repository's actual structure.
Use a custom actions/cache configuration only when you need more control over cache paths or keys. Finally, measure restore time and cache hit rates to confirm that the change improves the workflow rather than adding unnecessary complexity.
Join the conversation! Your thoughts help the community grow.