You copy a new build of your ASP.NET Core app to IIS and the copy fails half-way: "The process cannot access the file 'MyApp.dll' because it is being used by another process." Someone on Stack Overflow says "use app_offline.htm". You drop the file in, and either nothing changes, or the site shows a blank page, or IIS's own 503 page, or it stays offline after you deleted the file.
app_offline.htm is a small feature with very little documentation. Microsoft's page on it is about 360 words and doesn't even say which status code it returns. Most of what you find on blogs and forums was written for classic ASP.NET on IIS 7, and some of it, like "the file must be at least 512 bytes", is wrong for ASP.NET Core.
So for this guide I read the source of the ASP.NET Core Module (ANCM), the IIS module that implements the feature, and checked each rule against it. Every fact below links to the line it comes from. I also use the file on every deploy of this site's own API, an ASP.NET Core 9 app running in-process on shared Windows hosting with Plesk, so the deploy sections are the steps I actually run, including the one that cost me 40 uploaded images.
Short version: put a file named
app_offline.htmin the folder that holds your app'sweb.config. ANCM shuts the app down (waiting up toshutdownTimeLimit, 10 seconds by default), which releases the locked DLLs, and answers every request with 503 Service Unavailable and the file's contents. Delete the file and the next request starts the app again. If it "doesn't work", the cause is almost always the folder, the file name, anhttpErrorssetting that replaces the page, or copying before the app has finished shutting down.
What app_offline.htm actually does in ASP.NET Core
In classic ASP.NET (.NET Framework) the ASP.NET runtime handled the file. In ASP.NET Core it is ANCM, the AspNetCoreModuleV2 you see in your web.config. Here is what the module does, read from its source:
| Question | Answer | Source |
|---|---|---|
| Status code | 503 Service Unavailable, every time. You can't change it. | AppOfflineHandler.cpp |
| Content-Type |
text/html, with no charset. The body is the file's bytes, unchanged. |
AppOfflineHandler.cpp |
| Does IIS replace it with its own error page? | Not by default. ANCM sets the "try skip custom errors" flag, which IIS honours unless httpErrors existingResponse="Replace". |
AppOfflineHandler.cpp |
| Where it must be | The application's physical path, the folder with web.config. Subfolders are not checked. |
PollingAppOfflineApplication.cpp |
| Is the name case-sensitive? | No. App_Offline.htm works. app_offline.html does not. |
filewatcher.cpp (_wcsnicmp) |
| Minimum size | None. An empty file still takes the app offline. Visitors get a 503 with a blank body. | AppOfflineApplication.cpp |
| Maximum size | Under 4 GB. Anything bigger is rejected. (Some hosts set their own lower limit.) | AppOfflineApplication.cpp |
| How fast it notices | While the app runs, a directory watcher reacts as soon as the file appears. While the app is offline, the module checks whether the file still exists at most every 200 ms. | filewatcher.cpp, PollingAppOfflineApplication.h |
| Static files too? | Yes, if they go through ANCM. The standard web.config maps path="*" to the module, so CSS, images and JS from wwwroot all get the 503 as well. |
your web.config
|
You can see the response for yourself while the file is in place:
curl -sI https://example.com/
HTTP/1.1 503 Service Unavailable
Content-Type: text/html
...
The 503 status is good for SEO. It tells Google the outage is temporary, so a few minutes of maintenance won't drop your pages from the index. A maintenance page served with status 200 would.
What happens to the running app
The point of the file isn't the page. It's that it makes the app let go of its files. Windows won't let you overwrite a DLL that a running process has loaded, and an ASP.NET Core app loads its DLLs straight from the folder you deploy into.
-
In-process hosting (the default since ASP.NET Core 3.0,
hostingModel="inprocess"): your app runs inside the IIS worker process,w3wp.exe. When ANCM sees the file, it asks the app to shut down, waits up toshutdownTimeLimit, and then recycles the worker process. The application pool itself stays started. The new worker process answers with the offline page until you delete the file. -
Out-of-process hosting: your app runs as a separate
dotnet.exe(orMyApp.exe) behind IIS. ANCM asks it to shut down and kills the process if it is still alive aftershutdownTimeLimit.
Microsoft's own sample script has comments saying the file "stops the app pool". It doesn't. In the source, the pool keeps running and only the process hosting your app goes away. That matters if you monitor pool state: a pool that shows "Started" doesn't mean the site is online.
shutdownTimeLimit defaults to 10 seconds (range 0 to 600). Requests that are still running get that long to finish. An open WebSocket or a long-running request can hold the app up until the limit. If your app needs longer to shut down cleanly, raise it:
<aspNetCore processPath="dotnet" arguments=".\MyApp.dll"
hostingModel="inprocess"
shutdownTimeLimit="30" />
The practical consequence: dropping the file does not free the DLLs instantly. If your script copies the new build the moment it creates app_offline.htm, the first few files can still be locked. Waiting a fixed 5 seconds, as some guides suggest, is shorter than the 10-second default. Wait until the main DLL can actually be opened for writing. The script further down does exactly that.
"app_offline.htm not working": the causes, in order of likelihood
1. It's in the wrong folder
It must sit next to the web.config that loads AspNetCoreModuleV2. If your app is an IIS application under a site (for example /api), that is the application's folder, not the site root. On Plesk it is usually httpdocs, or whatever folder you set as the document root for the domain. A file one level up or one level down does nothing.
2. The name isn't what you think
Windows Explorer hides known extensions by default, so a file you created as a text document is often really app_offline.htm.txt. Run dir in that folder to see the real name. app_offline.html (with an l) is also ignored. Upper or lower case doesn't matter.
3. IIS shows its own 503 page instead of yours
Look for existingResponse="Replace" in httpErrors, in your web.config or at server level. "Replace" makes IIS throw away the response body ANCM sends and show its own error page. That is the problem a 2019 post about classic ASP.NET ran into, and it applies to ASP.NET Core too. The default, "Auto", lets your page through:
<system.webServer>
<!-- "Replace" throws away every error body the app sends, including app_offline.htm.
"Auto" (the IIS default) lets ANCM's response through. -->
<httpErrors existingResponse="Auto" />
</system.webServer>
4. You get a blank page
The file is empty, or it was still being written when ANCM read it. The old advice to make the file "at least 512 bytes" comes from Internet Explorer, which replaced short error pages with its own "friendly" message. ANCM has no minimum, and modern browsers show whatever you send. Put real HTML in the file, and create it under a temporary name and rename it, so the module never reads it half-written.
5. Characters look garbled
ANCM sends Content-Type: text/html without a charset. Curly quotes, accents or an em dash saved as UTF-8 can show up as ’. Add <meta charset="utf-8"> as the first thing in <head>.
6. Your images and CSS are broken on the maintenance page
They are served by the same app, which is now offline, so they get the 503 as well. Keep the page self-contained: inline CSS, no images from your own site (an inline SVG or a data URI is fine).
7. The DLL is still locked
Either you started copying before the app finished shutting down (see above), or something other than the app holds the file. Antivirus scanning the new files, a second IIS application or scheduled task running from the same folder, or a debugger attached on the server will all keep the lock. Microsoft's Process Explorer (Find > Find Handle or DLL) shows who holds it.
8. The site stays down after the deploy
The opposite problem: a deploy that failed half-way left app_offline.htm behind, and your site now returns 503 forever. Visual Studio and Web Deploy create and remove the file themselves, so a crashed publish can leave one you never created. If you are chasing a 503 that won't go away, check for this file first, then read my guide to IIS 503 errors when the app pool keeps stopping.
9. It's a classic ASP.NET app
For .NET Framework apps the ASP.NET runtime handles the file, not ANCM. Static files served by the IIS static file handler can bypass it, and an application pool set to "No Managed Code" won't load the runtime at all. Everything else in this guide is about ASP.NET Core.
The error messages you see without it
If you search for these messages, this is the problem, and app_offline.htm is the fix:
-
Web Deploy / Visual Studio publish:
ERROR_FILE_IN_USE, "Web Deploy cannot modify the file 'MyApp.dll' on the destination because it is locked by an external process". The message itself suggests the AppOffline rule. -
robocopy:
ERROR 32 (0x00000020), "The process cannot access the file because it is being used by another process", followed by retries. - Copy-Item / Explorer: "The process cannot access the file ... because it is being used by another process" / "The action can't be completed because the file is open in IIS Worker Process".
-
Plesk File Manager "Extract": no error at all. On this site's API it skipped the locked DLLs and still reported success. The result was a half-updated app: new
web.configand content, old DLLs. That is worse than a failed deploy, because nothing tells you.
How to use it from each deploy method
Visual Studio and dotnet publish with Web Deploy
Web Deploy can create and remove the file for you, but the publish profile doesn't turn it on unless you ask. Add one property to your .pubxml:
<Project>
<PropertyGroup>
<WebPublishMethod>MSDeploy</WebPublishMethod>
<!-- Web Deploy drops app_offline.htm before syncing and removes it afterwards -->
<EnableMSDeployAppOffline>true</EnableMSDeployAppOffline>
<!-- ...your MSDeployServiceURL, DeployIisAppPath, UserName etc. -->
</PropertyGroup>
</Project>
Then dotnet publish -c Release /p:PublishProfile=MyServer or Visual Studio's Publish button takes the app offline for the sync. Calling msdeploy.exe directly, the same thing is a rule:
msdeploy.exe -verb:sync ^
-source:contentPath="C:\build\publish" ^
-dest:contentPath="Default Web Site/myapp",computerName="https://server:8172/msdeploy.axd",userName="deploy",password="***",authType="Basic" ^
-enableRule:AppOffline ^
-allowUntrusted
A PowerShell script (VPS, robocopy, CI runners)
If you copy files yourself, from a CI job, a file share or a build agent on the server, this is the sequence I'd use. It waits for the lock to clear instead of sleeping a fixed time, protects folders that the build doesn't contain, and checks the site afterwards:
# deploy.ps1 - take the app offline, wait until the DLL is really free, copy, bring it back.
param(
[string]$Source = "C:\build\publish",
[string]$AppRoot = "D:\sites\myapp", # the folder that holds web.config
[string]$MainDll = "MyApp.dll",
[string]$Health = "https://example.com/health"
)
$ErrorActionPreference = "Stop"
$offline = Join-Path $AppRoot "app_offline.htm"
# 1. Drop the maintenance page. Write it to a temp name first, then rename,
# so ANCM never reads a half-written file.
Copy-Item ".\app_offline.template.htm" "$offline.tmp" -Force
Move-Item "$offline.tmp" $offline -Force
# 2. Wait until the app has really let go of its DLL (default shutdownTimeLimit is 10 s).
$dll = Join-Path $AppRoot $MainDll
$deadline = (Get-Date).AddSeconds(30)
while ($true) {
try { [IO.File]::Open($dll, 'Open', 'ReadWrite', 'None').Dispose(); break }
catch { if ((Get-Date) -gt $deadline) { throw "$MainDll is still locked after 30 s" }
Start-Sleep -Milliseconds 500 }
}
# 3. Copy the new build. /XF keeps our offline page, /XD keeps folders the build doesn't own.
robocopy $Source $AppRoot /E /XF app_offline.htm /XD uploads logs /R:3 /W:2 /NP
if ($LASTEXITCODE -ge 8) { throw "robocopy failed with exit code $LASTEXITCODE" }
# 4. Back online. The next request starts the app.
Remove-Item $offline
# 5. Prove it: one cold start, then expect 200.
$r = Invoke-WebRequest $Health -UseBasicParsing -TimeoutSec 120
if ($r.StatusCode -ne 200) { throw "Health check returned $($r.StatusCode)" }
"Deployed."
Keep app_offline.template.htm next to the script, not in your publish output. If the template ends up in the build folder, the copy step can take your app offline again on the next deploy.
Plesk, FTP and other control panels
This is how I deploy this site's API, because the host gives me Plesk and nothing else:
- In File Manager, create
app_offline.htmin the app folder (next toweb.config). Paste in the maintenance HTML from the next section. - Wait 15 seconds, then load the site. You should see the maintenance page, with status 503.
- Delete only the files the new build replaces: the DLLs,
web.config, the*.jsonconfig files. Not the whole folder. - Upload and extract the new build. Check that extracting didn't overwrite your production settings file.
- Delete
app_offline.htmand load the site to start it.
Step 3 is the one that bit me. On 17 October 2026 a "delete everything, then extract" deploy wiped the API's wwwroot/uploads folder. That folder isn't in the build or in git, and 40 images uploaded since the last backup were lost. I got most of them back from my Downloads folder, but not all. Any folder your app writes to at run time (uploads, logs, App_Data) needs the same protection.
For the error codes you'll hit after a deploy like this (500.30, 500.31, 500.19), see deploying ASP.NET Core to Plesk: the errors nobody warns you about.
Azure App Service
App Service on Windows runs ASP.NET Core under the same IIS and ANCM, so the file works the same way, in site/wwwroot. Azure DevOps' App Service deploy task has a "Take App Offline" option that drops the file for you. Most App Service deploys avoid the problem altogether: Run From Package mounts a zip instead of copying files, and deployment slots swap a warmed-up copy in without taking the site down. For a production site with real traffic, a slot swap is the better answer.
A maintenance page that works
Everything above, in one file: UTF-8 declared, CSS inline, no external images, noindex in case a crawler fetches it. Copy it as is, or as a starting point:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Back in a few minutes</title>
<style>
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
background: #0f172a; color: #e2e8f0; }
main { max-width: 32rem; padding: 2rem; text-align: center; }
h1 { font-size: 1.6rem; margin: 0 0 .5rem; }
p { color: #94a3b8; }
</style>
</head>
<body>
<main>
<h1>We're updating the site</h1>
This usually takes a minute or two. Please refresh the page shortly.
</main>
</body>
</html>
ANCM doesn't let you add a Retry-After header, and you don't need one for an outage of a few minutes. If the site will be down for hours, the 503 status still protects your rankings. Leave it in place for days, though, and Google will start dropping pages.
Alternatives to app_offline.htm
Shadow copying (ASP.NET Core 6 and later, documented from 7) removes the problem instead of working around it. ANCM copies your app to another folder and runs it from there, so the deploy folder is never locked. When the DLLs change, the module waits for the copy to settle and then restarts the app. No offline page, no locks:
<aspNetCore processPath="dotnet" arguments=".\MyApp.dll" hostingModel="inprocess">
<handlerSettings>
<handlerSetting name="enableShadowCopy" value="true" />
<handlerSetting name="shadowCopyDirectory" value="../ShadowCopyDirectory/" />
</handlerSettings>
</aspNetCore>
On .NET 6 the setting names had an experimental prefix. Test it on a staging site first. The shadow-copy folder grows with each deploy until old copies are cleaned up, and on shared hosting you need write access to a folder outside the site root.
The other options:
-
Stopping the application pool (IIS Manager or
Stop-WebAppPool): this frees the files too, but visitors get the bare HTTP.sys "Service Unavailable" page instead of yours, and on shared hosting you often can't do it. Microsoft says not to combine it withapp_offline.htm. - Two folders and a switch: deploy into a new folder, then point the IIS site's physical path at it. Nothing is locked, because nothing is overwritten. It needs IIS admin rights, so it's a VPS option.
- Deployment slots or a load balancer: the zero-downtime answer, when the budget allows it.
Checklist
- File named
app_offline.htm(not.html, not.htm.txt), next toweb.config. - Real HTML in it,
<meta charset="utf-8">first, CSS inline, no images from your own site. - Written under a temporary name and renamed into place.
- Wait until the main DLL is unlocked (up to
shutdownTimeLimit, 10 s by default). Don't use a fixed 5-second sleep. - Copy only what the build contains. Never delete uploads, logs or
App_Data. - Delete the file, then request the site and expect a 200.
- Still seeing IIS's own 503 page? Check
httpErrors existingResponse.
If your ASP.NET Core deploys are still a manual, nervous process and you'd like them scripted, that's the kind of work I do. See how to hire me.
FAQ
What is app_offline.htm?
A file you place in the root folder of an ASP.NET or ASP.NET Core app on IIS to take it offline. In ASP.NET Core, the ASP.NET Core Module shuts the app down, which releases its locked DLLs so you can deploy, and answers every request with status 503 and the file's contents. Deleting the file brings the app back on the next request.
Where do I put app_offline.htm?
In the application's root folder, the same folder as the web.config that loads AspNetCoreModuleV2. For an IIS application under a site, such as /api, that is the application's folder, not the site root. Subfolders are not checked.
Why is app_offline.htm not working?
Usually one of these: it is in the wrong folder, it is really named app_offline.htm.txt or app_offline.html, httpErrors existingResponse="Replace" makes IIS replace the page with its own 503, or the deploy started copying before the app finished shutting down (up to shutdownTimeLimit, 10 seconds by default), so the DLLs were still locked.
What status code does app_offline.htm return?
503 Service Unavailable, with Content-Type text/html and the file's contents as the body. The status is hard-coded in the ASP.NET Core Module and cannot be changed. That is the right code for maintenance, because it tells search engines the outage is temporary.
Does app_offline.htm need to be at least 512 bytes?
Not in ASP.NET Core. That advice came from Internet Explorer, which replaced short error pages with its own friendly message. The ASP.NET Core Module has no minimum size, and even an empty file takes the app offline (visitors just see a blank page). The maximum is 4 GB.
Is app_offline.htm case-sensitive?
No. The ASP.NET Core Module compares the name case-insensitively, so App_Offline.htm works. The extension must be .htm, though: app_offline.html is ignored.
Does app_offline.htm stop the application pool?
No. With in-process hosting the module shuts the app down and recycles the worker process, but the application pool stays started. With out-of-process hosting it stops the separate dotnet process. Either way the files are released and the pool keeps running.
Originally published on knowledgemarkg.com.
Top comments (0)