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:

HTML / XML
<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:

  1. GitHub Actions checks whether a matching cache exists.

  2. If a cache is available, the runner restores the cached NuGet packages.

  3. The dotnet restore command checks the project's dependencies and downloads anything missing.

  4. The application is built and tested.

  5. 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:

Code
%USERPROFILE%\.nuget\packages

On Linux and macOS, it is usually:

Code
~/.nuget/packages

GitHub-hosted Ubuntu runners commonly use a home directory such as /home/runner, so the package cache is often located at:

Code
/home/runner/.nuget/packages

However, 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:

YAML
env:
  NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages

This 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:

Code
.github/workflows/dotnet.yml

The following example restores, builds, and tests a .NET application while caching NuGet packages.

YAML
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 Release

This 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

YAML
env:
  NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages

This 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

YAML
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

YAML
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:

Bash
dotnet restore
dotnet build --no-restore --configuration Release
dotnet test --no-build --configuration Release

Each command has a different purpose.

  • dotnet restore checks the dependency graph and downloads missing packages.

  • dotnet build --no-restore builds the application without repeating the restore step.

  • dotnet test --no-build runs 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:

Bash
dotnet restore --use-lock-file

This creates a packages.lock.json file for supported project configurations. Commit the generated lock files to source control.

Then use locked mode in CI:

Bash
dotnet restore --locked-mode

Locked 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 dotnet restore in the workflow

Forgetting central package configuration

Dependency changes may not affect the cache key

Include files such as Directory.Packages.props

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:

  1. Cache the global packages folder. This is where NuGet keeps downloaded packages.

  2. Use a dependency-aware cache key. Include the files that affect package resolution.

  3. Use restore keys carefully. They can help reuse packages after small dependency changes.

  4. Keep restore in CI. The cache is an optimization, not a replacement for dependency validation.

  5. Consider lock files. They help keep dependency versions consistent across builds.

  6. Measure the actual improvement. Compare restore time and total workflow duration before and after caching.

  7. Keep the cache focused. Avoid storing large build outputs unless you have a separate reason to cache them.

  8. 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.