Caching NuGet Packages on GitHub Actions
Cache the NuGet global packages folder, which dies with the runner on every GitHub Actions run. Use WarpBuilds/setup-dotnet on Linux, actions/cache on Windows.
Last verified:
NuGet restore repeats its work on every GitHub Actions run because the global packages folder lives on the runner, and the runner is destroyed when the job ends. Cache that folder: on Linux the documented path is WarpBuilds/setup-dotnet@v4 with cache: true and cache-dependency-path pointed at packages.lock.json, and on Windows, where WarpBuild caching is unsupported, keep actions/cache@v4 against GitHub's own Actions cache service.
.NET solutions routinely build on two of them in the same workflow. This guide covers how to measure where restore time goes, the workflow changes for each platform, the cache keys that hold up under a matrix, and what the change does to the monthly bill at real runner rates. For the surrounding pipeline, see running .NET builds on GitHub Actions.
Diagnosis
Open a recent run and read the duration GitHub prints next to the restore step. Three separate causes hide behind that one number, and they respond to different fixes, so measure all three before editing the workflow.
The global packages folder starts cold on every run
NuGet resolves a package by looking for it in the global packages folder first, then downloading the .nupkg from a feed and extracting it into a versioned directory under that folder. The folder is ~/.nuget/packages on Linux and %UserProfile%\.nuget\packages on Windows. Nothing in a GitHub Actions job preserves it between runs.
Confirm the path and the working set from inside a job:
dotnet nuget locals global-packages --list
du -sh ~/.nuget/packages
find ~/.nuget/packages -mindepth 2 -maxdepth 2 -type d | wc -lThe last command counts extracted package versions. That count, and the size of the folder, are the two inputs to every estimate later on this page.
Without a lock file, restore resolves the whole graph on every run
With no packages.lock.json committed, dotnet restore walks every PackageReference, asks each configured feed which versions satisfy each range, and builds the transitive closure before it downloads anything. Version ranges and floating versions make that a live negotiation with the feed rather than a lookup, so a slow or distant feed shows up as restore time even when most packages are already local.
The lock file also gates the cache. Caching in the setup-dotnet action is off by default and depends on a NuGet lock file, so a repository with no packages.lock.json has no stable hash to key an entry on. Generating the lock file is therefore the first fix rather than an optional hardening step.
The transitive closure is larger than the dependency list you wrote
Teams size the problem from the PackageReference entries they can see in the project files. The number that matters is the closure. Print it:
dotnet list package --include-transitiveA solution with 40 direct references commonly expands past 400 resolved packages once analyzers, ASP.NET Core metapackages, test frameworks, and cloud SDKs pull in their own trees. Every one of those is a separate download and a separate extraction into thousands of small files.
That last detail is why the same solution restores more slowly on Windows than on Linux. Extraction is dominated by file creation, NTFS charges more metadata work per file than ext4, and Microsoft Defender real-time scanning inspects each file on the way to disk. The Windows runner page covers the image and size choices for that platform.
Separating a network-bound restore from a CPU-bound build is where WarpBuild CI observability helps. It sits alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger in the product surface, and it correlates runner system metrics with job logs, so a restore step with heavy network and idle CPU is visibly a different problem from a build step pinned on one core.
Fix
Three changes, in this order. The first is a prerequisite for the other two.
1. Commit lock files. Add this to Directory.Build.props at the repository root so every project in the solution opts in:
<Project>
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>Run dotnet restore locally, then commit the packages.lock.json file that appears next to each project file. From then on, GitHub Actions runs dotnet restore --locked-mode, which fails the job when the lock file disagrees with the project files instead of silently resolving a new graph.
2. On Linux runners, swap the setup action. WarpBuild maintains forks of the popular setup-* actions that accept the same inputs and outputs and route caching through WarpBuild Cache. On a WarpBuild runner they use it automatically, with no extra configuration:
- uses: actions/setup-dotnet@v4
+ uses: WarpBuilds/setup-dotnet@v4
with:
dotnet-version: '8.0'
cache: true
cache-dependency-path: '**/packages.lock.json'Two inputs carry the whole change. cache defaults to false for .NET, so it has to be set explicitly. cache-dependency-path points at the lock files when they are anywhere other than the repository root, which in a multi-project solution they always are.
3. On Windows runners, use the GitHub cache service. The caching documentation states plainly that WarpBuild caching is not supported for Windows-based runners, while it is enabled by default on Linux runners. The honest path on Windows is actions/setup-dotnet@v4 plus actions/cache@v4 keyed on the same lock file hash. Entries land in GitHub's Actions cache service, which works from a WarpBuild Windows runner with no extra setup. The answer page on Windows cache support covers the boundary in more detail.
Nothing is lost by splitting the backends, because the two platforms could never have shared an entry anyway. A WarpBuild cache entry is scoped to its key, its version, and its branch, and the version is a hash over the compression tool and the cached paths. Change the operating system and the version changes, so a Linux entry never restores on a Windows job.
Configuration
Here is the whole workflow, with both platforms in one file:
name: build
on:
pull_request:
push:
branches: [main]
jobs:
linux:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v5
- uses: WarpBuilds/setup-dotnet@v4
with:
dotnet-version: '8.0'
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: Restore
run: dotnet restore --locked-mode
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Test
run: dotnet test --no-build --configuration Release
windows:
runs-on: warp-windows-latest-x64-8x
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0'
- 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
run: dotnet restore --locked-mode
- name: Build
run: dotnet build --no-restore --configuration ReleaseKey design
Hash the lock files and nothing else. hashFiles('**/packages.lock.json') changes when a dependency changes and stays stable when application code changes, which is the behavior you want from a package cache. Adding the commit SHA to the key produces a miss on every run and a new entry on every run, which pays storage for entries that are read once.
Keep one restore-keys prefix so a lock file bump falls back to yesterday's entry instead of to nothing. A partial hit is safe under --locked-mode: restore fills the missing packages from the feed and fails only when the lock file itself is stale relative to the project files.
Pin the packages folder when the job runs in a container
Set NUGET_PACKAGES at the job level when the restore and the build run in different contexts, such as a container step followed by a host step:
env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packagesThis makes the cached path identical everywhere in the job. Containers matter for a second reason: the cache client falls back from zstd to gzip when zstd is missing inside a container, and an entry saved with one compression tool does not restore where the other is in use.
Expiry, fees, and concurrency
A cache entry expires 7 days after its last use, and it can be deleted at any time from the action or from the console. On hosted runners, cache storage bills at $0.20 per GB-month and every write or restore bills at $0.0001 per operation. On BYOC runners, cache storage and operations are included.
Fan the workflow out across target frameworks or test shards. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a plan limit does not serialize a 12-leg matrix.
Restore caching removes the download and extraction work. Compilation is the next line item on the same job, and the guide to MSBuild caching on GitHub Actions covers the obj and bin side of the problem.
Cost or Time Model
Prices first. GitHub list prices come from the GitHub Actions billing reference, checked on 2026-08-13. WarpBuild rates come from the pricing page and the cloud runners documentation.
| Shape | WarpBuild label | WarpBuild per minute | GitHub-hosted per minute | Lower list price |
|---|---|---|---|---|
| 2 vCPU, 8 GB Linux | warp-ubuntu-latest-x64-2x | $0.004 | $0.006 (ubuntu-latest, private repositories) | 33 percent |
| 4 vCPU, 16 GB Linux | warp-ubuntu-latest-x64-4x | $0.008 | $0.012 (4-core Linux larger runner) | 33 percent |
| 8 vCPU, 32 GB Linux | warp-ubuntu-latest-x64-8x | $0.016 | $0.022 (8-core Linux larger runner) | 27 percent |
| 16 vCPU, 64 GB Linux | warp-ubuntu-latest-x64-16x | $0.032 | $0.042 (16-core Linux larger runner) | 24 percent |
| 32 vCPU, 128 GB Linux | warp-ubuntu-latest-x64-32x | $0.064 | $0.082 (32-core Linux larger runner) | 22 percent |
| 4 vCPU, 16 GB Windows | warp-windows-latest-x64-4x | $0.016 | $0.022 (4-core Windows larger runner) | 27 percent |
| 8 vCPU, 32 GB Windows | warp-windows-latest-x64-8x | $0.032 | $0.042 (8-core Windows larger runner) | 24 percent |
| 16 vCPU, 64 GB Windows | warp-windows-latest-x64-16x | $0.064 | $0.082 (16-core Windows larger runner) | 22 percent |
| 32 vCPU, 128 GB Windows | warp-windows-latest-x64-32x | $0.128 | $0.162 (32-core Windows larger runner) | 21 percent |
Windows shapes start at 4 vCPU because Windows 2 vCPU runners were removed on June 8, 2026.
Assumptions
Substitute your own numbers from the counts you collected in the diagnosis section.
- A solution whose closure resolves 420 packages, extracting to 1.1 GB in the global packages folder and compressing to a 380 MB cache entry.
- 30 workflow runs per weekday over 22 weekdays, so 660 runs per month per platform.
- Linux jobs on
warp-ubuntu-latest-x64-8x, Windows jobs onwarp-windows-latest-x64-8x. - Cold restore, meaning download and extract all 420 packages: 2.50 minutes on Linux, 4.00 minutes on Windows, where extraction writes more slowly.
- Cached restore, meaning entry download, decompression, and a locked-mode restore against a populated folder: 1.00 minute on Linux, 1.50 minutes on Windows.
- The lock file changes on one run in ten, so a save runs 66 times per month and costs 0.50 minutes on Linux and 1.50 minutes on Windows, which amortizes to 0.05 and 0.15 minutes per run.
Restore minutes and monthly cost
| Path | Restore minutes per run | Monthly runner minutes | Monthly cost |
|---|---|---|---|
| Linux, no cache | 2.50 | 1,650 | $26.40 |
| Linux, cached | 1.05 | 693 | $11.09 |
| Windows, no cache | 4.00 | 2,640 | $84.48 |
| Windows, cached | 1.65 | 1,089 | $34.85 |
Add the cache fees on the Linux path: 0.38 GB of storage at $0.20 per GB-month is $0.08, and 726 operations at $0.0001 each is $0.07. The cached Linux path lands at $11.24 per month against $26.40 uncached, a difference of $15.16. Windows entries live in GitHub's cache service, so the cached Windows path carries no WarpBuild cache fee and lands at $34.85 against $84.48 uncached.
The same workload at GitHub-hosted list prices
Hold the cached minute counts fixed and reprice them at the GitHub-hosted rates for the identical shapes:
| Path | Monthly runner minutes | WarpBuild | GitHub-hosted | Lower list price |
|---|---|---|---|---|
| Linux, cached, 8 vCPU and 32 GB | 693 | $11.09 | $15.25 | 27 percent |
| Windows, cached, 8 vCPU and 32 GB | 1,089 | $34.85 | $45.74 | 24 percent |
Two levers stack here, and it helps to keep them separate when you build the case internally. Caching removes minutes, and the list price sets what each remaining minute costs. Restore caching is the larger lever on this workload, and it is the one you control entirely from the workflow file.
FAQ
How do I cache NuGet packages on GitHub Actions?
Cache the global packages folder, which is ~/.nuget/packages on Linux and %UserProfile%\.nuget\packages on Windows. On WarpBuild Linux runners, use WarpBuilds/setup-dotnet@v4 with cache set to true and cache-dependency-path pointed at packages.lock.json. On Windows runners, use actions/cache@v4 keyed on the same lock file, because WarpBuild caching is documented as unsupported on Windows-based runners.
Why does NuGet caching in setup-dotnet require packages.lock.json?
The cache key is a hash of the dependency file, and NuGet has no single lock file by default. Set RestorePackagesWithLockFile to true in Directory.Build.props, run dotnet restore once, and commit the generated packages.lock.json files. Caching in setup-dotnet is off by default and stays off until both the cache input and a lock file are present.
Does WarpBuild caching work on Windows runners?
No. The caching documentation states that WarpBuild caching is not supported for Windows-based runners. It is enabled by default on Linux runners. On a WarpBuild Windows runner, use actions/cache@v4, which stores entries in GitHub's own Actions cache service and needs no extra configuration.
How long does a NuGet cache entry live, and what does it cost?
A WarpBuild cache entry expires 7 days after its last use and can be deleted at any time from the action or the console. On hosted runners, cache storage bills at $0.20 per GB-month and each write or restore operation bills at $0.0001. On BYOC runners, cache storage and operations are included.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.