
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Slow builds are the silent killer of developer velocity, especially when your team is shipping multiple times a day. If you need to cache .NET dependencies in CI pipelines effectively, the solution lies in hashing your project files to create deterministic cache keys that survive branch switches but invalidate on dependency updates. This guide provides exact configurations for GitHub Actions and Azure DevOps that I have validated across production environments ranging from Kathmandu-based startups to global enterprise platforms.
*.csproj or packages.lock.json files as the primary cache key. Use the official setup-dotnet action in GitHub Actions or Cache@2 task in Azure DevOps, ensuring restore paths target the global-packages folder for maximum hit rates and build reproducibility.Before diving into YAML configurations, it helps to understand why generic caching fails for .NET ecosystems. Unlike Node.js where package-lock.json is always at the root, .NET projects often scatter dependencies across multiple solution folders. For teams managing complex architectures, aligning this with broader build pipeline automation best practices ensures you aren't just caching blindly but doing so with intent. The diagram below illustrates the decision flow that determines whether a runner downloads fresh packages or restores from a compressed archive.
How do you configure GitHub Actions to cache .NET dependencies correctly?
GitHub Actions remains the dominant platform for .NET teams in 2026, largely because the actions/setup-dotnet action has matured significantly. A common mistake I see in code reviews is relying solely on hashFiles('/*.csproj') without accounting for Directory.Packages.props or central package management (CPM). If your solution uses CPM, omitting that file from the hash means your cache will serve stale versions after a dependency bump, leading to phantom build failures.
Optimized GitHub Actions Workflow
The following configuration handles both standard and CPM-enabled projects. Note the explicit NUGET_PACKAGES environment variable; this forces NuGet to use a predictable path rather than the user-profile default, which varies between Linux and Windows runners.
name: .NET CI with Dependency Cache
on: [push, pull_request]
env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup .NET 9
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
cache: true
cache-dependency-path: |
/*.csproj
/Directory.Packages.props
/packages.lock.json
- name: Restore dependencies
run: dotnet restore --locked-mode
- name: Build
run: dotnet build --no-restore -c Release
- name: Test
run: dotnet test --no-build -c Release Setting --locked-mode during restore is non-negotiable for production-grade pipelines. It guarantees that the restore operation respects your lock file exactly, preventing accidental upgrades that bypass your cache key logic. This approach aligns with principles discussed in reproducible builds, ensuring that what you test is identical to what you deploy.
What is the best way to cache NuGet packages in Azure DevOps?
Azure DevOps requires a different mental model. While GitHub Actions integrates caching into the setup step, Azure separates concerns: you must explicitly define a Cache@2 task before your restore command. The critical detail here is the key syntax. Azure's cache task uses a pipe-delimited format where order matters. Always include the OS identifier and runtime version in the key to prevent cross-platform contamination when sharing agents.
Azure Pipelines Cache Configuration
- Key Composition: Combine static identifiers (
nuget,$(Agent.OS)) with dynamic hashes. - Path Precision: Point to
$(NUGET_PACKAGES)not~/.nuget. The tilde expansion behaves inconsistently in YAML pipelines. - Restore Condition: Use
cacheHitVarto skip restore entirely on exact matches, saving 15-30 seconds even before network calls.
variables:
NUGET_PACKAGES: $(Pipeline.Workspace)/.nuget/packages
CACHE_KEY: 'nuget | "$(Agent.OS)" | /*.csproj | **/Directory.Packages.props'
steps:
- task: Cache@2
displayName: Cache NuGet packages
inputs:
key: $(CACHE_KEY)
restoreKeys: |
nuget | "$(Agent.OS)"
path: $(NUGET_PACKAGES)
cacheHitVar: CACHE_RESTORED
- script: dotnet restore --locked-mode
displayName: Restore NuGet packages
condition: ne(variables.CACHE_RESTORED, 'true')
- script: dotnet build --no-restore -c Release
displayName: Build solution In my experience auditing SOC 2 compliance for fintech clients, this explicit separation actually helps. Auditors prefer seeing distinct cache and restore steps because it makes the supply chain provenance clearer. When you can demonstrate that cached artifacts are derived solely from committed lock files, you reduce the attack surface for dependency confusion attacks.
Why should you use packages.lock.json over csproj hashing?
Hashing *.csproj files works for simple solutions, but it breaks down in monorepos or when using transitive dependency pinning. The packages.lock.json file captures the entire resolved dependency graph, including transitive versions that never appear in your project file. This distinction matters because two builds with identical .csproj content can resolve different transitive versions if an upstream package was republished or if floating version ranges were used.
To enable lock files, add <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> to your Directory.Build.props. Commit the generated JSON files to source control. In CI, always pair this with --locked-mode. Without locked mode, NuGet may silently update the lock file during restore, defeating the purpose of caching and introducing non-determinism that makes debugging production incidents nearly impossible.
How do caching strategies compare across CI platforms for .NET?
Choosing the right platform-specific implementation prevents subtle bugs. Each CI system handles cache eviction, compression, and restoration differently. Understanding these differences saves hours of troubleshooting when builds pass locally but fail in the pipeline.
| Feature | GitHub Actions | Azure DevOps | GitLab CI |
|---|---|---|---|
| Setup Integration | Built into setup-dotnet | Separate Cache@2 task | Manual cache: key config |
| Max Cache Size | 10 GB per repo | 2 GB per pipeline | Runner-dependent |
| Eviction Policy | LRU after 7 days unused | FIFO when limit reached | Configurable per job |
| Cross-Branch Sharing | Default branch only | All branches (scoped) | Prefix-based fallback |
| Compression | Zstandard (automatic) | Tar/Gzip | Zip/Tar (configurable) |
| Best For | OSS & cloud-native teams | Enterprise Microsoft shops | Self-hosted infrastructure |
For teams operating hybrid environments or migrating between platforms, note that cache formats are not portable. You cannot copy a GitHub Actions cache artifact into Azure DevOps. Plan for a cold-cache period during migrations. Also, GitLab's self-hosted runners give you full control over cache storage backends (S3, GCS, local), which matters for data residency requirements in regulated industries. If you're evaluating platforms, review GitHub Actions vs Azure Pipelines comparison for deeper architectural trade-offs beyond caching.
What security risks exist when caching dependencies in CI?
Caching introduces a trust boundary that attackers can exploit. The most significant risk is cache poisoning: if an attacker compromises your CI environment or injects a malicious package reference, they can persist malware in the cache long after the initial vector is patched. Every subsequent build restoring from that poisoned cache inherits the compromise.
Mitigate this through three controls:
- Immutable Lock Files: Never allow CI to modify lock files. Treat them as source code requiring PR approval.
- Scoped Cache Keys: Include the branch name or PR number in development caches to isolate untrusted code from main branch caches.
- Periodic Cold Builds: Schedule weekly builds with caching disabled to verify that restored packages match registry contents. Alert on discrepancies.
In high-security contexts, consider signing your lock files or using NuGet's package signature verification during restore. This adds latency but ensures that even a poisoned cache cannot serve tampered binaries. Remember that caching optimizes speed, but security must never be traded for seconds saved. For teams handling sensitive data, integrating these checks early supports shifting security left without sacrificing developer experience.
Implementing Safe and Fast .NET Dependency Caching
Getting dependency caching right transforms your CI from a bottleneck into an accelerator. Start by enabling packages.lock.json across your solution today, then apply the platform-specific configurations above. Monitor your cache hit rates in the CI logs; anything below 80% on feature branches indicates a key generation problem worth investigating. If your team needs help optimizing build performance or hardening pipeline security for compliance audits, reach out to discuss your specific architecture.