A build that works on a developer's machine but fails in CI is more than an inconvenience. It is often a sign that the build environment is resolving dependencies differently.

.NET applications can depend on hundreds of direct and transitive NuGet packages. If those dependencies are allowed to change during restore, the same source code can potentially produce a different dependency graph at a later point in time.

That creates problems for:

A reproducible build starts with a simple principle:

The source code, dependency graph, SDK, and build configuration should be explicit enough that the same inputs produce the same dependency resolution.

For .NET projects, NuGet lock files can be an important part of that strategy.

This article explains how locked dependency graphs work, how to enable them, how to enforce them in CI, and where teams commonly get into trouble.

Why Dependency Reproducibility Matters

Consider a project with this dependency:

<ItemGroup>
    <PackageReference Include="Example.Data.Client"
                      Version="5.2.0" />
</ItemGroup>

That direct dependency may itself depend on:

Example.Data.Client
    |
    +-- Example.Core
    |
    +-- Example.Serialization
    |
    +-- Example.Logging

Those transitive dependencies become part of the actual application dependency graph.

If a transitive dependency is resolved differently later, the application may effectively be built with different inputs even though the Git commit has not changed.

The important distinction is:

Source code unchanged
        ≠
Dependency graph unchanged

A reproducible build needs both.

What Is a Locked Dependency Graph?

A NuGet lock file records information about the resolved dependency graph.

For a project, the lock file is commonly named:

packages.lock.json

A simplified example looks like:

{
  "version": 1,
  "dependencies": {
    "net10.0": {
      "Example.Data.Client": {
        "type": "Direct",
        "requested": "[5.2.0, )",
        "resolved": "5.2.0",
        "contentHash": "..."
      }
    }
  }
}

The actual file contains considerably more information.

The important idea is that restore has a recorded dependency resolution that can be checked during future restores.

Instead of asking:

"What dependency versions can I resolve today?"

the build can ask:

"Can I restore exactly the dependency graph that was previously recorded?"

That distinction is valuable in CI and production release pipelines.

Enable NuGet Lock Files

Lock-file behavior can be enabled through MSBuild properties.

For example:

<PropertyGroup>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>

After restoring the project, NuGet can generate:

packages.lock.json

The lock file should normally be committed to source control when the repository uses locked dependency resolution as a build policy.

A typical project structure becomes:

MyApplication/
├── MyApplication.csproj
├── Program.cs
├── NuGet.config
├── packages.lock.json
└── ...

The lock file is not a replacement for the project file.

The project file defines the application's package requirements.

The lock file records the resolved dependency graph.

Restore in Locked Mode

Generating a lock file is only one part of the process.

CI must also enforce it.

Use:

dotnet restore --locked-mode

Locked mode tells NuGet that the existing lock file must be honored.

If the dependency graph would need to change, restore fails instead of silently updating the lock file.

Conceptually:

Project files
     |
     v
Existing lock file
     |
     v
Can current restore match it?
     |
   +---+---+
   |       |
  Yes      No
   |       |
 Build    Fail

This is exactly the behavior desired in a reproducible CI build.

Why Normal Restore Is Different

A normal restore can update dependency resolution when the project's dependency requirements change or when the resolved graph no longer matches the current project state.

That behavior is useful during development.

It is less desirable for a release pipeline that expects dependency resolution to be controlled.

A useful development workflow is:

Developer changes dependency
        |
        v
Normal restore
        |
        v
Lock file updated
        |
        v
Review dependency changes
        |
        v
Commit project + lock file

The CI workflow then becomes:

Git commit
    |
    v
dotnet restore --locked-mode
    |
    +-- Match --> Continue
    |
    +-- Mismatch --> Fail

This separates dependency modification from dependency consumption.

Use a Deterministic SDK Version

A lock file does not solve every reproducibility problem.

The .NET SDK itself can affect the build.

For example, a repository can use global.json to specify the SDK version.

{
  "sdk": {
    "version": "10.0.100",
    "rollForward": "latestPatch"
  }
}

The exact SDK version should be selected according to the application's supported toolchain.

The important principle is to avoid accidentally building the same commit with materially different SDK versions.

Think of reproducibility as multiple layers:

Source Code
     +
.NET SDK
     +
NuGet Configuration
     +
Package Sources
     +
Dependency Graph
     +
Build Configuration
     =
Build Inputs

Locking only the NuGet graph leaves other variables uncontrolled.

Check the Lock File Into Source Control

If a repository uses locked dependency resolution, the lock file should generally be version-controlled.

For example:

git add packages.lock.json
git commit -m "Lock NuGet dependency graph"

Do not add it to .gitignore simply because it is generated.

Generated does not always mean disposable.

In this case, the generated file represents an important build input.

Review Lock File Changes Like Code Changes

A pull request that changes:

packages.lock.json

should receive dependency-focused review.

For example:

Example.Core
5.1.0 -> 5.2.0

Example.Serialization
3.4.1 -> 3.5.0

New transitive dependency
Example.Security.Extensions

A reviewer should ask:

The lock file therefore becomes useful not only for reproducibility but also for dependency governance.

Central Package Management and Lock Files

Large repositories may centralize package versions.

For example:

<ItemGroup>
    <PackageVersion Include="Example.Core"
                    Version="5.2.0" />

    <PackageVersion Include="Example.Data"
                    Version="4.1.0" />
</ItemGroup>

This can simplify version management across many projects.

However, centralized package versions and lock files solve different problems.

Central package management answers:

"What version should this package request?"

The lock file answers:

"What dependency graph was actually resolved?"

Using both can provide stronger control.

Multiple Target Frameworks Need Attention

A project may target multiple frameworks:

<TargetFrameworks>
    net10.0;net8.0
</TargetFrameworks>

Dependency resolution can differ between target frameworks.

For example:

net10.0
   |
   +-- Package A 5.x

net8.0
   |
   +-- Package A 4.x

Do not assume that one target framework's dependency graph represents the entire project.

The lock file can contain framework-specific resolution information.

When reviewing changes, consider every target framework used by the application.

Package Sources Are Part of Reproducibility

A lock file is not a complete solution if package sources are uncontrolled.

For example:

<packageSources>
    <clear />

    <add key="CompanyFeed"
         value="https://packages.company.local/nuget/v3/index.json" />

    <add key="nuget.org"
         value="https://api.nuget.org/v3/index.json" />
</packageSources>

The repository should explicitly define approved package sources where reproducibility and supply-chain security are important.

Otherwise, two environments could use different NuGet configuration and potentially resolve dependencies differently.

A stronger build model is:

Known SDK
   +
Known NuGet configuration
   +
Known package sources
   +
Locked dependency graph

Combine Locking With Package Source Mapping

For enterprise repositories, package source mapping can add another layer of control.

For example:

<packageSourceMapping>

  <packageSource key="CompanyFeed">
    <package pattern="Company.*" />
  </packageSource>

  <packageSource key="nuget.org">
    <package pattern="*" />
  </packageSource>

</packageSourceMapping>

This creates an explicit relationship between package identities and package sources.

The combination is useful:

Lock file
   |
   v
Expected dependency graph

Source mapping
   |
   v
Expected package provenance

Reproducibility and supply-chain security therefore reinforce each other.

A CI Pipeline Example

A basic CI pipeline can perform restore in locked mode before building.

For example:

steps:
  - name: Restore
    run: dotnet restore --locked-mode

  - name: Build
    run: dotnet build --no-restore --configuration Release

  - name: Test
    run: dotnet test --no-restore --configuration Release

The important detail is:

restore
   |
   v
build --no-restore
   |
   v
test --no-restore

The build and test stages do not unexpectedly perform another dependency restore.

That makes the pipeline's dependency boundary easier to understand.

Fail Fast on Lock File Drift

Suppose a developer changes:

<PackageReference Include="Example.Core"
                  Version="6.0.0" />

but forgets to regenerate the lock file.

CI runs:

dotnet restore --locked-mode

and detects that the existing lock file does not represent the current project dependency requirements.

The build should fail.

That is a good failure.

It forces the dependency change to be explicit.

The developer can then regenerate the lock file, inspect the resulting changes, and commit both files.

Updating Dependencies Safely

Dependency updates should be intentional.

A practical workflow is:

1. Change PackageReference
2. Restore normally
3. Update lock file
4. Review dependency changes
5. Run tests
6. Run security checks
7. Commit project + lock file
8. CI restores with --locked-mode

This gives dependency changes a clear audit trail.

Detecting Unexpected Dependency Changes

A locked graph also makes unexpected changes easier to detect.

Suppose a pull request modifies only:

OrderService.cs

but also changes:

packages.lock.json

That deserves attention.

It may be legitimate, but the dependency change should have an explanation.

A CI policy can flag dependency changes when:

No package-related project file changed
        |
        v
Lock file changed
        |
        v
Review required

This is particularly useful in large repositories.

Reproducibility Does Not Mean Bit-for-Bit Reproducible

There is an important distinction between:

Dependency reproducibility

and

Complete binary reproducibility.

A lock file primarily helps control dependency resolution.

It does not automatically guarantee that every build produces byte-for-byte identical binaries.

Other variables can affect the final artifact, including:

Therefore, describe the lock file accurately.

It helps make dependency resolution reproducible.

It is one component of a broader reproducible-build strategy.

Common Mistakes

Generating a Lock File but Not Enforcing It

This is probably the most common mistake.

If CI simply runs:

dotnet restore

the lock file is not necessarily being treated as a strict dependency contract.

Use locked mode where enforcement is required.

Ignoring the Lock File

If the lock file is continuously regenerated and committed without review, teams lose one of its major benefits.

Treat dependency changes as meaningful changes.

Forgetting Transitive Dependencies

A direct package update can introduce many new packages.

Review the complete graph.

Ignoring Multiple Target Frameworks

A package can resolve differently for different target frameworks.

Check all supported frameworks.

Assuming Lock Files Prevent Malicious Packages

They do not.

A lock file can make dependency changes visible and controlled, but it does not independently establish package trust.

Combine it with source controls and security scanning.

Using Different NuGet Configurations in CI

If developers and CI restore against different feeds or mapping rules, reproducibility becomes difficult.

Keep the restore environment explicit.

Troubleshooting Locked Restore Failures

A locked restore failure generally means the current project state does not match the recorded dependency graph.

Start with:

dotnet restore --locked-mode

If it fails, check:

  1. Was a PackageReference changed?

  2. Was a package version changed centrally?

  3. Was a target framework added or removed?

  4. Did NuGet.config change?

  5. Did package source mapping change?

  6. Was the lock file generated using a different dependency configuration?

  7. Is the correct .NET SDK being used?

If the dependency change is intentional, regenerate the lock file using the normal restore process and review the resulting diff.

Do not simply delete the lock file to make CI pass.

That defeats the purpose of the control.

A Practical Reproducibility Checklist

For a production .NET repository, verify:

[ ] SDK version is controlled
[ ] NuGet sources are explicit
[ ] Internal package sources are controlled
[ ] Package source mapping is defined where appropriate
[ ] Lock files are generated
[ ] Lock files are committed
[ ] CI uses --locked-mode
[ ] Build uses --no-restore after restore
[ ] Test uses the same restored graph
[ ] Dependency changes are reviewed
[ ] Vulnerability scanning is enabled
[ ] Multiple target frameworks are considered

This checklist provides a strong baseline without requiring an overly complicated build system.

Frequently Asked Questions

Does packages.lock.json lock direct package versions?

It records resolved dependency information, including direct and transitive dependencies. The exact contents depend on the project's target frameworks and dependency graph.

Should every .NET project use a lock file?

Not necessarily. The right approach depends on the repository, application lifecycle, and dependency-management strategy. Lock files become particularly valuable when deterministic dependency resolution is important.

Should the lock file be committed to Git?

If the repository uses NuGet locked mode as part of its build policy, yes. The lock file needs to be available to CI and should be reviewed as part of dependency changes.

Can I still update packages?

Yes. The normal workflow is to intentionally update the package requirement, regenerate the lock file, review the dependency graph, and then commit the resulting changes.

Does a lock file improve security?

It can improve dependency governance and make unexpected dependency changes easier to detect, but it is not a complete security control. Package-source restrictions, vulnerability scanning, package provenance, and CI hardening are still important.

Does locked restore guarantee identical application binaries?

No. Locked restore primarily controls dependency resolution. Complete reproducibility involves additional build inputs such as the SDK, toolchain, build configuration, and environment.

Conclusion

Reproducible .NET builds begin by making dependency resolution an explicit build input rather than an implicit result of whatever NuGet happens to resolve at build time. A committed packages.lock.json combined with dotnet restore --locked-mode gives CI a clear contract: if the dependency graph changes, the build should know about it.

For enterprise applications, the strongest approach goes beyond the lock file. Control the .NET SDK, package sources, source mapping, dependency versions, and CI restore behavior together. Review lock-file changes as carefully as application code changes, and fail the build when an unexpected dependency change appears. That turns dependency management from a hidden source of build drift into a controlled and auditable part of the .NET delivery process.