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
<?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.

yaml
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.

xml
<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.