Knowledge Mark G
Hire Me

Deploying ASP.NET Core to Plesk on Windows: Every Error I Hit and What It Actually Meant

Every guide to deploying ASP.NET Core on Plesk stops at "dotnet publish and upload the folder". Mine all went wrong after that step. Here are the five errors in the order you meet them, and what each one actually means.

Kausar RazaBy Published on · 7 min read · 3 views
Share:

Web Development

ASP.NET Core deployment errors on Plesk: 500.19, 500.30 and 500.31
  • Hosting
  • Windows Server
  • DevOps
  • Web Development

Need help with this?

I build and fix business websites - fast, mobile-first and built to bring enquiries.

Get in touch

Every guide to deploying ASP.NET Core on Plesk says the same four things: install the hosting component, run dotnet publish, upload the folder, set the application pool. That is all correct, and I have never had a deploy where those four steps were the hard part.

The hard part is everything that happens after, where the server answers with a three-digit sub-code and no explanation. This is that list, in the order you actually meet it. This site's API runs on Plesk shared Windows hosting, so all of it is first-hand.

Publish the right thing first

dotnet publish src/YourApi/YourApi.csproj -c Release -o ./publish

Upload the contents of publish, not the folder itself. Framework-dependent is fine and much smaller, as long as the server has the matching runtime — which is the first thing that goes wrong.

500.31 — the runtime is not there

Full text: Failed to load ASP.NET Core runtime.

The server does not have the ASP.NET Core Hosting Bundle for your target framework, or has an older one. Note it is the Hosting Bundle specifically, not the SDK and not the plain runtime: the bundle is what installs the ASP.NET Core Module into IIS.

On shared hosting you cannot install it yourself. Two options: ask support which versions are installed and target one of those, or publish self-contained so the runtime travels with the app. Self-contained turns a 6 MB upload into about 70 MB, which is a fair trade for not filing a support ticket every time .NET ships a major version.

500.30 — the app started and then died

Full text: ASP.NET Core app failed to start. The module launched your process and the process exited. Three causes cover almost all of it.

The application pool is wrong. It must be set to No Managed Code. ASP.NET Core does not run on the .NET CLR that the pool loads, and a pool set to "v4.0" will fail in a way that looks like your code crashed.

Configuration is missing at runtime. Anything the app reads during startup — a connection string, an API key — that is present on your machine and absent on the server will throw before the first request is served.

Something in Program.cs throws. Which you cannot see, until you turn the log on:

<aspNetCore processPath="dotnet" arguments=".\YourApi.dll"
            stdoutLogEnabled="true" stdoutLogFile=".\logs\stdout"
            hostingModel="inprocess" />

Create the logs folder yourself — the module will not create it, and if it cannot write there you get the same blank 500.30 with no log. Read the stack trace, fix it, then set stdoutLogEnabled back to false. It is not a logger; it grows forever.

500.19 — IIS could not read your web.config

This is the one that fools people, because it has nothing to do with your application. IIS failed to parse the configuration file, so the app never started at all.

On shared hosting the usual cause is a locked section. Hosts lock parts of system.webServer at server level, and writing a locked section into your site's web.config makes IIS reject the entire file — not just that section.

The giveaway: static files fail too. If a plain text file in the same folder also returns 500, the problem is configuration, not code.

Keep the file to the minimum ASP.NET Core actually needs:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <location path="." inheritInChildApplications="false">
    <system.webServer>
      <handlers>
        <add name="aspNetCore" path="*" verb="*"
             modules="AspNetCoreModuleV2" resourceType="Unspecified" />
      </handlers>
      <aspNetCore processPath="dotnet" arguments=".\YourApi.dll"
                  stdoutLogEnabled="false" hostingModel="inprocess" />
    </system.webServer>
  </location>
</configuration>

One piece of folklore worth correcting: not every section people assume is locked actually is. On this host <httpErrors> turned out to be allowed, and I had avoided it for months on the strength of a warning I had written myself. Test a section before you rule it out — but test by loading a page you know works, because a locked section breaks everything at once.

app_offline.htm "not working", and the deploy order that fixes it

Drop a file called app_offline.htm in the application root and the ASP.NET Core Module shuts the app down and serves that file to every request. Remove it and the app restarts. It is the cleanest maintenance switch IIS has.

When people say it does not work, it is almost always one of these:

  • Wrong name or place. It must be exactly app_offline.htm — not .html, not App_Offline.htm in a subfolder — and it must sit next to web.config in the application root, not the site root.
  • Windows hid the extension from you. You created app_offline.htm.txt and Explorer is showing you app_offline.htm.
  • In-flight requests. Only new requests get the offline page; existing ones finish normally.

The reason it matters on Windows is the thing it protects you from. Windows locks files that a running process has open. Extract a new build over a running app and the extract skips the locked DLLs — and Plesk's extract still reports success. You get a half-updated application, a version mismatch, and no error anywhere.

So the order is not optional:

  1. Upload app_offline.htm to the application root.
  2. Delete the old files.
  3. Extract the new build.
  4. Delete app_offline.htm.

appsettings.Production.json does not replace appsettings.json

It layers on top of it. Keys present in both are overridden by the environment file; keys that exist only in the base file still apply.

That cuts both ways and both have bitten me. A key you thought you had overridden is still being read from the base file because you spelled the section differently. And a key you assumed was dead because it is not in the production file is very much alive.

If a setting is not behaving, log the resolved value at startup once rather than reasoning about which file won:

app.Logger.LogInformation("Using provider {Provider}",
    builder.Configuration["Email:Provider"] ?? "(null)");

And keep appsettings.Production.json out of source control — it holds the real connection string. Copy it forward from the previous deploy rather than regenerating it, or you will overwrite live credentials with Server=localhost;Database=dev;User Id=sa;Password=changeme; and spend an hour on a 500.30 that is really a login failure.

__EFMigrationsHistory drift

EF Core records applied migrations in a table called __EFMigrationsHistory, keyed on the migration's ID — the timestamp in its filename. That ID is the only thing EF compares.

Here is how it goes wrong. A migration is applied to production. Later, someone regenerates that migration — a rename, a rebase, a merge that recreated the file — and it comes back with a new timestamp. EF now sees an ID it has no record of, concludes the migration has never run, and tries to apply it again. On a table that already exists, that fails.

The fix is to tell the history table what it already knows, idempotently, so re-running it is harmless:

IF NOT EXISTS (SELECT 1 FROM __EFMigrationsHistory
               WHERE MigrationId = N'20260927134609_AddSomeColumn')
BEGIN
    INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion)
    VALUES (N'20260927134609_AddSomeColumn', N'9.0.0');
END

Do not delete rows from this table to "force a rerun" unless you have checked that the migration is genuinely reversible against live data. It is a ledger, not a cache.

Give yourself a way to tell which build is live

Because a Windows deploy can half-succeed, "did my change actually ship?" is a question you will ask often. Guessing from behaviour is slow. A tiny unauthenticated endpoint answers it in a second:

app.MapGet("/api/v1/version", () => new
{
    version = typeof(Program).Assembly.GetName().Version?.ToString(),
    built = System.IO.File.GetLastWriteTimeUtc(
        typeof(Program).Assembly.Location).ToString("u"),
});

One warning from my own code, which I am in the middle of fixing. When I first added this I also returned the assembly's full path, because it was useful while debugging a deploy that had half-extracted. That ships the absolute directory layout of the server to anyone who asks, and it stayed in production far longer than the debugging session did.

Return a version and a timestamp. Do not return paths, environment variables, connection state, or the machine name. If you need those while chasing a problem, add them for the hour and take them out again — a diagnostic endpoint is the easiest thing in a codebase to forget about, because it never breaks and nothing tests it.

One last thing: the database is probably not local

On shared hosting your SQL Server usually lives on a different box, sometimes a different datacentre. Every query crosses a network, and the first one after an idle period pays a cold start. Local development against that database feels broken when it is merely remote.

This is worth knowing before you spend an afternoon profiling EF Core over a 40 ms round trip. Measure against the server, not your laptop, and judge a query by its round-trip count before its execution plan.

Is Plesk the right place for this?

If the API is the only thing you are hosting, a small Linux VPS running Kestrel behind nginx is simpler and better documented. Plesk on Windows earns its place when something else already requires that box — a SQL Server licence, a legacy ASP.NET app, or a client who buys Windows hosting and will not move.

That is the situation here, and it is stable once the five problems above are behind you. If you are running the front end on the same stack, I wrote up the matching set of traps for Next.js on IIS and Plesk — different runtime, same hosting model, and a couple of the failures are identical. And if you are still choosing, this is the Windows VPS versus Linux VPS comparison I would read first.

Frequently asked questions

What does ASP.NET Core error 500.31 mean?+
The ASP.NET Core Hosting Bundle for your target framework is not installed on the server, or is an older version. It must be the Hosting Bundle, not the SDK or the plain runtime, because the bundle is what installs the ASP.NET Core Module into IIS. On shared hosting, either target a version the host already has or publish self-contained.
How do I fix ASP.NET Core 500.30, app failed to start?+
The process launched and exited. Check that the application pool is set to No Managed Code, that every setting the app reads at startup exists on the server, and then turn on stdoutLogEnabled in web.config to read the actual exception. Create the logs folder yourself - the module will not, and without it you get the same blank error with no log.
Why do I get HTTP Error 500.19 on IIS?+
IIS could not parse web.config, so your app never started. On shared hosting the usual cause is a system.webServer section the host locks at server level; writing one makes IIS reject the whole file rather than that section. The giveaway is that static files in the same folder fail too.
Why is app_offline.htm not working?+
Usually the name or the location. It must be exactly app_offline.htm, in the application root next to web.config, not the site root - and check Windows is not hiding a .txt extension from you. Requests already in flight also finish normally; only new ones get the offline page.
Does appsettings.Production.json replace appsettings.json?+
No, it layers on top. Keys in both are overridden by the environment file, but keys that exist only in the base file still apply. If a setting is not behaving, log the resolved value at startup rather than reasoning about which file won.
Why is EF Core trying to apply a migration that already ran?+
EF compares the migration ID, which is the timestamp in the filename. If the migration file was regenerated after it was applied, it comes back with a new timestamp, so EF sees an ID it has no record of and runs it again. Insert the new ID into __EFMigrationsHistory idempotently rather than deleting rows.
Can you deploy ASP.NET Core to Plesk shared hosting at all?+
Yes, and it is stable once configured. You cannot install the Hosting Bundle or edit applicationHost.config yourself, so you work within what the host allows. It makes most sense when something else already requires the Windows box, such as a SQL Server licence or a legacy ASP.NET application.

Join the conversation

No comments yet — be the first to share what you think.

Leave a comment

Never published.

Optional.

Keep reading

Related articles

View all