Cache .NET Dependencies in CI Pipelines

Khimananda Oli 8 min read Programming and Languages
Cache .NET Dependencies in CI Pipelines

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.

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.

CI Job StartsGenerate Hash Key(/*.csproj)Match?YesRestore from CacheSkip Network DownloadNoNuGet Restore + SaveDownload & Compress
Cache lookup flow for .NET dependencies: hash matching determines restore source vs. fresh download

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 cacheHitVar to 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.

*.csproj Hash OnlyDirect Refs OnlyMisses TransitiveRisk: Stale Transitive Depspackages.lock.jsonFull Graph SnapshotExact Versions PinnedSafe: Deterministic RestoresRecommendation MatrixSmall App (<10 deps)✓ csproj OKMonorepo / Shared Libs✗ Use lock.jsonCompliance / Audit✗ Mandatory lock.json
Decision matrix: when to use csproj hashing versus packages.lock.json for safe .NET dependency caching

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.

FeatureGitHub ActionsAzure DevOpsGitLab CI
Setup IntegrationBuilt into setup-dotnetSeparate Cache@2 taskManual cache: key config
Max Cache Size10 GB per repo2 GB per pipelineRunner-dependent
Eviction PolicyLRU after 7 days unusedFIFO when limit reachedConfigurable per job
Cross-Branch SharingDefault branch onlyAll branches (scoped)Prefix-based fallback
CompressionZstandard (automatic)Tar/GzipZip/Tar (configurable)
Best ForOSS & cloud-native teamsEnterprise Microsoft shopsSelf-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:

  1. Immutable Lock Files: Never allow CI to modify lock files. Treat them as source code requiring PR approval.
  2. Scoped Cache Keys: Include the branch name or PR number in development caches to isolate untrusted code from main branch caches.
  3. 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.

Threat: Cache Poisoning VectorMalicious package injected → persisted in shared cache → propagated to all buildsLayer 1: Locked Mode--locked-mode flagPrevents silent updatesFails on mismatchLayer 2: Scoped KeysBranch/PR isolationUntrusted code sandboxedMain cache protectedLayer 3: Cold VerificationWeekly no-cache buildCompare against registryAlert on driftResult: Trusted Cache StateCached packages verified • Supply chain integrity maintained • Build speed preserved
Defense-in-depth model for securing .NET dependency caches against supply chain attacks

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.

Frequently Asked Questions

Use the actions/cache action targeting the ~/.nuget/packages path with a hash of your csproj files as the key to restore dependencies only when project references change.

Hash your Directory.Packages.props or csproj files using hashFiles to generate deterministic keys that invalidate automatically when package versions update, preventing stale artifact restoration during builds.

Yes. Restoring cached NuGet packages typically cuts restore steps from minutes to seconds, reducing total pipeline duration by thirty to fifty percent on average for medium-sized solutions.

No. The local cache exists only within ephemeral runner containers and persists nothing between jobs. You must use platform-specific caching mechanisms like actions/cache or Azure Pipelines Cache task.

Include the dotnet-version output or global.json hash in your cache key prefix so upgrading the SDK automatically creates a new cache entry without manual intervention or stale package conflicts.

Never cache credentials directly. Configure authenticated feeds via environment variables or service connections, and cache only the package binaries which contain no sensitive authentication tokens or secrets.

Check path casing sensitivity on Linux runners and verify hashFiles returns a value. Empty hashes create invalid keys causing silent misses. Add restore-keys fallbacks for partial match recovery.

GitHub Actions enforces a 10GB repository cache limit. Large monorepos should scope caches per solution or use restore-keys hierarchies to evict older entries automatically when approaching storage quotas.

No. Only cache ~/.nuget/packages. Build outputs in obj and bin are non-deterministic across runners and cause compilation errors. Let the build step regenerate artifacts fresh each run.

Azure uses explicit key and restoreKeys inputs with built-in .NET templates, while GitHub requires manual path configuration. Both support scoped caching but Azure integrates natively with Artifacts feeds.

Yes. Caches are scoped to repository and branch by default. Use identical key generation logic across workflows to enable cross-file sharing, but note that main branch caches propagate to feature branches.

Corrupted archives cause restore failures. Implement integrity checks by validating package hashes post-restore, and configure automatic cache deletion via API or UI when detecting persistent extraction errors in logs.

Less than hosted runners since persistent filesystems retain packages naturally. However, explicit caching still helps when rotating runner pools or using containerized self-hosted agents with ephemeral storage layers.

Enable verbose logging for the cache action, inspect computed key values in output, and compare against previous successful runs to identify unintended hash changes or path misconfigurations causing misses.

GitHub includes 10GB free per repository. Azure Pipelines offers 2GB free tier. Exceeding limits incurs charges or requires cleanup policies. Monitor usage dashboards regularly to avoid unexpected billing surprises.