Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

MSBuild and NuGet for .NET CI/CD

By Kokil Thapa | Last reviewed: September 2026

Your .NET app builds locally, then fails in CI with missing packages or wrong output paths. That gap usually sits between NuGet restore and MSBuild compile—not in your C# code. MSBuild and NuGet for .NET CI/CD are the two engines every pipeline depends on, whether you run GitHub Actions, Azure Pipelines, or a self-hosted runner. This guide walks through restore, build, test, and publish with copy-paste configs you can run today.

What role do MSBuild and NuGet play in a .NET CI/CD pipeline?

MSBuild is Microsoft's build engine. It reads your .csproj or .sln file and runs targets like Restore, Build, Test, and Publish. NuGet is the package manager that supplies those dependencies.

In CI, the sequence is predictable. Checkout code. Restore packages. Build the solution. Run tests. Publish output. Upload artifacts. Deploy downstream.

On real client projects, most CI failures I see trace back to restore—not compile. Wrong feed URL, expired credential, or a floating version that resolved differently on the agent.

.NET CI/CD Pipeline FlowCheckoutGit cloneNuGetRestoreMSBuildCompileTestxUnit / NUnitPublishRelease outputArtifactsZip / containerDeployIIS / K8s / AzureNuGet feeds packages — MSBuild produces binariesSame pattern on GitHub Actions, Azure Pipelines, or self-hosted agents
End-to-end MSBuild and NuGet for .NET CI/CD: restore, build, test, publish, then deploy

The dotnet CLI wraps MSBuild on modern SDK-style projects. Most teams call dotnet restore and dotnet build instead of invoking msbuild.exe directly. Under the hood, the same targets run.

For legacy .NET Framework apps on Windows agents, you may still call MSBuild directly with Visual Studio Build Tools installed. The restore step still goes through NuGet.

How do you configure NuGet restore for reliable CI builds?

NuGet restore must be deterministic. Pin your SDK, lock package versions, and point feeds through nuget.config checked into the repo root.

Create a repo-level nuget.config

Place this beside your solution file. It controls feed order and blocks surprise upstream restores.

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

Package Source Mapping stops dependency confusion attacks. Private packages resolve only from your private feed. Public packages stay on nuget.org.

Pin SDK version with global.json

Without a pinned SDK, CI may compile with a newer runtime than your laptop. That causes subtle build breaks.

{
  "sdk": {
    "version": "8.0.403",
    "rollForward": "latestFeature"
  }
}

Commit global.json at the repo root. Your pipeline reads it automatically when dotnet runs.

Use Central Package Management for version consistency

CPM keeps versions in one Directory.Packages.props file. Every project references packages without repeating version numbers.

<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
    <PackageVersion Include="xunit" Version="2.9.2" />
  </ItemGroup>
</Project>

In projects, reference packages without a Version attribute. CI restores the same versions every run. This pairs well with advice from our CI/CD secrets management guide when private feeds need tokens.

Should you use dotnet CLI or MSBuild directly in CI?

For SDK-style .NET 6+ projects, prefer the dotnet CLI. It is cross-platform and simpler to script. Use raw MSBuild when you need Visual Studio-specific targets or older .NET Framework web projects.

Criteriadotnet CLIMSBuild.exe
Cross-platform (Linux/macOS agents)YesLimited; Framework apps need Windows
SDK-style .NET 8/9 projectsRecommended defaultWorks but verbose
.NET Framework 4.x web appsNot supported for publishRequired with VS Build Tools
Custom MSBuild targets/propsHonours Directory.Build.propsFull target control
CI YAML simplicityShort commandsLonger property strings

My default on greenfield .NET CI: dotnet restore, dotnet build --no-restore, dotnet test --no-build, dotnet publish --no-build. Splitting restore from build makes caching effective. See our CI/CD caching guide for the same principle applied to other stacks.

dotnet CLI vs MSBuild.exedotnet CLICross-platform agentsSDK-style projectsglobal.json SDK pinShorter pipeline YAMLDefault for .NET 8+ CI/CDMSBuild.exeWindows agents.NET Framework 4.xCustom MSBuild targetsVS Build Tools requiredLegacy enterprise appsBoth invoke the same MSBuild engine for SDK projectsNuGet restore runs before either build path
Choosing dotnet CLI or MSBuild.exe in MSBuild and NuGet for .NET CI/CD pipelines

How do you write a GitHub Actions pipeline for MSBuild and NuGet?

GitHub Actions is a common choice for open-source and small-team .NET repos. The actions/setup-dotnet action installs the SDK. Cache NuGet packages to cut restore time from minutes to seconds.

Complete workflow example

name: .NET CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  DOTNET_NOLOGO: true
  DOTNET_CLI_TELEMETRY_OPTOUT: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET SDK
        uses: actions/setup-dotnet@v4
        with:
          global-json-file: global.json

      - name: Cache NuGet packages
        uses: actions/cache@v4
        with:
          path: ~/.nuget/packages
          key: nuget-${{ runner.os }}-${{ hashFiles('/Directory.Packages.props', '/*.csproj') }}
          restore-keys: nuget-${{ runner.os }}-

      - name: Restore
        run: dotnet restore MyApp.sln

      - name: Build
        run: dotnet build MyApp.sln --configuration Release --no-restore

      - name: Test
        run: dotnet test MyApp.sln --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=results.trx"

      - name: Publish
        run: dotnet publish src/MyApp.Web/MyApp.Web.csproj -c Release -o ./publish --no-build

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: web-app
          path: ./publish

Store feed credentials in GitHub Secrets—not in nuget.config. Inject them at runtime:

- name: Add private NuGet source
  run: dotnet nuget add source "${{ secrets.NUGET_FEED_URL }}" --name CompanyFeed --username api --password "${{ secrets.NUGET_PAT }}" --store-password-in-clear-text

This mirrors patterns from handling secrets in CI/CD pipelines safely. Never commit personal access tokens.

Validate JSON configs before push

Pipeline YAML and global.json are easy to break with a trailing comma. Paste configs into a JSON formatter and validator before committing. Small syntax errors waste an entire CI run.

How do you set up Azure Pipelines with NuGet and MSBuild?

Azure Pipelines integrates tightly with Azure Artifacts private NuGet feeds. The NuGetAuthenticate task handles credentials automatically when the feed lives in the same organisation.

For teams already on Azure DevOps, this is often the lowest-friction path. Read our Azure Pipelines first pipeline tutorial for account setup basics.

azure-pipelines.yml for a web API

trigger:
  branches:
    include:
      - main

pool:
  vmImage: ubuntu-latest

variables:
  buildConfiguration: Release
  NUGET_PACKAGES: $(Pipeline.Workspace)/.nuget/packages

steps:
  - task: UseDotNet@2
    displayName: Install SDK
    inputs:
      packageType: sdk
      useGlobalJson: true

  - task: Cache@2
    displayName: Cache NuGet
    inputs:
      key: 'nuget | "$(Agent.OS)" | /Directory.Packages.props, /*.csproj'
      path: $(NUGET_PACKAGES)
      restoreKeys: |
        nuget | "$(Agent.OS)"

  - task: NuGetAuthenticate@1

  - script: dotnet restore MyApp.sln
    displayName: Restore packages

  - script: dotnet build MyApp.sln -c $(buildConfiguration) --no-restore
    displayName: Build

  - script: dotnet test MyApp.sln -c $(buildConfiguration) --no-build --collect:"XPlat Code Coverage"
    displayName: Test

  - script: dotnet publish src/MyApp.Api/MyApp.Api.csproj -c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory) --no-build
    displayName: Publish

  - task: PublishBuildArtifacts@1
    inputs:
      PathtoPublish: $(Build.ArtifactStagingDirectory)
      ArtifactName: drop

Set the NUGET_PACKAGES environment variable so cache and restore share the same folder. Without it, the Cache task saves files your restore step never reads.

NuGet Cache Strategy in CICache MissFull restore ~2-5 minCache HitRestore ~15-30 secBuild--no-restore flagCache key = OS + hash of csproj and Directory.Packages.propsSet NUGET_PACKAGES env var to match cache pathGotcha: stale cache after major version bumpsBust cache by changing the key prefix on SDK upgrades
NuGet caching cuts restore time in MSBuild and NuGet for .NET CI/CD workflows

What are the most common MSBuild and NuGet CI/CD failures?

These failures show up repeatedly across teams. Most are configuration issues, not code bugs.

  1. Missing runtime on the agent. You target net8.0 but the agent only has .NET 6 installed. Fix with setup-dotnet or UseDotNet@2 and global.json.
  2. Restore succeeds, build fails with CS0246. Often a test project references a package the main project does not. Run dotnet restore on the full solution, not a single csproj.
  3. Private feed 401 errors. Token expired or nuget.config lacks credentials. Use NuGetAuthenticate on Azure or runtime source injection on GitHub.
  4. Non-deterministic builds. Floating versions like 8.* resolve differently over time. Pin with Central Package Management or exact Version attributes.
  5. Publish output in the wrong folder. CI uploads an empty artifact because dotnet publish ran against the wrong csproj. Verify paths in the publish step log.
  6. Windows-only dependencies on Linux agents. Some packages require native Windows binaries. Check agent OS against package requirements.

Enable binary logging when a build fails mysteriously. Add -bl:build.binlog to your build command. Download the binlog and open it with MSBuild Structured Log Viewer. The official MSBuild command-line reference documents every switch.

For security scanning of credentials before they hit CI, combine this pipeline with guidance from DevSecOps shift-left practices and secrets scanning with Gitleaks.

.NET CI Failure Decision TreeCI Build FailedRestore error?401 / 404 / timeoutCompile error?CSxxxx warningsCheck feed authnuget.config + PATVerify SDK pinglobal.json matchRestore full slnnot single csprojAdd -blbinlog debug90% of failures are restore auth, SDK mismatch, or wrong project pathBinary logs reveal the exact MSBuild target that failed
Decision tree for debugging MSBuild and NuGet for .NET CI/CD pipeline failures

How do you publish and deploy .NET artifacts from CI?

Build artifacts should be immutable. CI publishes once. CD deploys that exact zip or container image.

For containerised APIs, add a Docker build stage after dotnet publish. For IIS or Azure App Service, zip the publish folder and pass it to your release pipeline.

- script: dotnet publish src/MyApp.Api/MyApp.Api.csproj -c Release -o ./out --no-build
- script: |
    cd out
    zip -r ../app.zip .
  displayName: Create deploy zip

Separate CI and CD stages. CI validates every pull request. CD runs only on merged main. This matches blue-green patterns described in our CI/CD blue-green deployment guide.

For database schema changes, run migrations in a controlled CD stage—not silently inside publish. Our database migrations in CI/CD article covers ordering and rollback.

Self-hosted Windows agents suit .NET Framework apps that cannot move to Linux. Harden them using self-hosted CI runner security practices. A compromised build agent is a supply-chain entry point.

Small teams choosing between platforms should read GitHub Actions vs GitLab CI in 2026 and CI/CD best practices for small teams. The MSBuild steps stay the same; only the YAML wrapper changes.

If you need a private feed without Azure DevOps, the official NuGet.config reference documents every XML element. Pair it with the dotnet restore documentation for flag details.

On enterprise engagements, I treat .NET CI the same way I treat enterprise application development for PHP stacks: pin versions, cache dependencies, split stages, and make failures loud early. A law-firm portal or client portal project with a .NET backend deserves the same pipeline discipline as a public eCommerce store.

Linux-based build agents cost less than Windows VMs. Prefer them for .NET 8+ API workloads. Keep one Windows agent pool only for Framework apps. Linux system administration skills transfer directly to maintaining those agents.

Key Takeaways

  • Pin SDK versions with global.json and package versions with Central Package Management before writing pipeline YAML.
  • Cache NuGet packages using a hash of csproj files as the key, and set NUGET_PACKAGES to match the cache path.
  • Split restore, build, test, and publish into separate steps with --no-restore and --no-build flags for speed and clarity.
  • Inject private feed credentials at runtime from CI secrets—never commit tokens to nuget.config.
  • Use dotnet CLI on Linux agents for SDK-style projects; reserve MSBuild.exe for legacy .NET Framework on Windows.
  • Enable binary logging (-bl) on failed builds to pinpoint the exact MSBuild target that broke.

People Also Ask

What is the difference between dotnet restore and dotnet build?

dotnet restore downloads NuGet packages and writes assets files. dotnet build compiles source into binaries. Build runs restore automatically unless you pass --no-restore. In CI, run restore once, cache packages, then build with --no-restore to save time.

Do I need NuGet.exe in my CI pipeline?

Usually no. The dotnet CLI and MSBuild 16+ include built-in restore. Use NuGet.exe only for legacy packages.config projects or non-SDK-style solutions that predate PackageReference.

How do I speed up NuGet restore in CI?

Cache the global packages folder (~/.nuget/packages on Linux). Hash your csproj and props files for the cache key. Pin package versions so restore does not re-resolve the dependency graph every run.

Can I run MSBuild on Linux for .NET Framework apps?

No. .NET Framework targets Windows. Use a Windows agent with Visual Studio Build Tools for Framework 4.x apps. .NET 6 and later run cross-platform on Linux, macOS, and Windows agents.

Build .NET pipelines that survive production

MSBuild and NuGet for .NET CI/CD are not exotic tooling. They are the foundation every .NET deployment rests on. Pin your SDK, cache your packages, split your stages, and treat restore auth as a first-class concern. That combination eliminates most of the build failures teams chase for hours.

If you want help wiring a .NET pipeline, auditing an existing Azure DevOps setup, or integrating private NuGet feeds into your release flow, contact us or explore custom software development services. You can also browse the portfolio for examples of production systems shipped with disciplined CI/CD, or read more on the blog.

Frequently Asked Questions

MSBuild is Microsoft's build engine. It reads your .csproj or .sln and runs targets like Restore, Build, Test, and Publish. NuGet supplies PackageReference dependencies. In CI the sequence is checkout, restore, build, test, publish, upload artifacts, then deploy. Most failures trace back to restore, not compile.

dotnet restore downloads NuGet packages and writes assets files. dotnet build compiles source into binaries. Build runs restore automatically unless you pass --no-restore.

Usually no. The dotnet CLI and MSBuild 16+ include built-in restore. Use NuGet.exe only for legacy packages.config projects or non-SDK-style solutions that predate PackageReference.

Cache the global packages folder, hash csproj and props files for the cache key, and pin package versions so restore does not re-resolve the dependency graph every run.

Make restore deterministic. Check a repo-level nuget.config beside your solution to control feed order and block surprise upstream restores. Pin the SDK with global.json so CI does not compile with a newer runtime than your laptop. Use Central Package Management in Directory.Packages.props so every project restores the same package versions every run. Pair this with runtime credential injection when private feeds need tokens.

For SDK-style .NET 6 and later projects, prefer the dotnet CLI. It is cross-platform and simpler to script on Linux and macOS agents. Use raw MSBuild when you need Visual Studio-specific targets or older .NET Framework web projects on Windows with Visual Studio Build Tools. A practical CI split is dotnet restore, dotnet build --no-restore, dotnet test --no-build, and dotnet publish --no-build.

Without a pinned SDK, CI may compile with a newer runtime than your laptop, causing subtle build breaks. Commit global.json at the repo root with an explicit sdk version and a rollForward policy such as latestFeature. Pipeline steps like actions/setup-dotnet with global-json-file or Azure Pipelines UseDotNet@2 with useGlobalJson read it automatically when dotnet runs.

Central Package Management keeps package versions in one Directory.Packages.props file. Enable ManagePackageVersionsCentrally, define PackageVersion entries there, and reference packages in projects without repeating Version attributes. CI restores identical versions every run. This stops floating versions like 8.* from resolving differently over time and pairs well with deterministic NuGet restore.

Package Source Mapping in nuget.config stops dependency confusion attacks. You map package name patterns to specific feeds so private packages resolve only from your private feed and public packages stay on nuget.org. Combine it with a clear packageSources list and avoid committing credentials. Inject private feed tokens at runtime from CI secrets instead.

Use actions/checkout, actions/setup-dotnet with global-json-file, and actions/cache keyed on Directory.Packages.props and csproj hashes pointing at ~/.nuget/packages. Run dotnet restore, dotnet build --no-restore, dotnet test --no-build, and dotnet publish --no-build, then upload-artifact. Store private feed credentials in GitHub Secrets and add the source at runtime with dotnet nuget add source. Validate global.json syntax before push.

Use UseDotNet@2 with useGlobalJson, Cache@2 on the NuGet packages folder, and NuGetAuthenticate@1 when the feed lives in the same Azure DevOps organisation. Set NUGET_PACKAGES so cache and restore share the same folder. Run dotnet restore, build, test, and publish with --no-restore and --no-build flags, then PublishBuildArtifacts. Without matching NUGET_PACKAGES, the cache task saves files your restore step never reads.

Missing runtime on the agent when you target net8.0 but only .NET 6 is installed. Restore succeeds but build fails with CS0246 when restore ran on one csproj instead of the full solution. Private feed 401 errors from expired tokens. Non-deterministic builds from floating package versions. Empty publish artifacts from pointing dotnet publish at the wrong csproj. Windows-only native dependencies failing on Linux agents. Enable binary logging with -bl:build.binlog and inspect it with MSBuild Structured Log Viewer when failures are unclear.

No. .NET Framework targets Windows. Use a Windows agent with Visual Studio Build Tools for Framework 4.x apps. .NET 6 and later run cross-platform on Linux, macOS, and Windows agents.

Treat build artifacts as immutable. CI publishes once; CD deploys that exact zip or container image. Run dotnet publish with --no-build to a known output folder, optionally zip it for IIS or Azure App Service, or add a Docker stage for containerised APIs. Separate CI and CD stages so CI validates every pull request and CD runs only on merged main. Run database migrations in a controlled CD stage, not silently inside publish.

Linux-based build agents cost less than Windows VMs. Prefer them for .NET 8 and later API workloads using the dotnet CLI on ubuntu-latest pools. Keep a Windows agent pool only for legacy .NET Framework apps that require Visual Studio Build Tools and cannot move to Linux. The MSBuild and NuGet steps stay the same; only the YAML wrapper and agent OS change. Harden self-hosted Windows runners because a compromised build agent is a supply-chain entry point.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: