Skip to content
Shiny.NET

NuGet Feed

A private package feed is usually another service to run: a container, a database, sometimes a licence. Shiny.AspNet.NuGet maps one onto the ASP.NET Core app you already have. It speaks the NuGet V3 server API, the protocol dotnet restore, dotnet nuget push, dotnet package search, Visual Studio and Rider use, so clients need nothing but a package source URL. It is trim and Native AOT clean.

GitHub GitHub stars for shinyorg/aspnet
Downloads NuGet downloads for Shiny.AspNet.NuGet
Frameworks
ASP.NET
Terminal window
dotnet add package Shiny.AspNet.NuGet
using Shiny.AspNet.NuGet;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapNuGetFeed("/nuget", o =>
{
o.Store = new DiskNuGetPackageStore(Path.Combine(dataDir, "packages"));
o.ApiKeys.Add(builder.Configuration["NuGet:ApiKey"]!);
});
app.Run();
Terminal window
dotnet nuget add source http://build-box:5000/nuget/v3/index.json -n team --allow-insecure-connections
dotnet nuget push bin/Release/My.Library.1.2.0.nupkg -s team -k <key>
dotnet add package My.Library

The package source URL is always {prefix}/v3/index.json. Clients find every other route from there. The feed can be mapped inside other route groups (app.MapGroup("/tenants/{tenant}").MapNuGetFeed(...)); the links it hands out include the outer prefix and the request’s PathBase.

Resource Route Used by
Service index GET {prefix}/v3/index.json Every client, first
Package content GET {prefix}/v3/package/{id}/index.json, .../{id}/{version}/{id}.{version}.nupkg, .../{id}.nuspec Restore
Registrations (metadata) GET {prefix}/v3/registration/{id}/index.json, .../{version}.json Package manager UI, dotnet list package --outdated
Search GET {prefix}/v3/search?q=&skip=&take=&prerelease=&semVerLevel=&packageType= dotnet package search, the IDE browse tab
Autocomplete GET {prefix}/v3/autocomplete?q= or ?id= IDE id and version completion
Push PUT {prefix}/api/v2/package dotnet nuget push
Delete or unlist DELETE {prefix}/api/v2/package/{id}/{version} dotnet nuget delete
Relist POST {prefix}/api/v2/package/{id}/{version} — (no client command sends it)

Version rules come from the official NuGet.Versioning package. 1.0 and 1.0.0 are the same version, ids compare without case, and build metadata is ignored. Prerelease versions appear in search only with prerelease=true. SemVer 2.0.0 packages (dotted prerelease labels or build metadata) appear only for clients that send semVerLevel=2.0.0, which every current client does.

Search terms match against the id, title, description, summary, authors and tags. field:value narrows a term to id, packageid (exact), tags, author, owner, title, description or summary. Results put an exact id match first, then ids that start with the query, then everything else alphabetically. Download counts are always 0.

DiskNuGetPackageStore lays packages out exactly as NuGet does for a hierarchical folder feed and the global packages folder:

packages/
my.library/
1.2.0/
my.library.1.2.0.nupkg
my.library.1.2.0.nupkg.sha512
my.library.nuspec

So the same directory is also a local package source on its own (dotnet nuget add source ./packages). You can seed it by copying a folder in, and back it up with anything that copies files. An unlisted version gets an empty .unlisted marker next to it. The directory is read once, on first use, and kept in memory; call Reload() after changing it by hand while the app is running.

To keep packages somewhere else (blob storage, a database), implement INuGetPackageStore. It has seven members: get versions of one id, get everything (for search), add, open the .nupkg, open the .nuspec, set listed, and delete.

dotnet nuget push -k <key> sends the key as X-NuGet-ApiKey. A key in ApiKeys is compared in constant time. ValidateApiKeyAsync handles anything else, such as a lookup or a key scoped to certain ids:

o.ValidateApiKeyAsync = async ctx =>
await keys.IsValidAsync(ctx.ApiKey, ctx.PackageId); // PackageId is set for delete/relist

A missing or wrong key gets a 403, as nuget.org answers. A 401 would send the client off to its credential providers. A feed that accepts pushes but has no key configured fails to map, because an open feed is almost never intended. Pick one of these instead:

  • o.ReadOnly = true serves the store without mapping push, delete or relist. Use it for a mirror, or a folder filled some other way.
  • o.RequireApiKey = false hands write protection to authorization instead. For example, put Basic credentials in nuget.config and call .ForWrites(w => w.RequireAuthorization()).

A push is streamed (with MultipartReader, not ReadFormAsync) to a temporary file and never buffered in memory. The feed checks that the .nupkg is a zip with exactly one .nuspec at its root, that the id follows NuGet’s rules, and that the version and every dependency range parse. Then it computes the SHA-512 NuGet records. A version that already exists gets 409, unless AllowOverwrite is on (it is off by default: changing a version’s bytes breaks every machine that already cached the old ones). Symbol packages (.snupkg) are refused, and SymbolPackagePublish is not advertised, so dotnet nuget push does not try to send them.

o.OnBeforePushAsync = ctx =>
{
if (!ctx.Package.Id.StartsWith("Contoso.", StringComparison.Ordinal))
ctx.Reject(StatusCodes.Status403Forbidden, "Only Contoso.* packages.");
return ValueTask.CompletedTask;
};
o.OnPackagePushedAsync = async ctx => await notify.PublishedAsync(ctx.Package.Id, ctx.Package.Version);

MaxPackageSize defaults to 250 MB, nuget.org’s limit. Kestrel’s own MaxRequestBodySize is 30 MB by default and would refuse most packages first, so the feed’s write routes carry their own request size limit (IRequestSizeLimitMetadata) of MaxPackageSize plus 64 KB for the multipart framing. Raise or lower MaxPackageSize and the route limit follows. FormOptions limits do not apply, because the body is never read as a form.

To set a different limit on the write routes, add your own metadata after mapping; the last one wins:

app.MapNuGetFeed("/nuget", o => { ... })
.ForWrites(w => w.WithMetadata(new RequestSizeLimitAttribute(500 * 1024 * 1024)));

By default, dotnet nuget delete unlists the version, as nuget.org does. The version disappears from search and from new installs, but it still restores for any project that already pins it, so no build breaks. POST to the same URL relists it. Set o.DeleteBehavior = NuGetDeleteBehavior.Delete to remove the files for real.

MapNuGetFeed returns an IEndpointConventionBuilder for the feed’s route group, so ASP.NET’s own extensions apply to every route at once. ForReads and ForWrites (or the Reads and Writes properties) apply a policy to one half:

app.MapNuGetFeed("/nuget", o => { ... })
.ForReads(r => r.RequireAuthorization()) // a private feed
.ForWrites(w => w.RequireRateLimiting("push"))
.RequireCors("tools");

For a private feed, clients put Basic credentials in nuget.config (<packageSourceCredentials>), and NuGet sends them on every request to that source, push included. The feed is excluded from OpenAPI descriptions by default.

Every link in the protocol is absolute, and by default it is built from the request’s scheme, host and path base. Behind a reverse proxy, either use forwarded headers or set o.PublicBaseUrl = "https://pkgs.example.com/nuget".

Errors are RFC 9457 problem details, written through IProblemDetailsService when the app registers one (builder.Services.AddProblemDetails()).

Option Default
Store required Where packages live
ApiKeys empty Keys accepted in X-NuGet-ApiKey
ValidateApiKeyAsync null Checks a key that is not in ApiKeys
RequireApiKey true Whether push, delete and relist need a key
ReadOnly false Do not map push, delete or relist
DeleteBehavior Unlist Unlist or Delete
AllowOverwrite false Let a push replace an existing version
MaxPackageSize 250 MB Largest .nupkg accepted
PublicBaseUrl null Absolute base for the links the feed hands out
OnBeforePushAsync / OnPackagePushedAsync null Reject or react to a push

The feed has no catalog resource (used only by nuget.org’s own mirroring), no symbol server, no download counts, no deprecation or vulnerability metadata, no package signing verification, and no V2 (OData) API, so nuget.exe older than 3.x and the old Visual Studio 2013 client cannot use it. Embedded package icons are not served; iconUrl from the nuspec is passed through. It is tested with NuGet’s own client libraries and the dotnet CLI over HTTP/1.1 and HTTP/2 on Kestrel.

claude plugin marketplace add shinyorg/skills
claude plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

View shiny-aspnet Skill