Most .NET deploy pain on Windows hosting is not the framework—it is an incomplete publish package, a Web.config that still points at dev SQL, or an app pool recycle that drops requests with no health check afterward. If you treat IIS like a folder drop instead of a controlled release target, you will keep debugging 500.19 and connection-string surprises in production.
This walkthrough sticks to the hosting floor: Web Deploy (MSDeploy), CI publish to Windows Server / IIS 10.0, config transforms, app-pool friendly swaps, and a short smoke test you can run from the same pipeline. Examples assume .NET 10 LTS and ASP.NET Core on Windows shared or VPS plans—not container-only workflows.
#Publish once, deploy the same artifact
Build a framework-dependent or self-contained publish output in CI, then push that folder with Web Deploy. Do not compile on the server. On shared Windows hosts you typically get Web Deploy over HTTPS with site-level credentials; on a VPS you may use an IIS Management Service endpoint or a locked-down agent. Either way, the contract is the same: one publish directory, one deploy step, environment-specific config applied at publish or transform time.
dotnet publish .\src\MyWeb\MyWeb.csproj `
-c Release -f net10.0 `
-o .\artifacts\web `
/p:EnvironmentName=Production
# Web Deploy (site credentials from CI secrets)
msdeploy.exe -verb:sync `
-source:contentPath="$pwd\artifacts\web" `
-dest:contentPath="Default Web Site/MyApp", `
ComputerName="https://deploy.example.com:8172/msdeploy.axd?site=MyApp", `
UserName="$env:WD_USER", Password="$env:WD_PASS", AuthType="Basic" `
-enableRule:AppOffline `
-allowUntrusted
AppOffline is the blunt but reliable option on shared IIS: MSDeploy drops app_offline.htm, syncs files, then removes it. That is not a true blue/green cutover, but it avoids half-written bin folders and locked DLLs during the sync. On a VPS you can refine this with a second site binding and a host-header or ARR swap; on shared plans, AppOffline plus a fast package is usually the ceiling.
#Config transforms and connection strings
ASP.NET Core prefers appsettings.{Environment}.json and environment variables, but IIS still surfaces useful knobs in Web.config: ASPNETCORE_ENVIRONMENT, stdout logging, and processPath/arguments for the ANCM handler. Keep secrets out of the repo. Prefer the host’s application settings / connection-string UI or CI-injected parameters so Production never ships with Staging SQL.
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<location path="." inheritInChildApplications="false">
<system.webServer>
<aspNetCore processPath="dotnet"
arguments=".\MyWeb.dll"
stdoutLogEnabled="false"
stdoutLogFile=".\logs\stdout"
hostingModel="inprocess">
<environmentVariables>
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
</environmentVariables>
</aspNetCore>
</system.webServer>
</location>
</configuration>
For SQL Server, inject the connection string at deploy time rather than baking it into appsettings.Production.json in git. Parameterize MSDeploy or overwrite a secrets file that is excluded from source control. On Windows hosts the SQL endpoint is often a named instance or a remote SQL Server 2022 box—verify encrypt/trust server certificate settings match what the host documents, or you will pass smoke tests on HTTP and still fail on the first DbContext call.
#CI pipeline shape that works on shared and VPS
Keep the pipeline boring: restore, test, publish, deploy, smoke. Run unit tests before publish. Gate deploy on the main branch or a release tag. Store Web Deploy username, password, and site name as secrets. Pin the SDK (10.x) on the runner so local and CI match. If you multi-target older frameworks for legacy sites, publish the TFM the app pool’s installed runtime actually supports.
- Restore + test on a Windows runner when you depend on Windows-specific packages or integration tests against LocalDB/SQL.
- Publish to a clean artifacts directory; never zip bin/obj from a developer laptop.
- Deploy with -enableRule:AppOffline (or a staging site swap on VPS) and fail the job if MSDeploy returns non-zero.
- Run smoke tests against the public HTTPS URL immediately after deploy; roll back by redeploying the previous artifact if they fail.
#Zero-downtime-ish on real IIS
True zero downtime needs two physical paths and a traffic flip. On a Windows VPS you can approximate it: deploy to a sibling folder (site_b), warm the new app pool, then switch the IIS site’s physical path or rearrange bindings. Drain the old pool with a short overlapped recycle. On shared hosting you rarely control bindings that way, so optimize for short AppOffline windows: smaller publish output (framework-dependent when the host already has the .NET 10 runtime), no giant static trees in the web root, and avoid running EF migrations mid-request.
App pool settings matter more than blog posts admit. Use integrated pipeline, “no managed code” for ASP.NET Core (ANCM owns the process), and a dedicated pool per site so one recycle does not take neighbors down on a reseller layout. Disable overlapping recycle only if you understand memory pressure; the default overlap is what keeps in-flight requests alive during a planned recycle after deploy.
#Smoke tests you should not skip
After MSDeploy succeeds, hit real endpoints—not just /. Check a health route that opens the SQL connection, verifies the expected environment name, and returns 200 only when both pass. Fail the pipeline on non-200 or TLS errors so a bad transform never sits quiet until Monday morning.
$base = "https://myapp.example.com"
$r = Invoke-WebRequest -Uri "$base/health" -UseBasicParsing -TimeoutSec 30
if ($r.StatusCode -ne 200) { throw "Health check failed: $($r.StatusCode)" }
# optional: assert body contains "env\":\"Production\"" or your JSON shape
Write-Host "Smoke OK"
Expose /health with ASP.NET Core health checks bound to your DbContext, and keep it anonymous only if it reveals nothing sensitive. Pair that with stdout logging turned on temporarily when a release misbehaves—then turn it off so disks on shared plans do not fill with trace noise.
#Practical takeaway
Ship a single CI-built .NET 10 artifact with Web Deploy, inject Production config outside git, use AppOffline or a VPS folder swap to avoid torn binaries, and block the release on a SQL-aware health check. That sequence—publish, sync, smoke—removes most IIS deploy regressions without requiring Kubernetes. On Windows shared or VPS hosting, discipline around the package and the pool beats another layer of abstraction.
Comments
No comments yet