We can usually tell a blind publish from the ticket: site comes up, login 500s, and the connection string still points at the developer’s SQL box. The binary was fine. The package carried the wrong config, or nobody hit a health URL before DNS and bookmarks did.
On Windows shared and VPS hosts the deploy path is still Web Deploy (or a zip sync that behaves like it), not a container rollout. Treat publish as three steps that must stay separate: build a host-shaped package, inject environment values at deploy time, then smoke the site before you call it done.
#Publish profiles that match the IIS site
A profile is not a souvenir from Visual Studio. It encodes RuntimeIdentifier expectations, whether you ship framework-dependent or self-contained, and which web.config ANCM bits you allow the publish targets to rewrite. For .NET 10 on current Windows Server images we default to framework-dependent when the host already has the shared runtime; self-contained only when you control the VPS patch story and need a pinned runtime beside the app.
Keep one profile per destination shape (shared IIS site, staging folder on the same pool, internal VPS). Do not reuse a laptop “FolderProfile” that drops a development appsettings file into production. Delete WebPublishMethod=FileSystem profiles from the repo if the real path is MSDeploy.
<Project>
<PropertyGroup>
<WebPublishMethod>MSDeploy</WebPublishMethod>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<TargetFramework>net10.0</TargetFramework>
<SelfContained>false</SelfContained>
<PublishProvider>AzureWebSite</PublishProvider>
<ExcludeApp_Data>true</ExcludeApp_Data>
<MSDeployPublishMethod>WMSVC</MSDeployPublishMethod>
<EnableMSDeployAppOffline>true</EnableMSDeployAppOffline>
<SkipExtraFilesOnServer>true</SkipExtraFilesOnServer>
</PropertyGroup>
</Project>
SkipExtraFilesOnServer protects uploaded content and logs you did not mean to wipe. EnableMSDeployAppOffline is the blunt shared-host cutover: ANCM drains while app_offline.htm sits in the site root. On a VPS with two physical folders you can do a warmer swap; on a single shared site root, offline is still the honest tool.
#Transforms and parameters, not checked-in secrets
ASP.NET Core reads appsettings.json, environment variables, and (on IIS) settings pushed into the process. web.config transforms still matter for ANCM hostingModel, stdout logging, and processPath—but SQL credentials and API keys should never ride in the zip as plaintext “production” files. We keep seeing repos where appsettings.Production.json is the real password store. That file will leak in a backup, a support zip, or a bad Skip rule.
Prefer deploy-time parameters. Mark connection strings and environment names as MSDeploy parameters so CI supplies them from a secret store. The package stays identical across staging and live; only the parameter set changes.
<parameters>
<parameter name="ASPNETCORE_ENVIRONMENT"
defaultValue="Production"
tags="IisApp">
<parameterEntry kind="DestinationAppPool" scope="" match="" />
</parameter>
<parameter name="DbConnection"
description="SQL connection string"
defaultValue="" tags="SqlConnectionString">
<parameterEntry kind="XmlFile"
scope="\\appsettings\.Production\.json$"
match="/ConnectionStrings/DefaultConnection/text()" />
</parameter>
</parameters>
If you still use classic Web.config transforms for a Framework app or for ANCM attributes, keep them mechanical: environment name, stdoutLogEnabled off in production, and nothing that embeds secrets. Opinionated don’t: do not transform a password into the package “just for shared hosting.” Shared hosting is exactly where filesystem reads and mis-set ACLs show up.
#CI publishes the package; it does not invent the host
Pipeline job: restore, test, dotnet publish -c Release, then msdeploy (or dotnet msdeploy) against WMSVC with the site name the host gave you. Pass parameters from CI secrets. Fail the job if the sync returns non-zero—partial syncs leave half-written deps and are worse than no deploy.
$pkg = ".\publish\App.zip"
$args = @(
"-verb:sync",
"-source:package=$pkg",
"-dest:auto,ComputerName=https://deploy.example:8172/msdeploy.axd?site=contoso,",
"username=$env:DEPLOY_USER,password=$env:DEPLOY_PASS,AuthType=Basic",
"-setParam:name='DbConnection',value=$env:SQL_CONN",
"-setParam:name='ASPNETCORE_ENVIRONMENT',value=Production",
"-enableRule:AppOffline",
"-allowUntrusted" # only if your host uses a private WMSVC cert
)
& "$env:ProgramFiles\IIS\Microsoft Web Deploy V3\msdeploy.exe" $args
if ($LASTEXITCODE -ne 0) { throw "Web Deploy failed: $LASTEXITCODE" }
Use the site-scoped WMSVC URL the panel provides; do not aim at the whole server from a shared plan. On a Windows VPS you own the pool identity and can publish to a second folder, flip the site path, then recycle once—still parameterize the same way so both folders never bake secrets into source control.
#Smoke the site before humans do
A green msdeploy exit code means files landed. It does not mean EF can migrate, Data Protection keys loaded, or the SQL user still has rights after a credential rotation. Gate the release on a short, authenticated-as-anonymous script against the real hostname (or the staging hostname if you flipped a folder).
$base = "https://www.example.com"
$paths = @("/health/live", "/health/ready", "/")
foreach ($p in $paths) {
$r = Invoke-WebRequest -Uri ($base + $p) -UseBasicParsing -TimeoutSec 30
if ($r.StatusCode -ge 500) { throw "Smoke fail $p -> $($r.StatusCode)" }
Write-Host "OK $p $($r.StatusCode)"
}
# Optional: hit a read-only API that forces one SQL round-trip
$sql = Invoke-RestMethod "$base/api/version"
if (-not $sql.database) { throw "API up but database marker missing" }
- Check live/ready separately if you use ASP.NET Core health checks—live can pass while ready fails on SQL.
- Assert response headers you care about (HSTS, no server junk) so a bad web.config transform fails the gate.
- Run one write-free SQL path; leave destructive migrations to an explicit job with a human approval bit.
- If app_offline was used, confirm it is gone and the first request is not still compiling views or hitting a cold pool without preload.
#Shared IIS vs VPS: same discipline, different levers
On shared IIS you rarely get a second site slot under the same hostname. You get Web Deploy rights to one site root, app_offline, and maybe a staging subdomain if you create it. Parameter sets and smoke tests matter more there because rollback is “publish the previous package,” not a load-balancer flip. Keep the last good zip in CI artifacts for that reason.
On a Windows VPS you can run two folders, two pools, or a rewrite rule that points at a warm target. Still do not hand-edit connection strings on the box after sync—the next pipeline run will clobber them or drift from what you tested. Environment variables at the pool level and MSDeploy parameters stay the source of truth.
Zero-downtime-ish on this stack means: drain with app_offline or an overlapping pool, never overwrite in place while requests hold file locks, and only remove the gate when smoke is green. Full blue/green with zero dropped connections is a multi-node problem; most ASP.NET apps on a single IIS host are aiming at “no half-deployed DLL and no wrong SQL catalog,” which is achievable if you stop treating publish as a file copy with hope attached.
Comments
No comments yet