We still open tickets where Web Deploy returned success, the site answers 200, and every request is hitting the staging SQL catalog. The zip carried appsettings.Staging.json (or a Production file full of last month's secrets), the app pool never had ASPNETCORE_ENVIRONMENT set, and the worker process guessed wrong. On Windows shared and VPS IIS, environment belongs on the host. Baking it into the publish artifact is how a green pipeline ships the wrong database.
The habit we want teams off of is simple and stubborn: one .pubxml per environment, connection strings embedded at publish time, and a full content sync that overwrites the web.config ops already tuned for stdout logging, hostingModel, and ANCM. .NET 10 on IIS is calmer with a single Release package and configuration applied where the process actually starts—not where the build agent happened to run.
#Per-environment packages fail in predictable ways
On a laptop, `dotnet publish -c Release /p:EnvironmentName=Production` feels tidy. In CI it multiplies. You grow staging.zip and production.zip that differ by a handful of JSON values, then someone points the production site at the staging artifact—or deploys the right zip built from a branch whose appsettings.Production.json still had a shared dev password. Shared IIS makes it worse: you often cannot set machine-wide environment variables, app pool identities are isolated, and the “environment” the pipeline assumed simply does not exist on the worker.
Failure modes we see on the floor, repeatedly:
- Real secrets committed in appsettings.Production.json, or placeholders that CI forgot to replace before pack.
- A transform ran at publish, Web Deploy also ran a parameter replace, and the two disagreed—last writer won silently.
- contentPath sync replaced web.config and dropped the aspNetCore block’s hostingModel, arguments, or stdout settings the host needed.
- A folder cutover copied a physical path that still had ASPNETCORE_ENVIRONMENT=Staging baked into its web.config into the live binding.
Opinionated rule: do not maintain two publish outputs that differ only by environment name and connection strings. That is configuration drift with extra zip files. Build once. Bind environment where IIS starts the process.
#Bind ASPNETCORE_ENVIRONMENT on the host
For ASP.NET Core under IIS—in-process or out-of-process—the reliable levers are an app pool environment variable (VPS/dedicated where you own applicationHost.config) or an environmentVariables child under the site’s aspNetCore element in web.config (works on many shared plans when pool edits are locked down). Prefer the pool when you can; it survives accidental web.config overwrites. Use web.config when the control panel owns the pool. Either way, set the name the host framework actually reads at startup.
<aspNetCore processPath="dotnet"
arguments=".\MyApp.dll"
stdoutLogEnabled="false"
hostingModel="inprocess">
<environmentVariables>
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
</environmentVariables>
</aspNetCore>
That is enough for configuration providers to load appsettings.Production.json. Keep secrets out of that file when you can: non-secret defaults and feature flags in JSON, connection strings from IIS connection string settings, deploy-time parameters, or a vault the app reads at startup. Do not treat the environmentVariables block as a dumping ground for passwords in plain text on shared disks.
# VPS / dedicated — set on the pool so web.config syncs cannot wipe it
& "$env:windir\system32\inetsrv\appcmd.exe" set config `
-section:system.applicationHost/applicationPools `
/+"[name='MyAppPool'].environmentVariables.[name='ASPNETCORE_ENVIRONMENT',value='Production']" `
/commit:apphost
After changing pool environment variables, recycle the pool once so the new worker process picks them up. Confirm with a temporary diagnostic endpoint or stdout logging that Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") is Production in the running app—not what CI printed during publish.
#Keep the publish profile boring
A useful .pubxml chooses Configuration, output layout, and whether to delete existing files. It should not invent a second source of truth for environment. Leave EnvironmentName empty or omit it so publish does not pretend to know Production. Prefer framework-dependent publish on IIS hosts that already have the .NET 10 hosting bundle; self-contained zips are larger and still do not excuse baked secrets.
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<!-- Host sets ASPNETCORE_ENVIRONMENT; do not bake it here -->
<PublishProtocol>FileSystem</PublishProtocol>
<DeleteExistingFiles>false</DeleteExistingFiles>
<SelfContained>false</SelfContained>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>
From CI or a release machine: `dotnet publish .\src\MyApp\MyApp.csproj -c Release -o .\artifacts\web`. Ship appsettings.json with safe defaults. Ship appsettings.Production.json only when it holds non-secrets (log levels, public URLs, feature flags). Connection strings and API keys stay out of the artifact. Locally use user secrets; in deploy use Web Deploy parameters or host-level overrides. Do not run publish with `/p:EnvironmentName=Production` and assume IIS will honor it—the running ANCM worker still needs ASPNETCORE_ENVIRONMENT (or DOTNET_ENVIRONMENT) when the process starts. Publish-time EnvironmentName mainly drives transforms and file selection in the package, which is exactly the baking we are trying to stop.
#Web Deploy without clobbering host config
msdeploy verb:sync with contentPath is powerful and blunt. With delete enabled it removes files the app wrote under App_Data, data-protection keys, and upload folders. It also happily replaces web.config end-to-end, which undoes the aspNetCore environmentVariables block, custom rewrite rules, and locked sections you set through the panel. On shared plans, file locks during recycle make full wipe-and-replace a good way to serve 503s longer than you intended.
msdeploy -verb:sync `
-source:contentPath=.\artifacts\web `
-dest:contentPath=MySite,computerName=https://deploy.example:8172/msdeploy.axd,authType=Basic,userName=...,password=... `
-skip:objectName=dirPath,absolutePath=.*\\App_Data `
-skip:objectName=filePath,absolutePath=.*\\logs\\.* `
-enableRule:DoNotDeleteRule
Skip rules are a judgment call. Skipping web.config entirely means first-time provision needs a one-shot config push or panel edit; after that, app binaries and static files sync cleanly while host-owned settings stay put. If you must inject a connection string at deploy time, prefer a narrow Web Deploy parameter over rewriting the whole file—and still keep ASPNETCORE_ENVIRONMENT on the pool or in a web.config you are not blindly overwriting every release. DeleteExistingFiles=true in the publish profile plus an aggressive sync is how production lost its keys last Tuesday; default to false unless you know the site is empty.
#Pipeline shape and a short smoke check
Keep the pipeline dull: restore, test, one Release publish, deploy the same artifact to the target path (or to a staging physical path if you cut over folders), confirm the target app pool or site already has ASPNETCORE_ENVIRONMENT=Production, recycle only if you changed pool settings, then smoke. Do not build a second zip “for production.” Do not branch appsettings files in source to encode host names. If you use a pre-prod site on the same box, give that pool Staging and keep the artifact identical—only the host binding changes.
After deploy, hit a readiness endpoint that fails closed when SQL is wrong—not a liveness ping that only proves Kestrel accepted a socket. A minimal external check is enough for the deploy job:
$r = Invoke-WebRequest -Uri "https://site.example/health/ready" -UseBasicParsing
if ($r.StatusCode -ne 200) { throw "Ready check failed: $($r.StatusCode)" }
# Optional: assert a build id header your middleware sets from AssemblyInformationalVersion
if ($r.Headers["X-Build-Id"] -ne $env:BUILD_ID) { throw "Build id mismatch after deploy" }
If you expose environment name on an internal-only diagnostic route during bring-up, strip it before the site is public. The point of the smoke is to catch “we deployed Staging config to the live binding” in the same job that pushed bits—not an hour later when orders write to the wrong catalog. Pair that with Query Store or a quick row-count sanity check on a known table if the release touches schema; the deploy job should own the first proof, not the on-call phone.
One Release zip, environment bound on the app pool or a protected aspNetCore block, Web Deploy skips that refuse to trash App_Data and host web.config, and a ready check that cares about SQL—not just a TCP accept. That path works the same on shared IIS and Windows VPS without maintaining a forest of environment-specific packages that eventually get swapped by mistake.
Comments
No comments yet