Mert Özen Further With Every Line
Backend .NET SDK, CLI and MSBuild — #04

Managing NuGet Restore: nuget.config, Package Source Mapping, Lock Files, Private Feeds and Minimum Publish Age

Mert Özen Nov 15, 2026 12 min 9 views
Managing NuGet Restore: nuget.config, Package Source Mapping, Lock Files, Private Feeds and Minimum Publish Age

With several NuGet sources and no package source mapping, an outside package with the same name can be chosen over the internal one; mapping takes each package only from the source allowed for it. We put Bookstore.Contracts on a private feed whose credentials come from environment variables and show the nuget.config hierarchy, lock files and locked mode, the .NET 11 minimum publish age setting, NuGetAudit's dependence on the feed and restore caching in CI, with real output.

Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404); the Reporting sample and the minimum publish age section ran on SDK 11.0.100 (NuGet 8), and the internal feed is a BaGetter 1.6.5 container.

Guess First

The reporting team's service uses the Bookstore.Contracts package and asks for version 1.*: "give me the newest 1.x". Two package sources are configured on the machine: the internal company feed and a public feed. The internal feed has Bookstore.Contracts 1.0.0. Someone uploaded 1.99.0 with the same name to the public feed. Which one does restore pick?

dotnet package list --project Bookstore.Reporting
Project 'Bookstore.Reporting' has the following package references
   Top-level Package          Requested   Resolved
   > Bookstore.Contracts      1.*         1.99.0

The package from the public feed won. The build broke this time because the fake package had no BookSummary type; had it contained the same types plus a little extra code, the build would have passed. This attack is called dependency confusion. In this post we put Bookstore.Contracts for Bookstore, the online bookstore used as the shared example across this site's series, on an internal company feed and use it safely together with nuget.org: the nuget.config hierarchy, feed credentials without putting secrets in the repo, package source mapping, lock files, the minimum publish age setting in .NET 11 and faster restores in CI.

The "public feed" in the example is a local folder feed rather than a fake package uploaded to nuget.org; the behavior is the same. Publishing packages itself (metadata, version numbers, signing) is the subject of the Versioning and Release Management series; here we are on the consuming side.

The nuget.config Hierarchy

NuGet reads its settings not from one file but from a merge of several: every nuget.config from the folder the command runs in up to the drive root, then the user-level file (%AppData%\NuGet\NuGet.Config on Windows). We put a file at Bookstore's root that defines only nuget.org:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
  </packageSources>
  <packageSourceMapping>
    <packageSource key="nuget.org">
      <package pattern="*" />
    </packageSource>
  </packageSourceMapping>
</configuration>

<clear /> removes the sources read before this file, that is, the ones from the user-level file. An old feed a developer added on their machine, or Visual Studio's offline folder, can no longer interfere with Bookstore's restore; everyone on the team and the CI server use the same sources.

The sample service that uses Bookstore.Contracts lives under samples/reporting. Think of this folder as the reporting team's separate repo: it has its own global.json (SDK 11.0.100), Directory.Build.props, Directory.Packages.props and nuget.config. The nuget.config in this folder adds the internal feed; you can ask NuGet which files are merged:

cd samples/reporting
dotnet nuget config paths
dotnet nuget list source
C:\src\Bookstore\samples\reporting\NuGet.Config
C:\src\Bookstore\NuGet.Config
C:\Users\user\AppData\Roaming\NuGet\NuGet.Config
Registered Sources:
  1.  nuget.org [Enabled]
      https://api.nuget.org/v3/index.json
  2.  bookstore-internal [Enabled]
      http://127.0.0.1:52404/v3/index.json

The file closer to the folder comes first and overrides any key it redefines. The Reporting file has no <clear />, so nuget.org comes from the root file and the internal feed is added to the list.

A Private Feed and Credentials Without Putting Secrets in the Repo

In real life the internal feed is Azure Artifacts, GitHub Packages or a NuGet server you host yourself. So that everyone can run the example, we ran BaGetter, an open-source NuGet server, as a container on 127.0.0.1; it asks for a user name and password to read packages and an API key to upload them. The values live in environment variables, not in the repo:

docker run -d --name bookstore-feed -p 127.0.0.1:52404:8080 `
  -e ApiKey=$env:BOOKSTORE_FEED_API_KEY `
  -e Authentication__Credentials__0__Username=$env:BOOKSTORE_FEED_USER `
  -e Authentication__Credentials__0__Password=$env:BOOKSTORE_FEED_PASSWORD `
  -e Database__Type=Sqlite -e "Database__ConnectionString=Data Source=/data/db/bagetter.db" `
  -e Storage__Type=FileSystem -e Storage__Path=/data/packages `
  bagetter/bagetter:1.6.5

Reporting's nuget.config defines the source and where its credentials come from; the values themselves are not there:

<packageSources>
  <!-- No <clear />: this file is merged with the nuget.config at the repo root. -->
  <!-- Redefined here to add a 72-hour minimum publish age (NuGet 8 / .NET SDK 11). -->
  <add key="nuget.org" value="https://api.nuget.org/v3/index.json" minPublishAgeHours="72" />
  <!-- The company feed is HTTPS; the local BaGetter container in this sample is HTTP. -->
  <add key="bookstore-internal" value="http://127.0.0.1:52404/v3/index.json" allowInsecureConnections="true" />
</packageSources>
<packageSourceCredentials>
  <bookstore-internal>
    <add key="Username" value="%BOOKSTORE_FEED_USER%" />
    <add key="ClearTextPassword" value="%BOOKSTORE_FEED_PASSWORD%" />
  </bookstore-internal>
</packageSourceCredentials>

NuGet replaces the %NAME% syntax with the environment variable's value during restore. Developers set the variables in their own session, CI supplies them from the pipeline's secret store, and the file never contains a password. Without the variables the result is a clear error:

C:\src\Bookstore\samples\reporting\Bookstore.Reporting\Bookstore.Reporting.csproj : error NU1301: Unable to load the service index for source http://127.0.0.1:52404/v3/index.json. [C:\src\Bookstore\samples\reporting\Bookstore.Reporting.slnx]
C:\src\Bookstore\samples\reporting\Bookstore.Reporting\Bookstore.Reporting.csproj : error NU1301:   Response status code does not indicate success: 401 (Unauthorized). [C:\src\Bookstore\samples\reporting\Bookstore.Reporting.slnx]

allowInsecureConnections exists only for this local example. Current SDKs reject HTTP sources; without the attribute both 10.0.404 and 11.0.100 failed with NU1302. Use HTTPS for a real feed and leave this attribute out. Saving credentials with dotnet nuget add source --username --password is another option, but the value is written to the user-level file and isn't encrypted outside Windows; an environment variable is both safer and easier to move to CI. Feeds that require authentication, such as Azure Artifacts, can use credential provider plug-ins instead.

To upload the package to the feed, we pushed the file produced by dotnet pack at Bookstore's root. --configfile takes the source definition and credentials from Reporting's file:

dotnet pack src/Bookstore.Contracts
dotnet nuget push artifacts/package/release/Bookstore.Contracts.1.0.0.nupkg --source bookstore-internal --api-key $env:BOOKSTORE_FEED_API_KEY --configfile samples/reporting/nuget.config
Pushing Bookstore.Contracts.1.0.0.nupkg to 'http://127.0.0.1:52404/api/v2/package'...
  PUT http://127.0.0.1:52404/api/v2/package/
  Created http://127.0.0.1:52404/api/v2/package/ 213ms
Your package was pushed.

Where you define the source matters too. In a first attempt we added the internal feed to Bookstore's root nuget.config; even though Bookstore uses no package from that feed, restore broke like this in a session without credentials:

C:\src\Bookstore\src\Bookstore.Contracts\Bookstore.Contracts.csproj : error NU1900: Warning As Error: Error occurred while getting package vulnerability data: Unable to load the service index for source http://127.0.0.1:52404/v3/index.json.

NuGetAudit reads vulnerability data from every configured source. When it can't reach one, it raises the NU1900 warning, and TreatWarningsAsErrors turns it into an error. On a morning when the feed is down, every build stops. That is why we defined the internal feed only next to the project that uses it.

Package Source Mapping and Dependency Confusion

When several sources are configured, NuGet looks for a package in all of them. If you ask for an exact version it takes it from the first source that answers; with a floating version such as 1.* it picks the highest version across all sources. That is what happened in the opening example. Package source mapping defines which package may come from which source. The root file maps everything to nuget.org; Reporting's file maps packages starting with Bookstore. to the internal feed:

<packageSourceMapping>
  <packageSource key="bookstore-internal">
    <package pattern="Bookstore.*" />
  </packageSource>
</packageSourceMapping>

When a package matches more than one pattern, the longest, most specific pattern wins: for Bookstore.Contracts, Bookstore.* beats *. We repeated the opening experiment with the mapping in place:

   Top-level Package          Requested   Resolved
   > Bookstore.Contracts      1.*         1.0.0

Version 1.99.0 on the public feed was never queried. The verbose restore output also shows the package requested only from the internal feed:

GET http://127.0.0.1:52404/v3/package/bookstore.contracts/index.json
OK http://127.0.0.1:52404/v3/package/bookstore.contracts/index.json 7ms
GET http://127.0.0.1:52404/v3/package/bookstore.contracts/1.0.0/bookstore.contracts.1.0.0.nupkg
OK http://127.0.0.1:52404/v3/package/bookstore.contracts/1.0.0/bookstore.contracts.1.0.0.nupkg 5ms

Reserving your company prefix on nuget.org for your own account (ID prefix reservation) is an extra safeguard, but it only protects nuget.org. Mapping works regardless of the source.

Lock Files and Locked Mode

Restore may not produce the same package graph from the same Directory.Packages.props every time: floating versions, the ranges of transitive dependencies and versions that appear on sources later change the result. A lock file records the graph restore resolved, along with content hashes. We turned it on in Reporting's Directory.Build.props:

<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>
{
  "version": 2,
  "dependencies": {
    "net10.0": {
      "Bookstore.Contracts": {
        "type": "Direct",
        "requested": "[1.0.0, )",
        "resolved": "1.0.0",
        "contentHash": "t32kTZ2zfbt2XFqKKPAd8ipqX1j7AMDWXa4kf/PBBLtsFIo5I+OW0IRtXgp4NE7Z0vDsJHOHuVyAGGOcsVF9HA=="
      }
    }
  }
}

packages.lock.json is committed to the repo. On a developer machine restore updates the file; in CI it runs in locked mode and rejects anything that doesn't match the file. CI systems such as GitHub Actions set the CI environment variable to true, so the second line turns on locked mode in CI; on a system that doesn't set it, pass it in the pipeline yourself. When someone adds a new package to the project and pushes without updating the lock file:

C:\src\Bookstore\samples\reporting\Bookstore.Reporting\Bookstore.Reporting.csproj : error NU1004: The package references have changed for net10.0. Lock file's package references: Bookstore.Contracts:[1.0.0, ), project's package references: Bookstore.Contracts >= 1.0.0, Microsoft.Extensions.Logging.Abstractions >= 10.0.14.The packages lock file is inconsistent with the project dependencies so restore can't be run in locked mode. Disable the RestoreLockedMode MSBuild property or pass an explicit --force-evaluate option to run restore to update the lock file. [C:\src\Bookstore\samples\reporting\Bookstore.Reporting.slnx]

You can run the same check from the command line with dotnet restore --locked-mode. The content hash also catches a source that serves different content under the same version number. When we packed Bookstore.Contracts again and put it on another feed under the same 1.0.0 number, restore stopped even with locked mode off:

C:\src\Bookstore\samples\reporting\Bookstore.Reporting\Bookstore.Reporting.csproj : error NU1403: Package content hash validation failed for Bookstore.Contracts.1.0.0. The package is different than the last restore. [C:\src\Bookstore\samples\reporting\Bookstore.Reporting.slnx]

dotnet pack doesn't produce a bit-for-bit identical package every time, so the package you put on your own feed won't match the lock file in the repo either; when you run the sample on your machine, regenerate the lock file once with dotnet restore --force-evaluate. Microsoft's global.json documentation recommends setting rollForward to disable in repos that use lock files, because package pruning, which varies by SDK version, can change the dependency graph. In our small graph, a lock file written with 11.0.100 restored in locked mode without trouble on 10.0.404; the difference can show up as the number of dependencies grows.

.NET 11: Minimum Publish Age

Malicious versions published from a compromised account are usually noticed and removed within the first hours or days. Minimum publish age, introduced with NuGet 8, delays the automatic selection of newly published versions per source. That is why Reporting's file redefines nuget.org with minPublishAgeHours="72"; the internal feed has no delay. To see the effect, we temporarily raised the value to 168 hours (one week) and added a package:

dotnet package add Microsoft.Extensions.Logging.Abstractions --project Bookstore.Reporting
info : PackageReference for package 'Microsoft.Extensions.Logging.Abstractions' version '10.0.14' added to file 'C:\src\Bookstore\samples\reporting\Directory.Packages.props'.

Without the setting, the same command chose 11.0.0. 11.0.0 and 10.0.15 were published on November 10, 2026, five days before this post was written, and hadn't completed the one-week waiting period; the command picked 10.0.14, the newest version that had. Knowing the limits matters: the setting only applies when NuGet chooses a version, as with dotnet package add, dotnet package update and floating versions. If you write the version by hand or the package arrives transitively, restore doesn't check its age. It can't be combined with floating versions:

C:\src\Bookstore\samples\reporting\Bookstore.Reporting\Bookstore.Reporting.csproj : error NU1020: The following PackageReference items cannot specify a floating version because package source cooldown is enabled: Microsoft.Extensions.Logging.Abstractions. [C:\src\Bookstore\samples\reporting\Bookstore.Reporting.slnx]

This feature comes with SDK 11.0.100 and NuGet 8. With the same nuget.config, dotnet package add on 10.0.404 silently ignored the attribute and picked 11.0.0, and dotnet nuget add source didn't recognize the --min-publish-age-hours option. That is why the Reporting folder's global.json pins 11.0.100: the setting only makes sense with an SDK that understands it. If you want a new version that fixes a vulnerability without waiting, write the version explicitly; the age check is skipped.

Listing and Updating Packages

.NET 10's noun-first commands bring package management into one family: dotnet package add, list, remove, update and search. The most used options of list are --outdated, --vulnerable and --include-transitive. In Bookstore.Api, --outdated shows 11.0.0 as the latest version for the EF Core packages; in a project that targets .NET 10, narrow the range if you don't want to jump a major version:

dotnet package list --project src/Bookstore.Api --outdated --highest-minor
dotnet package list --vulnerable --include-transitive
The given project `Bookstore.Api` has no updates given the current sources.
...
The given project `Bookstore.Api` has no vulnerable packages given the current sources.
The given project `Bookstore.Contracts` has no vulnerable packages given the current sources.
The given project `Bookstore.Api.Tests` has no vulnerable packages given the current sources.
The given project `Bookstore.Contracts.Tests` has no vulnerable packages given the current sources.
The given project `Bookstore.Admin` has no vulnerable packages given the current sources.

dotnet package update goes beyond listing and changes the version: give it a package name and it updates that package; with --vulnerable it raises packages with known vulnerabilities to the lowest safe version. In a repo with central package management, the change is written to Directory.Packages.props. How to configure NuGetAudit and handle its findings in CI is in the DevSecOps series.

The Global Packages Folder, Caches and Restore in CI

Every downloaded package is extracted once into the machine-wide global packages folder, and every project uses it from there; HTTP responses are kept in a separate cache. On Windows the default locations are %UserProfile%\.nuget\packages and %LocalAppData%\NuGet\v3-cache. In CI, moving these folders somewhere cacheable with the NUGET_PACKAGES and NUGET_HTTP_CACHE_PATH environment variables is a common technique:

dotnet nuget locals all --list
http-cache: C:\nuget\http-cache
global-packages: C:\nuget\packages
temp: C:\nuget\scratch
plugins-cache: C:\nuget\plugins-cache

If restore is slow, first check whether the cache is used. In CI, cache the global packages folder and derive the cache key from the hash of packages.lock.json and Directory.Packages.props: as long as the dependencies don't change, packages come from disk, and when they do the key changes too. Then the sequence dotnet restore --locked-mode, dotnet build --no-restore does the restore in a single step. For a corrupted package or an odd restore error, dotnet nuget locals http-cache --clear is usually enough; all --clear means downloading every package again.

In Production

Give feed credentials to CI with the least privilege: read-only for restore, and a separate API key for push that only the publishing pipeline can use. Don't keep both in the same secret.

An internal feed being down can stop every build. As the NU1900 example showed, even NuGetAudit can cause it. Monitor the feed's availability and define the source only in the repos that need it.

Don't skip lock file changes in code review. If a PR shows a transitive package or version change you didn't expect, ask why; dependency confusion or a compromised package is often noticed there first.

Common Pitfalls

The same package name on two feeds without source mapping. With a floating version the highest version wins, with an exact version the first source to answer wins; neither is under your control. Use package source mapping in every repo that defines more than one source.

Committing the lock file but not using locked mode in CI. With locked mode off, restore silently updates the lock file and the file is just a record. Turn on RestoreLockedMode in CI or pass --locked-mode.

Writing credentials into nuget.config as plain text. A password that enters the repo stays in its history, and you have to rotate it. Bind it to an environment variable with %NAME%.

Relying on the user-level nuget.config. A restore that works on one developer's machine may only work thanks to a source that person added. A file at the repo root that starts with <clear /> removes that difference.

Assuming minimum publish age protects everything. It doesn't apply to transitive packages or to hand-written versions, and older SDKs don't read the setting at all.

A Small Experiment

★☆☆ Run dotnet nuget config paths and dotnet nuget list source in the samples/reporting folder and at the repo root. Add a source to your user-level file and watch the root <clear /> remove it.

★★☆ Delete the packageSourceMapping section from Reporting's nuget.config, change the Bookstore.Contracts version to 1.* (with central package management this needs CentralPackageFloatingVersionsEnabled) and put a package with a higher version that you build yourself into a local folder feed. Which package does restore pick? Put the mapping back and try again.

★★★ Add a new package to Reporting, but run dotnet restore with CI=true without updating packages.lock.json and read NU1004. Then design a CI step that caches the global packages folder with a key derived from the lock file's hash.

The Reporting service now gets Bookstore.Contracts safely, but only on .NET 10. The reporting team also has an old export service still on .NET 8 that wants the same package. In the next post we build Bookstore.Contracts for both net8.0 and net10.0: TargetFrameworks, framework-conditional code and package references, platform APIs, and tests that run on both frameworks.