Locked Files When Publishing .NET Core Apps to IIS with MSDeploy



Last updated: October 28th, 2026

Updated October 2026: added a quick answer, the corrected deployment script (the retry function now comes before it is used), and notes for .NET 8 and .NET 10. The shadow-copy fix below is still the approach that works reliably on self-hosted IIS.

Quick Answer

  • The error: "Web Deploy cannot modify the file 'MySite.dll' on the destination because it is locked by an external process." The running ASP.NET Core app holds its own DLLs open.
  • MSDEPLOY_RENAME_LOCKED_FILES=1 is an Azure App Service app setting (handled by Kudu). It does nothing on your own IIS server.
  • The fix on IIS: turn on ASP.NET Core shadow copying in web.config (enableShadowCopy + shadowCopyDirectory). IIS then runs the app from a copy, so Web Deploy can overwrite the originals at any time. It is supported in the ASP.NET Core Module from .NET 7 onward.

Problem

When deploying an ASP.NET Core application to IIS on Windows Server 2022, I sporadically encountered a problem where one or more .dll files were locked. This prevented the deployment from working.

The error: "Web Deploy cannot modify the file 'MySite.dll' on the destination because it is locked by an external process. In order to allow the publish operation to succeed, you may need to either restart your application to release the lock, or use the AppOffline rule handler for .Net applications on your next publish attempt."

On Azure, this issue is addressed and files are renamed with the flag MSDEPLOY_RENAME_LOCKED_FILES=1, as detailed here. On a VPS or hardware instance of IIS, however, there is no such option.

It's imperative to have a reliable release process. Logging onto the server and running iisreset is a last resort. It shouldn't be part of a release process.

Unfortunately, the file locking problem has followed .NET Core since its inception, as seen here.

The error message suggests the AppOffline rule (which drops an app_offline.htm file so the app shuts down) or restarting the application. In my testing neither was reliable on its own. It can be deceiving to believe they work, since the error only shows up sporadically.

Attempts At A Fix

None of these worked consistently:

  • Deleting the web.config file with MSDeploy, then deploying (not a good practice for security reasons)
  • Stopping the application pool with appcmd, then deploying
  • Stopping the site with appcmd, then deploying
  • Recycling the application pool with appcmd, then deploying
  • Placing an app_offline.htm file in root of the application, then deploying

On the first deployment there isn't a lock issue, but in practice it is the updates that matter. Unless there is a load balancer and a fresh directory to point to, this issue must be addressed.

I tried every AppCmd.exe command that should have addressed the issue. Running commands through MSDeploy, I ran into the permission error "A required privilege is not held by the client." To address it, I had to go onto the web server, run the commands outlined here, change permissions and restart the Web Deploy service.

Still, nothing I did with appcmd (stop or recycle, then deploy, then start) solved the problem. Stopping the application pool or the site doesn't release file locks, at least not in a predictable or timely manner.

Solution: Shadow Copying

The only solution I found, short of deploying to a new folder each time and switching the site's path, was to have IIS run a shadow copy of the app, configured in web.config as described in the ASP.NET Core IIS documentation.

Change YOURAPP.exe to your project's executable name:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <system.webServer>
    <handlers>
      <remove name="aspNetCore" />
      <add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
    </handlers>
    <aspNetCore processPath=".\YOURAPP.exe" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" hostingModel="inprocess">
      <handlerSettings>
        <!-- IIS runs a copy of the app from this folder, so the deployed files are never locked -->
        <handlerSetting name="enableShadowCopy" value="true" />
        <!-- The app pool identity needs Modify permission on this folder -->
        <handlerSetting name="shadowCopyDirectory" value="../ShadowCopyDirectory/" />
      </handlerSettings>
    </aspNetCore>
  </system.webServer>
</configuration>

How it works: the ASP.NET Core Module copies the app to shadowCopyDirectory and runs it from there. The files Web Deploy writes to are no longer the ones in use, so they can always be replaced. When web.config or the DLLs change, the module detects it, copies the new version and restarts the app.

  • .NET 6: the setting was experimental and named experimentalEnableShadowCopy.
  • .NET 7, 8, 10: use enableShadowCopy as shown. Make sure the server has the matching (or newer) ASP.NET Core Hosting Bundle installed.
  • Put the shadow copy folder outside the site folder (the ../ above), or Web Deploy will try to sync or delete it too.

After the MSDeploy command runs with this change, the site isn't instantly ready; the new version starts in the background, which takes roughly 10 seconds for my apps. I poll the site until it returns a success status. That is still far better than a failed deployment.

The Deployment Script

MSDeploy is at C:\Program Files\IIS\Microsoft Web Deploy V3\msdeploy.exe (download Web Deploy). This PowerShell script syncs a published folder and waits for the site to respond:

# deploy.ps1 - sync a published folder to IIS with Web Deploy, then wait for the site to come up
$msDeploy          = "C:\Program Files\IIS\Microsoft Web Deploy V3\msdeploy.exe"
$siteName          = "MY_SITE_NAME"
$publishFolder     = Resolve-Path "$PSScriptRoot\publish"
$msDeployServer    = "https://YOUR_SERVER:8172"
$msDeployUserName  = $env:MSDEPLOY_USER
$msDeployPassword  = $env:MSDEPLOY_PASSWORD

# Functions must be defined before they are called in a script.
function Invoke-WithRetry {
    param(
        [Parameter(Mandatory)] [scriptblock] $ScriptBlock,
        [int] $Maximum = 10,
        [int] $DelaySeconds = 3
    )
    for ($attempt = 1; $attempt -le $Maximum; $attempt++) {
        try {
            & $ScriptBlock
            return
        }
        catch {
            Write-Host "Attempt $attempt of $Maximum failed: $($_.Exception.Message)"
            Start-Sleep -Seconds $DelaySeconds
        }
    }
    throw "Gave up after $Maximum attempts."
}

Write-Host "Syncing files to '$siteName'..."
& $msDeploy `
    -verb:sync `
    -source:IisApp="$publishFolder" `
    -dest:IisApp="$siteName",computerName="$msDeployServer/msdeploy.axd?site=$siteName",authType=Basic,userName=$msDeployUserName,password=$msDeployPassword `
    -allowUntrusted:true `
    -enableRule:AppOffline

if ($LASTEXITCODE -ne 0) { throw "msdeploy failed with exit code $LASTEXITCODE" }

# With shadow copying the new version starts in the background; poll until it answers 200.
$url = "https://$siteName/"
Invoke-WithRetry -ScriptBlock {
    $response = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 30
    if ($response.StatusCode -ne 200) { throw "HTTP $($response.StatusCode)" }
    Write-Host "Site is up: $url"
}

During the Transition

While the site is coming online, visitors briefly see the app-offline or startup page (a 503) instead of an error page. That is better for users and for security than a half-deployed app. If the site never transitions from 503 to 200, the deployment has a real problem beyond file locking: check the stdout log or the Windows Event Log.

Other Options

  • Blue/green folders: publish each release to a new folder and point the IIS site at it. No locks at all, at the cost of more scripting.
  • Load balancer: take one server out of rotation, deploy, put it back.
  • Azure App Service: set the app setting MSDEPLOY_RENAME_LOCKED_FILES=1, or deploy with Run From Package, which mounts a read-only zip and avoids locks entirely.

Frequently Asked Questions

What does MSDEPLOY_RENAME_LOCKED_FILES do?

On Azure App Service, setting MSDEPLOY_RENAME_LOCKED_FILES=1 tells the deployment engine to rename files that are locked so new copies can be written. It is an Azure (Kudu) feature, not a Web Deploy switch, so it has no effect on a self-hosted IIS server.

Why is my DLL locked when deploying to IIS?

The running app (in w3wp.exe for in-process hosting, or in your app's own process for out-of-process hosting) has its assemblies open. Stopping the app pool releases them eventually, but not reliably within the time Web Deploy waits.

Does -enableRule:AppOffline fix locked files?

It helps: Web Deploy drops app_offline.htm so the ASP.NET Core Module shuts the app down before syncing. In practice the shutdown isn't always finished in time, which is why the error still appears now and then. Combining it with shadow copying is reliable.

Summary

On self-hosted IIS, the reliable fix for locked DLLs during Web Deploy is ASP.NET Core shadow copying, plus a script that polls the site until the new version responds. MSDEPLOY_RENAME_LOCKED_FILES only applies to Azure.

If you're interested in the GitHub discussion on shadow copying for ASP.NET Core, see this issue. Related: keeping IIS sites alive after a deployment or recycle.


Comments

No Comments

CAPTCHA



Join my email list!



ryan
About Me

With over 15 years in tech, I've excelled as a senior software engineer, specializing in ASP.NET, C#, SQL, Azure, and front-end technologies. I've led diverse projects across various sectors, from startups to global corporations, particularly during my decade in the San Francisco Bay Area.


Sign Up Hostinger Email