Anyone can run dotnet pack and get a .nupkg file. Getting a package consumers trust, that debugs cleanly, updates safely, and does not silently break its own semantic versioning promise, takes more deliberate work. This guide walks through the full lifecycle of a production-quality NuGet package: metadata that makes a package discoverable and trustworthy, SemVer and multi-targeting, README files and icons, SourceLink and symbol packages for a real debugging experience, deterministic builds, package validation against breaking changes, publishing to nuget.org with trusted publishing (OIDC) instead of a stored API key, private feeds with package source mapping, NuGet Audit for vulnerability scanning, and package signing.

What Makes a Good NuGet Package?#

A package is a contract. Consumers pin a version range, build tooling around your public API, and expect that a patch release will not break their build. The mechanics of dotnet pack are simple; earning that trust is not, and it rests on a handful of concrete things: accurate metadata so people can evaluate the package before installing it, a version number that actually follows SemVer, debug symbols so an exception inside your library is not a dead end, and a publishing pipeline that cannot be hijacked to push a malicious update under your package's name.

How NuGet Packaging Works: nupkg, Metadata and dotnet pack#

A .nupkg file is a zip archive containing your compiled assemblies organized by target framework, a .nuspec manifest with the package's metadata, and optionally a README, icon, license file and license metadata. dotnet pack generates the .nuspec from your project file automatically for an SDK-style project, which is why modern packages rarely hand-author a .nuspec directly. Recommended metadata maps directly onto MSBuild properties:

ConcernMSBuild propertyNotes
IdentityPackageIdDefaults to the assembly name; must be unique on the feed
VersionVersion or PackageVersionPackageVersion overrides Version for the package only
AuthorsAuthorsA "pretty name," not necessarily your NuGet.org username
READMEPackageReadmeFilePath to a Markdown file included and rendered on nuget.org
IconPackageIcon128x128 PNG with a transparent background is recommended
LicensePackageLicenseExpressionAn SPDX expression; do not use the deprecated LicenseUrl
RepositoryRepositoryUrlSet automatically when you add a SourceLink package

Getting Started: Packing a Library with Full Metadata#

XML
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <PackageId>Contoso.Resilience.Extensions</PackageId>
    <Version>1.2.0</Version>
    <Authors>Contoso Engineering</Authors>
    <Description>Opinionated resilience defaults for Contoso services built on Polly.</Description>
    <PackageTags>resilience;polly;http;dotnet</PackageTags>
    <PackageReadmeFile>README.md</PackageReadmeFile>
    <PackageLicenseExpression>MIT</PackageLicenseExpression>
    <PackageReleaseNotes>See https://github.com/contoso/resilience-extensions/releases/tag/v1.2.0</PackageReleaseNotes>
  </PropertyGroup>

  <ItemGroup>
    <None Include="README.md" Pack="true" PackagePath="\" />
  </ItemGroup>

</Project>
Bash
dotnet pack --configuration Release --output ./artifacts/package

dotnet pack builds the project first unless you pass --no-build after an explicit dotnet build, and writes the .nupkg (and, as covered below, a matching .snupkg) to the output path.

Semantic Versioning and Version Strategy#

NuGet does not enforce SemVer, but every tool built around it assumes you follow Major.Minor.Patch, with an optional -prerelease suffix such as -preview.1 or -rc.1. Bump major for a breaking API change, minor for a backward-compatible addition, and patch for a fix that changes no public surface; consumers pin ranges like [1.2.0,2.0.0) on the assumption that you will hold to that. Publish anything not yet stable as a prerelease version rather than a 1.0.0 that quietly breaks; a prerelease identifier also excludes the package from a plain dotnet add package unless the consumer opts in with --prerelease, which is a useful safety net on its own.

Multi-Targeting a Package#

A library aimed at both older and current runtimes multi-targets, declaring TargetFrameworks and letting dotnet pack produce one set of assemblies per framework inside the same .nupkg:

XML
<PropertyGroup>
  <TargetFrameworks>netstandard2.0;net8.0;net10.0</TargetFrameworks>
</PropertyGroup>

Consumers automatically get the assembly built for their own target framework at restore time, with no action on their part. The MSBuild and project system guide covers conditional PackageReference items and #if compilation symbols for framework-specific code paths in depth; the packaging concern here is simply that every declared TargetFramework must actually build and restore cleanly, since a broken one fails dotnet pack for the whole package, not just that framework.

Deterministic Builds#

A deterministic build produces byte-identical output from identical source, which matters for supply-chain verification: a consumer, or you, can rebuild a released version and confirm the .nupkg matches exactly what was published. SDK-style projects already build deterministically by default when ContinuousIntegrationBuild is set, which you should gate on your CI environment rather than set unconditionally, since it changes how paths are embedded in a way that is unhelpful for local F5 debugging:

XML
<PropertyGroup>
  <ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>

The one remaining source of non-determinism in the package itself is file modification timestamps inside the .nupkg. Starting with .NET 11, packages are timestamp-deterministic by default; on current SDKs, pin a DeterministicTimestamp property, for example to your release commit's timestamp, to get bit-identical repackaging:

Bash
export DeterministicTimestamp=$(git log -1 --pretty=%ct)
dotnet pack --configuration Release

Package Validation: Catching Breaking Changes Before You Ship#

EnablePackageValidation runs API compatibility checks during dotnet pack, comparing the package you are about to produce against a previously published baseline version, and failing the build if it finds a breaking change or a target framework that lost compatibility:

XML
<PropertyGroup>
  <EnablePackageValidation>true</EnablePackageValidation>
  <PackageValidationBaselineVersion>1.1.0</PackageValidationBaselineVersion>
</PropertyGroup>

This is the automated version of the promise SemVer makes: if PackageValidationBaselineVersion points at your last stable release and validation passes, you have machine-checked evidence, not just intent, that a patch or minor bump did not remove or change a public member a consumer might depend on. Run it in CI on every pull request that touches public API surface, alongside the code quality gates you already enforce for the rest of the codebase.

Publishing to nuget.org with Trusted Publishing#

Trusted publishing removes the long-lived API key from your release pipeline entirely. Instead of storing a nuget.org API key as a repository secret, your CI workflow requests a short-lived OIDC token from GitHub Actions, nuget.org validates it against a trusted publishing policy you configure once, and issues a temporary API key, valid for one hour and usable exactly once, for that single push:

YAML
permissions:
  id-token: write

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - run: dotnet pack --configuration Release --output ./artifacts

      - name: NuGet login (OIDC to temporary API key)
        uses: NuGet/login@v1
        id: login
        with:
          user: ${{ secrets.NUGET_USER }}

      - name: Push package
        run: >
          dotnet nuget push ./artifacts/*.nupkg
          --api-key ${{ steps.login.outputs.NUGET_API_KEY }}
          --source https://api.nuget.org/v3/index.json

On nuget.org, configure the trusted publishing policy under your account's Trusted Publishing page with the repository owner, repository name, workflow file name, and, optionally, the GitHub Actions environment the job must run under, so a fork or a differently named workflow cannot mint a key for your package even if it somehow gained id-token: write. There is nothing to rotate and nothing sitting in your secrets store for an attacker to exfiltrate from a compromised dependency or a misconfigured log.

Private Feeds and Package Source Mapping#

Internal packages typically live on a private feed, Azure Artifacts, GitHub Packages, or a self-hosted NuGet server, layered alongside nuget.org. The risk with multiple sources is dependency confusion: if an internal package name is requested and a public feed happens to serve a same-named package, NuGet's default behavior does not guarantee it resolves from the source you intended. Package source mapping, in nuget.config, closes that gap by pinning each package pattern to exactly one source:

XML
<configuration>
  <packageSources>
    <clear />
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
    <add key="contoso" value="https://pkgs.dev.azure.com/contoso/_packaging/internal/nuget/v3/index.json" />
  </packageSources>

  <packageSourceMapping>
    <packageSource key="nuget.org">
      <package pattern="*" />
    </packageSource>
    <packageSource key="contoso">
      <package pattern="Contoso.*" />
    </packageSource>
  </packageSourceMapping>
</configuration>

Once a packageSourceMapping section exists, every package, including transitive ones, must match a pattern under some source or restore fails, which is a deliberate, safe default: an unexpected package name has nowhere to resolve from by accident. Commit nuget.config to source control so the mapping applies identically on every developer machine and in CI.

NuGet Audit: Vulnerability Scanning on Restore#

dotnet restore checks every package in the graph against known vulnerability advisories automatically on .NET 8 SDK (8.0.100) and later, controlled by three MSBuild properties:

XML
<PropertyGroup>
  <NuGetAuditMode>all</NuGetAuditMode>
  <NuGetAuditLevel>moderate</NuGetAuditLevel>
</PropertyGroup>

<ItemGroup>
  <!-- An advisory you have assessed and accepted, suppressed explicitly rather than lowering the level -->
  <NuGetAuditSuppress Include="https://github.com/advisories/GHSA-xxxx-xxxx-xxxx" />
</ItemGroup>

NuGetAuditMode set to all audits transitive dependencies too, not just direct PackageReference entries; it defaults to all automatically for projects targeting net10.0 or later, and to direct otherwise. NuGetAuditLevel sets the minimum severity that produces a warning, and NuGetAuditSuppress silences one specific, already-assessed advisory instead of hiding every finding at that severity.

Wire a failing audit into your CI pipeline the same way you gate on tests and analyzers; a vulnerable transitive dependency is exactly the kind of thing that should block a merge rather than wait for someone to notice.

Signing Packages#

Author signing embeds a certificate-backed signature that proves a package came from you and was not tampered with after publishing:

Bash
dotnet nuget sign MyPackage.nupkg --certificate-path contoso.pfx --timestamper http://timestamp.digicert.com

nuget.org also applies its own repository signature to every package on the way in, regardless of whether you sign it yourself, which is what most consumers actually verify by default. Author signing is most valuable for private feeds and internal packages where the repository signature nuget.org provides does not apply, and where a certificate under your organization's control is the only signal a consumer has that a package genuinely came from you.

Best Practices#

  • Write real metadata, especially Description, PackageReadmeFile and PackageTags; they are the primary way a consumer decides whether to trust and use your package before reading a line of code.
  • Follow SemVer strictly, and publish anything unstable as a prerelease rather than a misleadingly stable-looking 1.0.0.
  • Always ship symbols and SourceLink. The cost is a few extra properties; the payoff is that a consumer's exception stack trace becomes something they can actually debug.
  • Turn on EnablePackageValidation with a baseline version once you have a stable release, so a breaking change fails the build instead of shipping silently.
  • Use trusted publishing instead of a stored nuget.org API key for any package published from CI.
  • Enable package source mapping the moment you add a second package source, not after a dependency confusion incident makes it urgent.

Common Pitfalls#

  • Shipping a 1.0.0 with an unstable API, which burns the version number SemVer needs to communicate a real breaking change later.
  • No README or icon, which makes a package look abandoned or untrustworthy even when the code is solid.
  • Skipping symbols entirely, forcing every consumer who hits a bug in your library to decompile it instead of stepping through real source.
  • A long-lived nuget.org API key stored as a CI secret, when trusted publishing removes the need for one, and a leaked key can push a malicious version under your package's name.
  • Multiple package sources with no source mapping, leaving the door open to dependency confusion attacks against internally named packages.
  • Silencing a NuGet Audit warning by disabling NuGetAudit entirely instead of suppressing the one specific advisory you actually assessed.

API Key Publishing vs. Trusted Publishing#

ConcernAPI keyTrusted publishing (OIDC)
Credential lifetimeLong-lived until manually rotatedMinutes; a fresh token is minted per run
StorageRepository or org secretNothing stored; identity federated per workflow run
Leak blast radiusValid until revoked, anywhere it worksEffectively unusable outside the exact configured workflow
SetupGenerate on nuget.org, add as a secretConfigure a trusted publishing policy once, no secret to manage
Best fitManual pushes, non-CI publishingAny package published from GitHub Actions or GitLab CI

Frequently Asked Questions#

Do I need both a .nupkg and a .snupkg file?#

Yes, they serve different purposes. The .nupkg carries your compiled assemblies and metadata; the .snupkg carries only the portable PDB symbol files, published separately to nuget.org's symbol server so consumers can step into your code without you bundling debug symbols into the main package.

What is the difference between Version and PackageVersion?#

Version sets the assembly version, file version and package version together, and is what most projects should use. PackageVersion overrides just the NuGet package's version, which is useful only in the uncommon case where you deliberately want the assembly version and the package version to diverge.

How does trusted publishing actually stop a compromised secret from being used?#

There is no long-lived secret to compromise. Each publish exchanges a short-lived, workflow-scoped OIDC token, valid for a single use, for a temporary nuget.org API key that expires within the hour. An attacker would need to compromise the specific workflow run itself, not a secret sitting in storage indefinitely.

Does NuGet Audit block a build the way a compiler error does?#

By default, audit findings surface as restore warnings, not build-breaking errors, so update to the patched version or add an explicit NuGetAuditSuppress entry once you have assessed the advisory. Treat an unaddressed audit warning as a CI gate you choose to enforce, the same way you might enforce TreatWarningsAsErrors for other warning categories.

Is package source mapping required, or just a best practice?#

It is not required for restore to work, but skipping it on a repository with more than one package source leaves you exposed to dependency confusion: NuGet has no other way to guarantee an internally named package cannot resolve from an unexpected public source.

Summary#

  • Treat a package's metadata, README, icon, license expression and tags as part of the product, not an afterthought after the code compiles.
  • Follow SemVer for real, and validate it automatically with EnablePackageValidation and a baseline version instead of relying on manual review alone.
  • Ship symbols (.snupkg) and SourceLink together for a debugging experience that actually works.
  • Prefer trusted publishing (OIDC) over a stored nuget.org API key for anything published from CI.
  • Add package source mapping the moment a second package source enters the picture, to close the dependency confusion gap.
  • Turn on NuGet Audit and treat its warnings as a real CI gate, suppressing only advisories you have actually assessed.

Further Reading#