Most Windows shared and VPS hosts do not give you Azure App Service deployment slots. You still need a safer path than overwriting the live wwwroot while users hit the site. A two-folder layout plus a physical-path swap gets you most of the way to zero-downtime-ish deploys without containers or a second public hostname.

The pattern is simple: keep two complete app trees under the site (for example app_a and app_b), always publish to the idle tree, run a smoke check against that path, then point IIS at the fresh tree in one IIS Manager or appcmd change. Rollbacks are the same operation in reverse.

#Why in-place publish still hurts

Copying new DLLs onto a running ASP.NET Core app pool is a race. File locks, half-written assemblies, and mid-request shadow-copy failures show up as 500.30 / 500.0 blips even when ANCM recovers. app_offline.htm helps drain traffic, but it is still a hard offline window. A sibling folder lets the live pool keep serving while you stage the next build.

On Server 2025 with IIS 10.0 this is ordinary site administration: one site, one binding, two physical paths you alternate. No second certificate, no DNS flip, no container orchestrator.

#Layout and publish target

Under the site root, create two directories and a tiny marker file that records which one is live. Keep user uploads and writeable data outside both trees (a shared App_Data or a path outside the site) so swaps never orphan files.

  • D:\sites\contoso\app_a — full published output
  • D:\sites\contoso\app_b — full published output
  • D:\sites\contoso\data — logs, uploads, anything mutable
  • D:\sites\contoso\current.txt — contains a or b

Publish framework-dependent for .NET 10 unless you have a hard reason to ship self-contained. From CI or a build box, target the idle folder only:

powershell
$root = "D:\sites\contoso"
$live = (Get-Content "$root\current.txt").Trim()
$idle = if ($live -eq "a") { "b" } else { "a" }
$dest = Join-Path $root "app_$idle"

dotnet publish .\src\Contoso.Web\Contoso.Web.csproj `
  -c Release -r win-x64 --self-contained false `
  -o $dest

# Optional: Web Deploy to the same idle path on a remote host
# msdeploy.exe -verb:sync -source:contentPath=$dest `
#   -dest:contentPath="D:\sites\contoso\app_$idle",computerName=...

Environment-specific values belong in the idle tree before you flip. Prefer a transform or a small post-publish step that writes connection strings and ASPNETCORE_ENVIRONMENT into that folder’s web.config so the live tree stays untouched until cutover.

xml
<aspNetCore processPath="dotnet"
            arguments=".\Contoso.Web.dll"
            stdoutLogEnabled="false"
            hostingModel="inprocess">
  <environmentVariables>
    <environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
    <environmentVariable name="ConnectionStrings__AppDb"
                         value="Server=...;Database=Contoso;..." />
  </environmentVariables>
</aspNetCore>

#Smoke-test the idle tree before traffic

Do not flip on a green CI build alone. Probe the idle bits the same way IIS will run them. Two practical options on a VPS: (1) a temporary second site bound to 127.0.0.1:port pointing at the idle folder, or (2) a one-shot `dotnet Contoso.Web.dll` with URLs set to localhost for process-level checks. Prefer the temporary IIS site so ANCM, web.config, and the app pool identity match production.

powershell
# Assume a local probe site already points at app_$idle on http://127.0.0.1:9080
$base = "http://127.0.0.1:9080"
$checks = @("/health/live", "/", "/api/version")

foreach ($path in $checks) {
  $r = Invoke-WebRequest -Uri ($base + $path) -UseBasicParsing -TimeoutSec 30
  if ($r.StatusCode -ge 500) { throw "Smoke failed: $path -> $($r.StatusCode)" }
  Write-Host "OK $($r.StatusCode) $path"
}

Keep probes cheap: a live check that does not touch SQL, a home page hit, and a build/version endpoint that returns the assembly informational version. If you already expose ASP.NET Core health endpoints, call the lightest one here—this step is verification, not a redesign of your health model.

#Flip the IIS physical path

When smokes pass, repoint the public site. App pools can keep running; IIS picks up the new path for new requests. Overlap is short. On hosts where you lack full server admin, the same idea works as two applications under one site or two virtual apps you swap with support—conceptually identical.

powershell
$site = "contoso.com"
$root = "D:\sites\contoso"
$live = (Get-Content "$root\current.txt").Trim()
$next = if ($live -eq "a") { "b" } else { "a" }
$path = Join-Path $root "app_$next"

Import-Module WebAdministration
Set-ItemProperty "IIS:\Sites\$site" -Name physicalPath -Value $path
Set-Content "$root\current.txt" -Value $next -NoNewline

# Confirm
(Get-ItemProperty "IIS:\Sites\$site").physicalPath

Watch the first minute of logs after the flip. If something escaped smoke tests, set physicalPath back to the previous folder and restore current.txt—the prior build is still intact. Schedule old-tree cleanup only after you are confident; keeping one prior release on disk is cheap insurance.

Practical takeaway: treat “slots” on Windows hosting as two published folders and one IIS path pointer. Publish only to the idle tree, inject env-specific config there, smoke-test through a localhost IIS binding that mirrors production, then flip physicalPath in a single step. You get reversible cutovers on shared and VPS IIS without pretending you have cloud deployment slots—and without dropping half-written binaries onto a live app pool.