Most failed .NET releases on Windows hosting are not compile errors. They are the five minutes after publish when the wrong connection string, a missing content file, or a cold app pool meets real traffic. Azure-style deployment slots are rare on shared IIS and many Windows VPS plans, so you need a floor-level pattern: publish beside the live site, flip with a short controlled window, then prove the site answers before you walk away.
A staging folder plus an optional app_offline.htm gate, Web Deploy (or a CI artifact copy), environment-specific config, and a tiny smoke script give you zero-downtime-ish behavior on ordinary IIS. Downtime shrinks to a rename or physical-path change instead of an in-place overwrite of running files.
#Layout that works on shared and VPS IIS
Keep two sibling directories under the site root your host already mapped, for example site\wwwroot-live and site\wwwroot-next (names vary by host; some lock the public folder name). Point the IIS application at one physical path. Always publish into the inactive folder. Never unzip or MSDeploy straight over the live tree while workers still serve requests.
- Live path: what the site binding and app pool currently use.
- Next path: clean publish target for this release (delete or recycle contents first).
- Optional previous path: last-known-good copy for a one-command rollback rename.
On full IIS (VPS/dedicated) you can change the application’s physical path via appcmd or PowerShell after publish. On locked-down shared hosting you often only control files inside one folder—in that case publish to a subfolder, then swap contents with a short app_offline window so IIS stops serving half-written binaries.
#Publish into “next”, not over live
Target .NET 10 (ASP.NET Core 10) with a framework-dependent publish unless you have a hard reason for self-contained. Prefer Web Deploy when the host exposes it; otherwise publish to a zip or folder in CI and sync with your host’s SFTP/FTPS or file API. Keep secrets out of the repo: use Web Deploy parameters, a host environment variable, or a transformed appsettings.Production.json that never lands in git.
# CI agent example: publish framework-dependent for IIS in-process
dotnet publish .\src\Web\Web.csproj `
-c Release -f net10.0 `
-o .\artifacts\web `
/p:EnvironmentName=Production
# Web Deploy to the inactive physical path (profile or publish settings from host)
msdeploy.exe -verb:sync `
-source:contentPath="$pwd\artifacts\web" `
-dest:contentPath="wwwroot-next",computerName="https://deploy.example:8172/msdeploy.axd?site=contoso", `
userName="deploy-user",password="***",authType="Basic" `
-enableRule:DoNotDeleteRule
If you still ship a classic ASP.NET Framework app alongside Core, keep Web.config transforms (or xdt) for compilation debug flags and custom errors; for ASP.NET Core on IIS, prefer appsettings environment files plus environment variables set in the app pool or host panel. Avoid baking machine-specific SQL passwords into the artifact.
#Cut over with a short, boring window
Goal: no reader hits a half-copied bin folder. On VPS IIS, drain with app_offline.htm only if you must recycle; often a physical-path swap is enough when the new folder is already complete. On shared hosts that only expose one web root, drop app_offline.htm, replace files from the staged build, then remove app_offline.htm.
# VPS / full IIS: swap physical path after publish validates on disk
Import-Module WebAdministration
$site = "Default Web Site/contoso"
$next = "D:\sites\contoso\wwwroot-next"
$live = "D:\sites\contoso\wwwroot-live"
# Optional: brief offline page if you also recycle the pool
# Set-Content -Path (Join-Path $live "app_offline.htm") -Value "<html><body>Updating</body></html>"
Set-ItemProperty "IIS:\Sites\$site" -Name physicalPath -Value $next
# Rename folders so the next release has a clean target
Rename-Item $live "wwwroot-prev-$(Get-Date -Format yyyyMMddHHmm)"
Rename-Item $next "wwwroot-live"
New-Item -ItemType Directory -Path $next | Out-Null
# Remove-Item (Join-Path $live "app_offline.htm") -ErrorAction SilentlyContinue
Restart-WebAppPool -Name "contoso-pool" # only if you need a clean load
Keep the offline page plain HTML with no managed dependencies. If the app pool starts before files finish copying, you get 500.19/500.30 noise that looks like a framework bug and is really a race.
#Smoke-test before you declare victory
Do not trust a green MSDeploy exit code alone. Hit a dedicated health endpoint that exercises the host pipeline you care about: ASP.NET Core on IIS (in-process), config load, and one cheap dependency check (for example SQL connectivity with a SELECT 1 or a ping of a shared cache). Run it from the CI agent or your laptop against the public hostname immediately after the swap.
// Minimal endpoint in ASP.NET Core 10 — map only in non-dev if you prefer
app.MapGet("/health/deploy", async (IConfiguration config, CancellationToken ct) =>
{
var cs = config.GetConnectionString("Default");
await using var conn = new Microsoft.Data.SqlClient.SqlConnection(cs);
await conn.OpenAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT 1";
var scalar = await cmd.ExecuteScalarAsync(ct);
return Results.Ok(new { status = "ok", sql = scalar, utc = DateTime.UtcNow });
});
# Post-swap smoke from CI
$r = Invoke-WebRequest -Uri "https://contoso.example/health/deploy" -UseBasicParsing -TimeoutSec 30
if ($r.StatusCode -ne 200 -or $r.Content -notmatch '"status":"ok"') {
throw "Smoke failed: $($r.StatusCode) $($r.Content)"
}
# Optional: hit a static asset and a Razor/MVC page to catch base-path and static-file misses
If smoke fails, swap physical path back to the previous folder (or restore the prior zip) and recycle once. That rollback path is why you never delete last-known-good until the new release passes. Log the release id (git sha or build number) in the health payload so support can confirm which bits are live.
#Config and slot habits that reduce 3 AM pages
- Separate connection strings per environment; never copy Production secrets into the “next” folder from a dev laptop publish.
- Set ASPNETCORE_ENVIRONMENT on the app pool or host panel; do not rely on a checked-in launchSettings.json.
- Enable only the IIS modules you need; failed module loads after swap look like app bugs.
- Warm the app after cutover (request / and /health/deploy) so the first customer is not the cold start.
- On shared plans, confirm Web Deploy site name and the exact remote path—wrong path is the usual “publish succeeded, site unchanged” report.
Practical takeaway: treat every IIS release as three steps—fill an inactive folder, flip path or replace under app_offline, then run an automated smoke that opens SQL and returns 200. That pattern fits Windows shared hosting and Windows VPS alike, works cleanly with .NET 10 and Web Deploy, and turns “deployed” into “verified under the real hostname” without needing cloud deployment slots.
Comments
No comments yet