Post

DotNet Build: dotnet CLI, MSBuild, NuGet and CI

How the dotnet build command, MSBuild and NuGet build .NET applications, which files they need, how to lint, scan and test, and a complete GitHub Actions pipeline.

DotNet Build: dotnet CLI, MSBuild, NuGet and CI

When working with .NET applications, the dotnet build command is the essential tool that compiles your code and prepares it for execution. Whether you are developing a console application, a web API or a large enterprise solution, the dotnet CLI (a front end for MSBuild and NuGet) is the build system you will use locally and in CI.

This post is part of Build Ecosystems by Programming Language.

At a glance

CategoryCommon tools
Build toolsdotnet CLI, MSBuild, Visual Studio Build Tools; build scripting with Cake, NUKE or FAKE (F#)
Package registryNuGet, nuget.org, Azure Artifacts, GitHub Packages, Artifactory / ProGet
Build files*.csproj / *.fsproj / *.vbproj, *.sln / *.slnx, Directory.Build.props, Directory.Packages.props, global.json, nuget.config, packages.lock.json
Lint and format.NET (Roslyn) analyzers, StyleCop.Analyzers, dotnet format, .editorconfig
SAST (open source)Roslyn security analyzers, Semgrep, CodeQL, SonarQube Community Build
SAST (commercial)Checkmarx One, OpenText Fortify, Veracode, Snyk Code, SonarQube Server
SCANuGet audit (built into restore), dotnet list package --vulnerable, OWASP Dependency-Check, Trivy, Snyk, Mend, Black Duck
FrameworksASP.NET Core, Blazor, .NET MAUI, Worker Services, Entity Framework Core
Test and coveragexUnit, NUnit, MSTest; Coverlet + ReportGenerator
Build output.dll / .exe, publish folder, NuGet package (.nupkg), container image
CI cache~/.nuget/packages

How the build works

What is dotnet build?

  • dotnet build restores dependencies, compiles the source code and produces the binaries for your application.
  • It writes intermediate outputs to obj/ and the final assemblies to bin/<Configuration>/<TFM>/, which can then be run, tested or published.

In CI, each stage of the generic build pipeline maps to a dotnet CLI command, plugin or scanner. Press play, or step through it:

  1. Source + project files: A commit triggers CI. Project and solution files, Directory.Packages.props and packages.lock.json define the build; global.json pins the SDK.
  2. Restore: dotnet restore --locked-mode restores exactly what packages.lock.json says from NuGet; NuGet audit warns about vulnerable packages.
  3. Lint and format: dotnet format --verify-no-changes fails if the code does not match .editorconfig and the analyzer style rules.
  4. Compile: dotnet build -c Release --no-restore compiles with MSBuild; -warnaserror turns analyzer warnings, including security rules, into errors.
  5. Test and coverage: dotnet test runs xUnit, NUnit or MSTest, and --collect:"XPlat Code Coverage" records coverage with Coverlet.
  6. Security scans in parallel: Roslyn security analyzers check our code, dotnet list package --vulnerable --include-transitive checks dependencies, and Gitleaks scans for secrets.
  7. Quality gate: Format, the build with analyzers as errors, the tests and the vulnerability report must pass before publishing.
  8. Fail the build (fail): A failed check stops the pipeline at the gate, so nothing is packaged, signed or published.
  9. Publish the output (pass): dotnet publish -c Release writes the deployable output; dotnet pack builds a .nupkg for libraries.
  10. SBOM and signing (pass): dotnet CycloneDX writes the SBOM for the solution, and the output is signed with Sigstore cosign.
  11. Publish (pass): Packages go to a NuGet feed (nuget.org, Azure Artifacts, GitHub Packages); deployable output is kept as a CI artifact.

Key functions of dotnet build

  • Compiles the project: converts the source code into assemblies (.dll, .exe).
  • Restores dependencies: runs dotnet restore implicitly unless you pass --no-restore. Since .NET 8, restore also runs NuGet audit and warns about packages with known vulnerabilities.
  • Runs analyzers: Roslyn code-quality and code-style analyzers run during compilation, so lint and part of SAST happen inside the build.
  • Handles configurations and frameworks: Debug (default) or Release, and one or more target frameworks (TFMs) such as net10.0.

Basic usage

1
2
3
4
dotnet build                                   # Debug build of the project/solution in the current folder
dotnet build MySolution.sln                    # build every project in a solution
dotnet build --configuration Release           # optimised build
dotnet build --framework net8.0                # one target framework of a multi-targeted project

Understanding a sample CI command

A commonly used command for building a .NET solution in a GitHub Actions workflow is:

1
dotnet build ${{ inputs.SOLUTION_FILE_PATH }} -c Release --configfile 'src/NuGet.Config'
  • ${{ inputs.SOLUTION_FILE_PATH }}: a workflow input holding the path to the solution or project file.
  • -c Release: builds the Release configuration. The default is Debug.
  • --configfile 'src/NuGet.Config': uses a specific NuGet configuration so the correct package sources and authentication settings apply during the implicit restore.

Build files

FilePurposeCommit?
*.csproj / *.fsproj / *.vbprojSDK-style project: target framework, packages, build propertiesYes
*.sln / *.slnxGroups projects into a solutionYes
Directory.Build.propsShared MSBuild properties for every project below the folderYes
Directory.Packages.propsCentral package management: one version per packageYes
global.jsonPins the .NET SDK versionYes
nuget.configPackage sources and source mappingYes, credentials via env vars
packages.lock.jsonLocked dependency graph (RestorePackagesWithLockFile)Yes
.editorconfigCode style and analyzer severitiesYes
bin/, obj/Build output and intermediatesNo

Commands by category

1
2
3
4
5
6
7
8
9
dotnet restore --locked-mode                     # restore exactly what packages.lock.json says
dotnet build -c Release --no-restore             # build
dotnet format --verify-no-changes                # lint / format check
dotnet build -c Release -warnaserror             # analyzers (incl. security rules) as a gate
dotnet list package --vulnerable --include-transitive   # SCA report
dotnet test -c Release --no-build --collect:"XPlat Code Coverage"   # tests + coverage (Coverlet)
dotnet CycloneDX MySolution.sln -o sbom          # SBOM (dotnet tool install --global CycloneDX)
dotnet publish src/Api -c Release -o out         # deployable output
dotnet pack -c Release -o nupkgs                 # NuGet package

Minimal project

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- Directory.Build.props: applies to every project in the repo -->
<Project>
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <AnalysisLevel>latest-recommended</AnalysisLevel>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
    <NuGetAudit>true</NuGetAudit>
    <NuGetAuditMode>all</NuGetAuditMode>  <!-- include transitive packages -->
  </PropertyGroup>
</Project>
1
2
3
4
5
6
<!-- src/Api/Api.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
  <ItemGroup>
    <PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
  </ItemGroup>
</Project>
1
2
3
{
  "sdk": { "version": "10.0.100", "rollForward": "latestFeature" }
}

With TreatWarningsAsErrors on, NuGet audit warnings (NU1901–NU1904) fail the build when a vulnerable package is restored, which turns SCA into a gate without extra tooling.

Frameworks

FrameworkStart a project
ASP.NET Core Web APIdotnet new webapi -n Api
Blazordotnet new blazor -n Web
.NET MAUIdotnet new maui -n App
Worker Servicedotnet new worker -n Worker
Entity Framework Coredotnet add package Microsoft.EntityFrameworkCore.SqlServer then dotnet ef migrations add Init
Tests (xUnit)dotnet new xunit -n Api.Tests

CI with GitHub Actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
name: dotnet-ci
on: [push, pull_request]
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          global-json-file: global.json
          cache: true                          # keyed on packages.lock.json
          cache-dependency-path: '**/packages.lock.json'

      - run: dotnet restore --locked-mode
      - run: dotnet format --verify-no-changes --no-restore
      - run: dotnet build -c Release --no-restore
      - run: dotnet test -c Release --no-build --collect:"XPlat Code Coverage"
      - run: dotnet list package --vulnerable --include-transitive
      - run: dotnet publish src/Api -c Release --no-build -o out

      - uses: actions/upload-artifact@v4
        with:
          name: api
          path: out/

SonarQube code analysis

If you use SonarQube, the scanner wraps the build so it can collect compiler and analyzer output:

1
2
3
dotnet sonarscanner begin /k:"MyProject" /d:sonar.host.url="http://localhost:9000"
dotnet build
dotnet sonarscanner end

Best practices

  • Run dotnet restore separately in CI so the package cache is reused, then build and test with --no-restore / --no-build.
  • Pin the SDK with global.json and lock packages with packages.lock.json + --locked-mode.
  • Centralise settings in Directory.Build.props and package versions in Directory.Packages.props.
  • Treat warnings as errors so analyzer findings and NuGet audit results gate the build.
  • Use package source mapping in nuget.config to prevent dependency confusion between public and private feeds.
  • Use dotnet clean (or a fresh checkout in CI) to avoid stale binaries.
  • Publish containers without a Dockerfile using dotnet publish /t:PublishContainer when the default base images are acceptable.
This post is licensed under CC BY 4.0 by the author.