
September 09, 2026
12 min read
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.
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.
| Criteria | dotnet CLI | MSBuild.exe |
|---|---|---|
| Cross-platform (Linux/macOS agents) | Yes | Limited; Framework apps need Windows |
| SDK-style .NET 8/9 projects | Recommended default | Works but verbose |
| .NET Framework 4.x web apps | Not supported for publish | Required with VS Build Tools |
| Custom MSBuild targets/props | Honours Directory.Build.props | Full target control |
| CI YAML simplicity | Short commands | Longer 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.
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.
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.
- Missing runtime on the agent. You target
net8.0but the agent only has .NET 6 installed. Fix withsetup-dotnetorUseDotNet@2andglobal.json. - Restore succeeds, build fails with CS0246. Often a test project references a package the main project does not. Run
dotnet restoreon the full solution, not a single csproj. - Private feed 401 errors. Token expired or
nuget.configlacks credentials. UseNuGetAuthenticateon Azure or runtime source injection on GitHub. - Non-deterministic builds. Floating versions like
8.*resolve differently over time. Pin with Central Package Management or exact Version attributes. - Publish output in the wrong folder. CI uploads an empty artifact because
dotnet publishran against the wrong csproj. Verify paths in the publish step log. - 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.
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.jsonand 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_PACKAGESto match the cache path. - Split restore, build, test, and publish into separate steps with
--no-restoreand--no-buildflags for speed and clarity. - Inject private feed credentials at runtime from CI secrets—never commit tokens to
nuget.config. - Use
dotnetCLI 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
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.

