Most failed .NET cutovers we see are not exotic runtime bugs. Someone published straight into the live site root, swapped connection strings by hand under load, and hoped the first customer click would prove the build. On shared IIS and Windows VPS hosts that pattern still works until it does not—file locks, half-written binaries, wrong ASPNETCORE_ENVIRONMENT, and a support ticket that starts with “it was fine five minutes ago.”
Treat production as a promotion target, not a build folder. Publish a versioned artifact once, land it in a staging path the app pool can reach, transform only what must change per environment, cut over with a short controlled window, then prove the site with a few boring HTTP checks before you walk away.
#Build one artifact; stop baking environment into the bin
For ASP.NET Core 10 on IIS we want framework-dependent deploys unless you have a hard reason otherwise. The CI job should restore, test, and publish a single folder (or zip) that is identical for staging and production. Environment-specific values belong in Web Deploy parameters, transformed config, or host-level settings—not in a second “prod build” that diverges three commits later.
dotnet publish .\src\Web\Web.csproj `
-c Release `
-f net10.0 `
-o .\artifacts\web `
--no-self-contained
Compress-Archive -Path .\artifacts\web\* `
-DestinationPath .\artifacts\web-10.0.$env:BUILD_NUMBER.zip -Force
Pin the SDK in global.json so agents do not silently roll to a preview toolchain. Keep the app pool’s .NET roll-forward policy intentional on the server; the publish output should not depend on whatever happened to be installed on the build runner last Tuesday. If you still ship classic ASP.NET Framework sites beside Core apps, use a separate msbuild publish profile—do not mash both stacks into one “deploy everything” script.
#Staging slots that work on shared IIS and Windows VPS
Hyperscale slot swaps are not what most hosted sites have. What you do have is disk and IIS site bindings. Give each environment a path and, when the host allows it, a separate app pool: D:\sites\contoso\prod and D:\sites\contoso\stage, or wwwroot with a sibling stage folder. On a VPS you can add a second site bound to a host header like stage.example.com. On shared plans you often get one public site plus the ability to deploy to a subdirectory or alternate physical path—use that as the dress rehearsal even if DNS still points only at production.
Web Deploy remains the least painful transport for Windows targets. Point the CI profile at the staging physical path first. Sync content with delete enabled only against staging until you trust the manifest. We still see production trees wiped because a profile’s skip rules never made it out of someone’s laptop.
msdeploy.exe -verb:sync `
-source:package=web-10.0.184.zip `
-dest:auto,ComputerName=https://deploy.example:8172/msdeploy.axd?site=contoso-stage,`
AuthType=Basic,UserName=deploy-user,Password=*** `
-setParam:name="IIS Web Application Name",value="contoso-stage" `
-setParam:name="ConnectionString-App",value="Server=...;Database=Contoso_Stage;..." `
-enableRule:DoNotDeleteRule
Opinionated don’t: do not deploy as the same Windows account you use for RDP day-to-day with Full Control on the whole drive. Use a constrained publish credential, lock the site to its application root, and keep SQL credentials out of the package. Connection strings for SQL Server should land via parameters or a transformed config on the server—not committed appsettings.Production.json in git.
#Config transforms and environment without a second codebase
ASP.NET Core 10 already layers appsettings.json, environment variables, and user secrets. On IIS the reliable levers are the app pool environment variables (ASPNETCORE_ENVIRONMENT, custom keys) and web.config environmentVariables under aspNetCore. Prefer those for slot differences. Keep secrets in the host’s secure settings or a vault your pipeline injects at deploy time.
<aspNetCore processPath="dotnet"
arguments=".\Web.dll"
stdoutLogEnabled="false"
hostingModel="inprocess">
<environmentVariables>
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Staging" />
<environmentVariable name="ConnectionStrings__App"
value="Server=sqlhost;Database=Contoso_Stage;..." />
</environmentVariables>
</aspNetCore>
If you still maintain Web.config XDT for Framework apps or for IIS-specific sections (handlers, rewrite, ANCM), keep transforms thin: binding redirects you truly need, and nothing that embeds production passwords. Validate the transformed file in CI against a smoke config so a bad XDT fails the build instead of the Friday deploy. Never let staging and production share the same Data Protection key ring path if cookies or auth tickets must stay isolated—and never let keys regenerate on every recycle either; that is a different outage class we already wrote up.
#Cut over with a short window, not a hope
Zero downtime on a single shared app pool is marketing language. What you can get is zero-surprise downtime measured in seconds. Warm the staging slot first: hit a few endpoints so ANCM and the JIT are not doing first-request work after the flip. Then choose one cutover style and stick to it for that site.
- Physical-path swap: point the IIS application at the new folder (or swap folder names behind a stable site root) after the new bits are fully written.
- app_offline.htm briefly if you must replace files in place and risk locked DLLs—better a clean 503 than a half-loaded shadow copy.
- Host-header staging site: keep production bound and warm, then flip DNS or a rewrite only after smoke tests pass on stage.
Drain matters. Recycle the target pool after the path points at the new build if old assemblies are still loaded; arrange recycle so in-flight requests finish when you can. Skip the cowboy move of copying over running Framework bin folders file-by-file from Explorer while traffic is live. On multi-instance VPS setups, deploy instance A, smoke it, shift traffic, then B—do not parallel-copy and pray both finished.
#Smoke tests that catch the failures we actually see
Full browser suites are fine in CI against staging URLs. After production cutover you need a 30-second gate the on-call person will actually run. Check TLS and the exact host header customers use, not only localhost. Verify the page that touches SQL, not only /health that returns 200 from memory. Confirm you did not leave ASPNETCORE_ENVIRONMENT=Development on a public site (detailed errors and swapped cookies show up more than people admit).
$base = "https://www.example.com"
$checks = @(
@{ Path = "/"; Expect = 200 },
@{ Path = "/health/live"; Expect = 200 },
@{ Path = "/api/version"; Expect = 200 }
)
foreach ($c in $checks) {
$r = Invoke-WebRequest -Uri ($base + $c.Path) -MaximumRedirection 0 -SkipHttpErrorCheck
if ($r.StatusCode -ne $c.Expect) { throw "$($c.Path) returned $($r.StatusCode)" }
}
# Fail if the new build did not report the expected assembly version
$ver = (Invoke-RestMethod "$base/api/version").informationalVersion
if ($ver -notlike "10.0.184*") { throw "version mismatch: $ver" }
Add one authenticated probe if the app is not public—expired cookies and wrong Data Protection paths only fail on real login. Watch the app pool for rapid fail protection trips in the first five minutes; a crash loop after a good HTTP 200 on the home page usually means a background hosted service or a SQL permission the staging database never exercised. Keep stdout logging off by default in production; turn it on only while bisecting a bad release, then delete the log files so the volume does not fill.
Roll back is part of the same playbook. Keep the previous publish folder intact until smoke passes and traffic looks normal. Re-point the application path or re-sync the last known zip faster than rebuilding from main under pressure. If SQL migrations shipped with the release, your rollback story is a migration story—run forward-only migrations in a gated step before the IIS flip, and never auto-migrate on app startup in production pools.
The discipline is dull on purpose: one artifact, a real staging slot, parameters instead of hand-edited secrets, a cutover you can reverse, and smoke checks that touch SQL and version metadata. That is how .NET 10 apps move onto IIS shared and Windows VPS hosts without turning every release into an incident review.
Comments
No comments yet