Most broken deploys we clean up are not failed builds. They are successful publishes that replaced the wrong files. A green `dotnet publish` plus Web Deploy happily overwrites `appsettings.Production.json`, custom `web.config` rewrite sections, and sometimes the Data Protection key ring path an operator set last quarter. On shared IIS and single-box Windows VPS hosts you rarely get Azure-style slots, so the publish target is the live site root. If your pipeline treats that root like a disposable container filesystem, production secrets and host wiring go with the last good bits.
The fix is boring and reliable: ship application code and static assets; leave host-owned config alone; inject environment values outside the package; smoke the site before you call the release done. Below is how we tell teams to structure publish profiles and CI for .NET 10 on Windows hosts without turning every release into a config restore from backup.
#What the package should never own
On a Windows host the app pool identity, SQL connection string, TLS termination story, and URL Rewrite rules often live in files under the site root even when they are really environment concerns. Visual Studio and `dotnet publish` will include whatever is in the project unless you stop them. We still see repos that commit a filled-in `appsettings.Production.json` “for convenience,” then wonder why the next developer’s publish pointed a customer site at a staging database.
- Do not publish production connection strings, API keys, or Data Protection keys inside the artifact.
- Do not let transforms stomp IIS sections an operator manages in the control panel (rewrite, customErrors, compression) unless that is intentional and reviewed.
- Do not assume a full site delete-and-replace is safe because “it works on my Kestrel box.”
Opinionated default: the artifact contains binaries, views/static files, and non-secret defaults. Production values come from the host—app pool environment variables, a server-side `appsettings.Production.json` excluded from deploy, or Web Deploy parameters applied at sync time without storing secrets in git.
#Publish profiles that skip host-owned files
A `.pubxml` aimed at IIS should declare the publish method, the target runtime if you are framework-dependent on the host’s shared .NET 10 install, and skip rules for paths the pipeline must not touch. Skip rules are the difference between updating `MyApp.dll` and wiping the connection string file someone dropped beside it.
<Project>
<PropertyGroup>
<WebPublishMethod>MSDeploy</WebPublishMethod>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<TargetFramework>net10.0</TargetFramework>
<SelfContained>false</SelfContained>
<MSDeployPublishMethod>WMSVC</MSDeployPublishMethod>
<EnableMSDeployBackup>true</EnableMSDeployBackup>
<ExcludeApp_Data>true</ExcludeApp_Data>
</PropertyGroup>
<ItemGroup>
<MsDeploySkipRules Include="SkipAppSettingsProd">
<ObjectName>filePath</ObjectName>
<AbsolutePath>appsettings\.Production\.json</AbsolutePath>
</MsDeploySkipRules>
<MsDeploySkipRules Include="SkipDataProtectionKeys">
<ObjectName>dirPath</ObjectName>
<AbsolutePath>keys</AbsolutePath>
</MsDeploySkipRules>
<MsDeploySkipRules Include="SkipAppData">
<ObjectName>dirPath</ObjectName>
<AbsolutePath>App_Data</AbsolutePath>
</MsDeploySkipRules>
</ItemGroup>
</Project>
Enable MSDeploy backup on hosts that support it so a bad sync has a restore point. Keep FDD (framework-dependent) as the default on shared Windows plans that already ship .NET 10; self-contained publishes are larger and easier to leave orphaned runtimes on disk when you forget to clean old folders. If you must transform `web.config`, limit XDT to app-specific settings you truly own—never blanket-replace the whole file on every release.
#Layer configuration so IIS can own production
ASP.NET Core configuration already merges in a fixed order. Use that instead of baking one environment into the zip. Ship `appsettings.json` with safe defaults and non-secret feature flags. Leave `appsettings.Production.json` on the server (or inject values through the app pool). Map secrets to environment variables the worker process already sees.
var builder = WebApplication.CreateBuilder(args);
// Host/env vars and server-side appsettings.Production.json win over the published defaults.
builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: false)
.AddEnvironmentVariables(prefix: "APP_");
builder.Services.AddOptions<SqlOptions>()
.Bind(builder.Configuration.GetSection("Sql"))
.Validate(o => !string.IsNullOrWhiteSpace(o.ConnectionString), "Sql:ConnectionString missing")
.ValidateOnStart();
`ValidateOnStart` turns a missing connection string into an immediate process failure instead of a half-up site that 500s on the first request. On IIS, set `ASPNETCORE_ENVIRONMENT=Production` and `APP_Sql__ConnectionString=...` on the app pool (double underscore for nested keys). That keeps the secret out of the deploy package and out of source control. For classic `web.config` connectionStrings still used by older ASP.NET modules alongside ANCM, prefer a one-time server edit or a parameterized set at provision time—not a git-tracked transform that every developer machine re-applies.
#CI: build once, publish deliberately, verify before you celebrate
Pipelines should produce a single Release artifact from `dotnet publish`, then push that artifact with Web Deploy or a constrained file sync. Do not rebuild on the server. Do not publish from a developer laptop “because the profile works.” Wire credentials as pipeline secrets (WMSVC URL, site name, username) and fail the job if the sync returns non-zero.
# Example release step after dotnet publish -c Release -o .\out
$publishDir = ".\out"
$msdeploy = "$env:ProgramFiles\IIS\Microsoft Web Deploy V3\msdeploy.exe"
& $msdeploy `
-verb:sync `
-source:contentPath=$publishDir `
-dest:contentPath=Default Web Site/MyApp,computerName="https://deploy.example:8172/msdeploy.axd?site=MyApp",userName=$env:DEPLOY_USER,password=$env:DEPLOY_PASS,AuthType=Basic `
-enableRule:DoNotDeleteRule `
-skip:objectName=filePath,absolutePath=appsettings\.Production\.json `
-skip:objectName=dirPath,absolutePath=keys `
-retryAttempts=2
if ($LASTEXITCODE -ne 0) { throw "Web Deploy failed: $LASTEXITCODE" }
# Cheap smoke: anonymous health endpoint must return 200 before the job goes green
$resp = Invoke-WebRequest -Uri "https://myapp.example/health/live" -UseBasicParsing -TimeoutSec 30
if ($resp.StatusCode -ne 200) { throw "Smoke failed: $($resp.StatusCode)" }
`DoNotDeleteRule` stops the sync from removing server-only files that are not in the artifact. Pair it with explicit skips for production settings and key material. The smoke line is intentionally dumb: hit a live or ready endpoint that exercises configuration bind and a SQL `SELECT 1` if you have one. Deep load tests belong elsewhere; this gate only answers “did we publish a process that starts and can reach its dependencies?” Run it against the same hostname customers use so you catch binding and HTTPS issues, not only loopback.
#Zero-downtime-ish without pretending you have slots
Shared IIS and many VPS layouts will not give you first-class deployment slots. You still can avoid the worst cutovers. Prefer overlapping file sync with skips over delete-everything publishes. Keep app pool overlapping recycle enabled so in-flight requests drain. For larger swaps, publish to a parallel folder under the site, warm that path, then shift the IIS application physical path—or use a brief `app_offline.htm` only when you must take exclusive locks on assemblies. None of that replaces keeping secrets off the package; it only reduces the window where users see failures.
Do not do this: overwrite `web.config` on every build “to be sure,” commit machine-specific publish profiles with passwords, or run CI as a full site wipe because an old DLL once stuck around. Leftover DLLs are a packaging hygiene problem—use a clean publish directory and an allow-list of what lands on the server—not an excuse to delete host config.
When a deploy still goes wrong, the first artifacts we ask for are the MSDeploy exit code, the skip list you thought you used, the app pool environment variables, and whether `appsettings.Production.json` on disk still matches pre-deploy. Nine times out of ten the code is fine and the host config was replaced by a file from the repo. Structure the pipeline so that cannot happen by default, and .NET 10 releases on Windows stay routine instead of forensic.
Comments
No comments yet