Automating .NET Deployments With One PowerShell Script: Lessons From 1,400 Lines
This is generalized from a real project. Every name, server and path here is an invented example. The point is the approach and the gotchas, not any specific codebase.
Building a real-world PowerShell deploy script. A 9 part series. This overview is part 1. The rest land over the next few weeks.
- Automating .NET Deployments With One PowerShell Script (you are here)
- Flowerboxes: How to Write a Script Header Worth Keeping
- The Skeleton and the Two Callers
- Choosing What and Where to Deploy
- Discovering Components From Disk (coming soon)
- Resolving the Deploy Target (coming soon)
- The Publish Phase and Its Gotchas (coming soon)
- The Main Loop, Safety and Provenance (coming soon)
- Testing a Tool That Can Do Damage (coming soon)
I recently wrote a single PowerShell script, about 1,400 lines, that builds, tests and publishes a large Visual Studio solution. The solution has a lot of moving parts: shared class libraries, a couple of shared Web APIs, a workflow API per feature area and a front-end app per feature area. Publishing that by hand out of Visual Studio, project by project, in the right order, to the right place, is slow and easy to get wrong.
One script now does the whole thing. It took roughly 60 hours and a lot of testing to get right, and it is now running in production for a real client. This post is the high-level design and the traps I hit, written so you can apply it to your own .NET solution.
The shape of the problem
Picture a solution with components like this, using invented names:
CoreLib, a shared class library other projects compile againstDataApi, a shared data-access Web APISecurityApi, a shared security Web APIOrdersFlow, a workflow API for the Orders featureOrdersApp, the Orders front-end app
There are many feature areas, each with its own flow API and app, all sitting on top of the shared pieces. They have to deploy bottom-up: shared libraries first, then shared APIs, then the per-feature flow, then the app. Get the order wrong and you publish an app against an API that is not there yet.
Principle 1: discover, do not hard-code
The filename ended up with the word “Template” in it, but it is not copy-per-project. It is one script that serves every package. For a given target it discovers from the repo on disk: which components exist, their deploy order, each publish profile, the path inside that profile, and whether the component needs the licensed UI component suite.
Deploy order is derived from a naming convention rather than a giant hand-written list. Something like:
- a name ending in
Libis a bundled class library, tier 0, never deployed on its own DataApiis tier 1- a name ending in
Securityis tier 2 - a name ending in
Flowis tier 3 - everything else is tier 4, the app
In practice that is a small function that reads the name, plus a sort:
function Get-ComponentTier {
param([string]$Name)
switch -Regex ($Name) {
'Lib$' { return 0 } # bundled class library, never deployed alone
'^DataApi$' { return 1 } # shared data-access API
'Security$' { return 2 } # security API
'Flow$' { return 3 } # workflow API
default { return 4 } # the front-end app
}
}
$ordered = $components | Sort-Object { Get-ComponentTier $_.Name }
Adding a new feature area needs no edit to the script. Drop the projects in, follow the naming, and discovery picks them up. That one decision is what keeps a 1,400-line script from becoming a 5,000-line one.
Principle 2: a human and a pipeline are different callers
The script has two very different users and it treats them differently on purpose.
A person runs it with no arguments and it prompts for what it needs: which package, which environment, where to put a local build. A pipeline must never see a prompt, because a prompt in automation hangs the run forever.
So automation is detected, not just declared. If it sees -NonInteractive, or any of the usual CI environment variables (GITHUB_ACTIONS, TF_BUILD, GITLAB_CI, JENKINS_URL, CI and friends), it switches to a mode where it never prompts. Anything it was not told becomes a hard error with the reason printed, instead of a menu nobody is there to answer.
$IsAutomation = $NonInteractive -or
$env:CI -or $env:GITHUB_ACTIONS -or $env:TF_BUILD -or
$env:GITLAB_CI -or $env:JENKINS_URL
One subtlety worth stealing: do not treat “stdin is redirected” as proof of automation. IDE terminals redirect stdin, and testing for it wrongly denied a real person the menu. Check for the CI signals instead.
The contract with the pipeline is dead simple: exit 0 is success, exit 1 is failure. Check the exit code, never scrape the log text.
Principle 3: never guess where a deploy goes
This is the highest-consequence rule in the whole thing. The environment parameter has no default. A deploy destination must never be guessed, because an accidental bare run must not quietly publish to a live server.
Resolution goes in a fixed order, first match wins: the explicit parameter, then a specific environment variable, then the CI environment name, then an interactive menu for humans only, then a hard error that lists every way to fix it.
function Resolve-Environment {
param([string]$Environment)
if ($Environment) { return $Environment } # 1. explicit flag
if ($env:DEPLOY_ENVIRONMENT) { return $env:DEPLOY_ENVIRONMENT } # 2. env var
if ($env:GITHUB_ENVIRONMENT) { return $env:GITHUB_ENVIRONMENT } # 3. CI environment
if (-not $IsAutomation) { return (Read-EnvironmentMenu) } # 4. humans only
throw "No environment set. Pass -Environment, set DEPLOY_ENVIRONMENT, or run interactively."
}
The git branch is never consulted to pick an environment. None of main, develop or release actually corresponds to staging or production, so any branch-to-environment rule would be fiction dressed up as safety. The branch gets recorded in the log and that is all it is used for.
Safety rules that earned their place
- Stop at the first failure. A component is never skipped silently. If the build breaks, nothing after it publishes, so a partial deploy is never reported as success.
- Publish overwrites, it does not delete. The publish profiles use a plain file copy with delete-existing turned off. Same-named files (DLLs, config) are replaced, everything else is left alone. That means low risk of wiping data, but a real risk of overwriting a hand-edited config on the server, so populated targets get checked before a write.
- A real deploy expects the release branch. For a server deploy, if the checked-out source is not on the release branch, an interactive run warns and asks, and automation hard-stops unless you explicitly allow it. A runner should not be able to ship the wrong branch by accident.
- Record what went out. The log prints the branch, the last commit and the count of uncommitted changes per repo, so months later the log says exactly what shipped. It is read-only. The script never fetches or modifies the source.
The MSBuild and NuGet gotchas
This is the part I wish someone had written down for me.
Publish with dotnet build, not dotnet publish. On this project graph, dotnet publish tripped an MSB4006 circular dependency. The working path is:
dotnet build -p:DeployOnBuild=true -p:PublishProfile=<name>
A missing publish profile is not an error. If the profile name is wrong or the file is not there, the SDK emits only a warning and still exits 0. The component silently does not deploy while the script happily reports success. So the profile file is verified to exist before publishing, rather than trusting the exit code.
$profilePath = Join-Path $projectDir "Properties\PublishProfiles\$ProfileName.pubxml"
if (-not (Test-Path $profilePath)) {
throw "Publish profile '$ProfileName' not found at $profilePath. Refusing to publish nothing."
}
dotnet build -p:DeployOnBuild=true -p:PublishProfile=$ProfileName
Watch the PowerShell output stream. Inside the function that publishes a component, native dotnet output is piped to Out-Host. If you let it fall through, that text joins the function’s output stream, and a function meant to return a single boolean instead returns an array. An array is always truthy, so a failed build would look like success. Pipe the noisy command’s output somewhere explicit and return only the value you mean to return.
function Publish-Component {
param($ProfileName)
dotnet build -p:DeployOnBuild=true -p:PublishProfile=$ProfileName | Out-Host
return ($LASTEXITCODE -eq 0) # return ONE boolean, not the build's chatter
}
Compiled-DLL references bite on a clean machine. Several projects referenced a shared library as a compiled DLL with a hint path into bin\Debug:
<Reference Include="CoreLib">
<HintPath>..\..\CoreLib\bin\Debug\net8.0\CoreLib.dll</HintPath>
</Reference>
Two problems. MSBuild does not build a plain <Reference>, so the DLL has to already exist, and the path is pinned to Debug. On a developer machine it works by accident because Visual Studio already built that library. On a clean build box the DLL is not there and every dependent project fails with a “type or namespace not found” error. The right fix is a project reference:
<ProjectReference Include="..\..\CoreLib\CoreLib.csproj" />
That lets MSBuild build it automatically, in the right order and the right configuration. Until a codebase is fixed that way, the script compensates by pre-building those libraries first, in the configuration the hint path names.
The phantom NuGet source on machines without Visual Studio. The build boxes here have no Visual Studio installed. An SDK install still registers a machine-level NuGet source named “Microsoft Visual Studio Offline Packages” that points at a folder which only exists when Visual Studio is present. Restore then fails with NU1301 the moment a project needs a real restore. Provision the box once:
dotnet nuget remove source "Microsoft Visual Studio Offline Packages"
# or ship a curated nuget.config that clears inherited sources
Suppress audit noise, not real errors. NuGet’s vulnerability audit can throw NU1900 when it cannot reach a private feed’s index. That is noise. Suppress it, but make sure real restore failures (NU1101, NU1301) still surface, because those are the ones you need to stop on.
Pin third-party versions and provision the feed. A licensed UI component suite was pinned to a specific version. A box that did not have that exact version silently resolved a newer one with a different API (NU1603), and the build broke on code that was fine everywhere else. Pin the version and make sure the private feed that serves it is actually configured on every build machine.
Discovery reads the disk, so the solution file can lie
The script finds components by listing project folders, not by reading the .sln. That has a sharp edge: removing a project from the solution does not remove it from the build, because the folder is still there. A retired component kept getting compiled after it was dropped from the solution.
The fix is an explicit exclusion list, and importantly, a loud one:
$RetiredModules = @('OldReportsFlow')
When a retired module is found in a checkout, the run prints a clear note that it was found and deliberately not built. “It did not build” should never be confusable with “it silently vanished.”
Two more layout realities the discovery has to tolerate: repos do not all share one folder shape, so the script finds the solution file structurally (shallowest match wins) rather than assuming a fixed path. And one component turned out to be a Worker Service hosted as a Windows Service, not an IIS app. It has no publish profile, and deploying a service is a different operation from a file copy (stop the service, replace files, restart). The script compiles it like anything else but hard-stops on a deploy attempt rather than guessing a destination.
Prove the failure paths with a mock harness
The scariest bugs in a deploy tool are the ones where it says success and did the wrong thing. So the tests do not build real projects against real servers. They build fake repos and fake servers in a temp folder and then drive the script against them to prove the behaviors that matter:
- a broken build stops the run
- a wrong or missing target never publishes anyway
- a failure never leaves a partial deploy reported as success
- automation can never hang on a prompt
- no branch can ever imply an environment
If your automation can cause real damage, the tests should exercise the damage paths in a sandbox, not just the happy path.
Compile-only mode for the pipeline
There is a mode that builds every module in the repo and publishes nothing. It does not stop at the first failure. It builds them all and writes a per-project pass or fail report, then exits non-zero if any failed. That is the perfect thing to run on every pull request: it answers “does the whole solution still compile” without touching a server.
When it is worth doing this
If you publish one project once in a while, keep clicking publish in Visual Studio. This kind of script earns its length when you have many interdependent projects, more than one environment, and a rule that a mistake could take down something live. The value is not that PowerShell is clever. It is that the order, the target and the failure behavior stop living in a person’s head and start being enforced the same way every time.
The single best decision was making the script refuse to guess. No default environment, no branch inference, no silent skip. A deploy tool that stops and asks, or stops and fails loudly, is worth far more than one that is convenient and occasionally ships to the wrong place.
Your turn
I want to hear how other people handle this.
- How do you deploy a multi-project .NET solution? A script like this, a full CI/CD product, or still publishing from Visual Studio by hand?
- What is your rule for deciding which environment a deploy goes to? Do you tie it to a branch, and has that ever gone wrong?
- Which MSBuild or NuGet gotcha has burned you the worst? I will start: a missing publish profile exiting 0 and “succeeding” at deploying nothing.
Drop it in the comments.
There is a whole series coming
This post stayed high level on purpose. There is a lot more under the hood, so I am turning it into a walkthrough series that rebuilds this script one phase at a time, with sanitized code you can lift and adapt. The full map is at the top of this post.
Part 2 covers documenting the script header, then each part after it builds a single phase: the skeleton and the two callers, deciding what and where to deploy, discovering components from disk, resolving the target, the publish phase and its gotchas, the main loop and safety, and how to test a tool that can do real damage. New parts land over the next few weeks. If there is one you want first, say so in the comments.
// comments