If you build a .NET application using GitHub Actions, you may notice that every workflow run spends time downloading NuGet packages. This can make your CI pipeline slower, especially when your project has many dependencies.
For example, your application may use Entity Framework Core, logging libraries, testing packages, and several other NuGet dependencies. Downloading them again and again adds unnecessary work to every build.
GitHub Actions provides caching features that can help. By caching NuGet packages, you can reuse packages downloaded during earlier workflow runs instead of downloading them all over again.
In this article, you will learn how to cache NuGet packages in GitHub Actions, configure a workflow for a .NET project, and avoid common mistakes that can make caching ineffective.
Why NuGet Package Caching Matters
When you build a .NET project, the dotnet restore command downloads the packages required by your project.
For example, a project file might contain these dependencies:
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore"
Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer"
Version="9.0.0" />
<PackageReference Include="xunit"
Version="2.9.2" />
</ItemGroup>These packages are stored in the NuGet global packages folder on the build machine.
On a local development machine, the packages often remain available between builds. As a result, restoring the same dependencies usually requires less work after the first run.
GitHub-hosted runners are different. Each new job normally starts on a fresh virtual machine, so packages downloaded during one job are not automatically available to the next job.
Without caching, a workflow may repeatedly download the same dependencies.
Caching lets GitHub Actions save the package folder and restore it in a later run when the cache key matches.
The result can be faster dependency restoration and shorter build times.
How NuGet Caching Works in GitHub Actions
The process is straightforward:
GitHub Actions checks whether a matching cache exists.
If a cache is available, the runner restores the cached NuGet packages.
The
dotnet restorecommand checks the project's dependencies and downloads anything missing.The application is built and tested.
When the cache is saved, later workflow runs can reuse it.
Caching does not replace dotnet restore. The restore command still checks that the required packages are available and that the project can be restored correctly.
This is important because your dependencies may change when you update a package version or modify a project file.
A good cache configuration should reuse existing packages without preventing the build from getting the dependencies it actually needs.
Step 1: Find the NuGet Package Cache Location
By default, NuGet stores downloaded packages in the global packages folder.
On Windows, the default location is usually:
%USERPROFILE%\.nuget\packagesOn Linux and macOS, it is usually:
~/.nuget/packagesGitHub-hosted Ubuntu runners commonly use a home directory such as /home/runner, so the package cache is often located at:
/home/runner/.nuget/packagesHowever, you should avoid hardcoding this path in your workflow. The location can change if the runner environment or NuGet configuration changes.
Instead, use the NUGET_PACKAGES environment variable to define a consistent location for both package restoration and caching.
For example:
env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packagesThis places the package folder inside the GitHub workspace.
Using a known location makes it easier to configure the cache action. Make sure all relevant restore and build steps use the same environment setting.
Step 2: Create a GitHub Actions Workflow
Create a workflow file in your repository:
.github/workflows/dotnet.ymlThe following example restores, builds, and tests a .NET application while caching NuGet packages.
name: .NET CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ${{ env.NUGET_PACKAGES }}
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj', '**/*.props', '**/*.targets', 'global.json', 'NuGet.config') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Run tests
run: dotnet test --no-build --configuration ReleaseThis workflow is an example for a project using .NET 9. Change the SDK version and target framework to match your application.
Let's look at the important parts.
Set the package directory
env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packagesThis tells NuGet where to store the global package cache. The cache action uses the same path to save and restore packages.
Create a cache key
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj', '**/*.props', '**/*.targets', 'global.json', 'NuGet.config') }}The cache key identifies the dependency setup associated with the cached packages.
It includes the operating system and hashes of important dependency configuration files. When those files change, GitHub Actions generates a different key.
This helps prevent the workflow from relying entirely on a cache created for an older dependency setup.
Use a restore key
restore-keys: |
${{ runner.os }}-nuget-If an exact cache match is unavailable, GitHub Actions can try to restore a cache with a matching prefix.
This can be useful when only a few dependencies have changed. NuGet can then reuse packages that are already present and download any missing packages during restore.
Step 3: Keep Restore, Build, and Test Steps Separate
The workflow uses three commands:
dotnet restore
dotnet build --no-restore --configuration Release
dotnet test --no-build --configuration ReleaseEach command has a different purpose.
dotnet restorechecks the dependency graph and downloads missing packages.dotnet build --no-restorebuilds the application without repeating the restore step.dotnet test --no-buildruns the tests using the binaries produced by the build step.
Separating these steps makes it easier to identify whether a failure comes from dependency restoration, compilation, or testing.
It also avoids unnecessary repeated restore operations in this workflow.
If your test project needs a separate build configuration or the workflow builds multiple projects, adjust the commands accordingly.
Step 4: Improve Cache Keys with Package Lock Files
A cache key should change when the dependency setup changes.
Project files are useful inputs, but package versions can also be affected by central package management, NuGet configuration, and other build files.
For projects that need repeatable dependency resolution, consider using NuGet package lock files.
You can generate a lock file with:
dotnet restore --use-lock-fileThis creates a packages.lock.json file for supported project configurations. Commit the generated lock files to source control.
Then use locked mode in CI:
dotnet restore --locked-modeLocked mode checks that the dependency graph matches the committed lock file instead of silently updating it during restore.
This is useful when you want builds to use the same resolved dependency versions until someone deliberately updates them.
If you use central package management, include the relevant Directory.Packages.props file in the cache key. Also account for any other files that affect dependency resolution in your repository.
Caching and package locking solve different problems. Caching helps speed up restore operations, while lock files help make dependency resolution more predictable.
Step 5: Check Whether the Cache Is Working
After running the workflow, open the job logs and find the cache step.
A successful cache restore should indicate that GitHub Actions found a matching cache. If no matching cache exists, the workflow may report a cache miss and continue without restored packages.
That is normal during the first run.
A typical sequence looks like this:
First run: No matching cache exists, so NuGet downloads the required packages.
Later run: GitHub Actions restores a matching cache, reducing the number of packages that need to be downloaded.
Dependency update: A changed cache key may cause a cache miss, and the workflow creates a cache for the new dependency setup.
Compare the time taken by dotnet restore before and after caching. The improvement depends on the size of your dependency graph, network conditions, cache size, and how often the dependencies change.
Remember that caching NuGet packages does not cache every part of the build. Compilation, tests, SDK setup, and other workflow steps still take time.
Common Mistakes to Avoid
Mistake | Why it causes problems | Better approach |
|---|---|---|
Caching the wrong directory | NuGet cannot reuse the saved packages | Use the same package path for restore and caching |
Using a cache key that never changes | The cache may not reflect dependency changes well | Include dependency-related files in the key |
Assuming the cache replaces restore | Required packages may still be missing | Keep |
Forgetting central package configuration | Dependency changes may not affect the cache key | Include files such as |
Expecting the first build to be faster | No cache may exist yet | Compare later workflow runs |
Caching everything in the workspace | Unnecessary files increase cache size | Cache only the directories that provide value |
Best Practices for Faster .NET Builds
Follow these practices when setting up NuGet caching:
Cache the global packages folder. This is where NuGet keeps downloaded packages.
Use a dependency-aware cache key. Include the files that affect package resolution.
Use restore keys carefully. They can help reuse packages after small dependency changes.
Keep restore in CI. The cache is an optimization, not a replacement for dependency validation.
Consider lock files. They help keep dependency versions consistent across builds.
Measure the actual improvement. Compare restore time and total workflow duration before and after caching.
Keep the cache focused. Avoid storing large build outputs unless you have a separate reason to cache them.
Review workflow security. Be careful about allowing untrusted pull requests to access sensitive workflow data or caches. Never place credentials in a cache.
Conclusion
Caching NuGet packages in GitHub Actions is a simple way to reduce repeated dependency downloads and speed up .NET CI builds.
The basic setup is to choose a consistent NuGet package directory, cache it with actions/cache, and use a key based on the files that affect dependency resolution. Keep dotnet restore in the workflow so that NuGet can still check dependencies and download missing packages.
For larger projects, package lock files can also help make builds more repeatable. After enabling caching, compare restore times across several workflow runs to confirm that it is helping.
A well-configured cache will not make every build instant, but it can remove a lot of unnecessary work from your CI pipeline.

Join the conversation! Your thoughts help the community grow.