Manual zip-and-FTP deploys are how production drifts. One box gets an old web.config, another keeps a stale DLL, and nobody can say which commit is live. For ASP.NET Core 10 on IIS, the reliable path is a publish profile plus Web Deploy driven from CI—same MSBuild flags, same sync rules, every time main moves.
You do not need containers or a full blue-green fabric on shared Windows hosting or a single VPS. You need a locked-down publish output, an MSDeploy endpoint (or agent), careful excludes so App_Data and logs survive, and a smoke request that fails the job if the site does not answer 200 after the sync.
#Publish profile aimed at IIS
Keep environment-specific values out of the repo where you can. The .pubxml should describe how to build and where to push, not production secrets. For ASP.NET Core 10, Framework-Dependent Deployment against the host’s shared runtime is usually enough on managed Windows plans; self-contained only when the server runtime lag is real.
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<WebPublishMethod>MSDeploy</WebPublishMethod>
<PublishProvider>AzureWebSite</PublishProvider>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<SiteUrlToLaunchAfterPublish />
<LaunchSiteAfterPublish>false</LaunchSiteAfterPublish>
<ExcludeApp_Data>true</ExcludeApp_Data>
<MSDeployServiceURL>https://deploy.example.com:8172/msdeploy.axd</MSDeployServiceURL>
<DeployIisAppPath>example.com</DeployIisAppPath>
<RemoteSitePhysicalPath />
<SkipExtraFilesOnServer>true</SkipExtraFilesOnServer>
<MSDeployPublishMethod>WMSVC</MSDeployPublishMethod>
<EnableMSDeployBackup>true</EnableMSDeployBackup>
<UserName />
<_SavePWD>false</_SavePWD>
<TargetFramework>net10.0</TargetFramework>
<SelfContained>false</SelfContained>
<RuntimeIdentifier></RuntimeIdentifier>
</PropertyGroup>
</Project>
SkipExtraFilesOnServer=true is the difference between an incremental content sync and a wipe that deletes user uploads sitting under the site root. Pair it with ExcludeApp_Data (or explicit skip rules) so Web Deploy does not thrash folders IIS and your app both write to at runtime.
#GitHub Actions workflow
Store the Web Deploy password and publish URL as repository or environment secrets. Prefer a least-privilege deploy account that can only sync the target site—not full server admin. On many Windows hosts the endpoint is WMSVC on 8172 with HTTPS; confirm the cert and firewall path before the first pipeline run.
name: deploy-iis
on:
push:
branches: [main]
jobs:
publish:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Restore and publish
run: |
dotnet restore
dotnet publish .\src\Web\Web.csproj -c Release -o .\publish /p:PublishProfile=IIS-Prod
- name: Web Deploy
env:
DEPLOY_USER: ${{ secrets.IIS_DEPLOY_USER }}
DEPLOY_PASS: ${{ secrets.IIS_DEPLOY_PASS }}
run: |
& "${env:ProgramFiles}\IIS\Microsoft Web Deploy V3\msdeploy.exe" `
-verb:sync `
-source:contentPath="$PWD\publish" `
-dest:contentPath="example.com",`
computerName="https://deploy.example.com:8172/msdeploy.axd?site=example.com",`
userName="$env:DEPLOY_USER",`
password="$env:DEPLOY_PASS",`
AuthType="Basic" `
-enableRule:AppOffline `
-skip:objectName=dirPath,absolutePath=App_Data `
-skip:objectName=dirPath,absolutePath=logs `
-allowUntrusted
- name: Smoke test
shell: pwsh
run: |
Start-Sleep -Seconds 5
$r = Invoke-WebRequest -Uri "https://www.example.com/healthz" -UseBasicParsing
if ($r.StatusCode -ne 200) { throw "Smoke failed: $($r.StatusCode)" }
The AppOffline rule drops app_offline.htm for the duration of the sync so in-flight requests drain instead of loading half-written assemblies. That is not true zero downtime, but on a single shared site or one VPS it is the practical cutover: brief maintenance file, file sync, file removed, app starts clean.
#Shared host vs VPS details
- Shared IIS: you usually get a site-scoped Web Deploy user and a fixed IIS app path. You cannot create arbitrary extra sites; stage with a subdirectory app or a second hostname the host already assigned.
- Windows VPS: you control two sites (prod + staging), two app pools, and can warm the staging pool before flipping the hostname binding or reverse-proxy rule.
- Always publish Release, net10.0, matching the ASP.NET Core Module / hosting bundle on the server. Roll-forward policy belongs in the app’s runtimeconfig or host policy—not as a surprise at first request.
- Keep connection strings and Data Protection keys off the payload when possible—machine-level or outside the content root—so a sync cannot clobber them.
#Config that should not ride the zip
Prefer environment-specific appsettings via the host environment name set in IIS (web.config environmentVariable or app pool), not a transformed file that overwrites production on every push. If you still ship a web.config, treat it as structural—handlers, modules, ANCM settings—and keep secrets in connection string stores or external files excluded by skip rules.
<aspNetCore processPath="dotnet"
arguments=".\Web.dll"
stdoutLogEnabled="false"
hostingModel="inprocess">
<environmentVariables>
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
</environmentVariables>
</aspNetCore>
After deploy, the smoke URL should hit something cheaper than a full UI login—a /healthz that checks the app started and, if you choose, a lightweight SQL ping. Fail the pipeline on non-200 so a bad publish does not sit unnoticed until the next customer ticket.
#Takeaway
Treat IIS deploys as a repeatable sync: Release publish for net10.0, Web Deploy with AppOffline and skips for App_Data/logs, secrets outside the content root, and a smoke check in the same job. On shared Windows hosting that is usually one site path; on a VPS you can add a staging site and warm it first. Either way, the goal is the same commit artifact on the server every time—not a hand-copied bin folder that only exists on someone’s laptop.
Comments
No comments yet