Field notes · flask
Deploying Flask on IIS when Linux is not an option
How I run production Flask apps on Windows Server with IIS and wfastcgi: the web.config, the two 500 errors that cost me a day, and the deploy script that recycles the pool.
Every Flask deployment tutorial assumes you have a Linux box. Gunicorn behind nginx, a systemd unit, maybe Docker. That advice is fine. None of it helps when your entire server room is Windows and your team is one person. You are not about to stand up and patch a Linux VM to run a 200-line internal web app. The small tools that run my new-hire onboarding automation are Flask apps, and I host them on the Windows Server and IIS I already run. It works, it is boring in the good way, and almost nobody writes down how.
So here is how, including the two errors that ate most of a day before I understood them.
Key takeaways
- IIS can host Flask through
wfastcgi, so a Windows-only shop does not need a Linux VM or Docker to ship a small internal tool. - The app pool must run with No Managed Code, because
wfastcgiis not a .NET runtime and IIS will fight you if you leave it on the default. - Two 500 errors cause most of the pain: the app pool identity cannot read the
app folder, and a literal double-dash inside a
web.configXML comment. - IIS does not watch your
.pyfiles. A deploy is not live until you recycle the app pool, and a machine environment-variable change needs a fulliisreset, not a recycle.
Why host Flask on IIS at all?
Because you meet the server you already run instead of adding one you do not. A small municipal or nonprofit IT shop is usually a Windows shop: Active Directory, file servers, IIS somewhere already serving an intranet page. Adding a Linux host means another OS to patch, another backup target, another thing to explain to whoever audits you. For one internal Flask app, that trade is not worth it.
I weighed the obvious alternatives. A Linux VM is the “correct” answer and the
most operational overhead. Docker on Windows adds a runtime and a daemon for a
single small app. Running the app as a standalone service with waitress works
and is genuinely good for non-WSGI stacks, but it puts the app on its own port
outside the web server I already secure and log. IIS with wfastcgi reuses the
Windows Authentication, TLS, and logging I already operate. For a lean shop,
reuse wins.
One honest caveat: wfastcgi is old and lightly maintained. It has not needed
much, because the FastCGI contract it implements has not changed. If that makes
you nervous, the standalone-service route is your escape hatch, and the same app
code runs either way.
What does IIS need to run a Flask app?
Three pieces, and a virtualenv that lives on the server. IIS hands each request
to wfastcgi, which runs your app inside a Python process from that virtualenv.
WSGI is the standard interface every Python web framework exposes to a web
server, and wfastcgi is the small bridge that lets IIS speak it. You install it into the app’s virtualenv,
point web.config at it, and IIS does the rest. Microsoft documents the full
setup in its guide to configuring Python web apps for
IIS;
what follows is the generalized version I actually run, plus the failures that
guide does not warn you about.
The layout on the server is deliberately plain:
D:\IntakeApp\
app.py # your Flask module: app = Flask(__name__)
requirements.txt
web.config # the wfastcgi handler, deployed from source
.venv\ # per-app virtualenv, created on the server, never deployed
logs\ # wfastcgi.log lands here
static\
templates\
The virtualenv stays on the server and is rebuilt only when requirements.txt
changes. You never copy it from your workstation, because a virtualenv is bound
to the machine and Python that built it.
How do you write the web.config?
The web.config is the whole handshake, and it is short. It tells IIS to route
every request to wfastcgi, which module in your code is the WSGI app, and
where to log. Get the scriptProcessor path by running
.venv\Scripts\wfastcgi-static.exe on the server once; it prints the exact
python.exe|wfastcgi.py pair to paste in.
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<handlers>
<add name="FlaskFastCGI" path="*" verb="*"
modules="FastCgiModule"
scriptProcessor="D:\IntakeApp\.venv\Scripts\python.exe|D:\IntakeApp\.venv\Lib\site-packages\wfastcgi.py"
resourceType="Unspecified" requireAccess="Script" />
</handlers>
</system.webServer>
<appSettings>
<add key="WSGI_HANDLER" value="app.app" />
<add key="PYTHONPATH" value="D:\IntakeApp" />
<add key="WSGI_LOG" value="D:\IntakeApp\logs\wfastcgi.log" />
</appSettings>
</configuration>
WSGI_HANDLER is <module>.<flask-instance>. If your file is app.py with
app = Flask(__name__), that value is app.app. If you use a wsgi.py entry
point, it is wsgi.application. Set WSGI_LOG before anything else, because
that log file is where every Python import error at startup goes, and you will
want it in the first five minutes.
How do you create the IIS app pool?
With managed code turned off, then the application pointed at the folder. No Managed Code is the app pool mode that loads no .NET runtime into the worker process, which is exactly what a Python app wants. This is the single setting people miss, and it produces the most confusing failure, so do it by script rather than by memory in the IIS UI.
Import-Module WebAdministration
# wfastcgi is unmanaged, so the pool must run "No Managed Code"
New-WebAppPool -Name 'IntakeApp'
Set-ItemProperty 'IIS:\AppPools\IntakeApp' -Name managedRuntimeVersion -Value ''
New-WebApplication -Name 'IntakeApp' `
-Site 'Default Web Site' `
-PhysicalPath 'D:\IntakeApp' `
-ApplicationPool 'IntakeApp'
Setting managedRuntimeVersion to an empty string is what “No Managed Code”
means under the hood. Leave it on the default and IIS tries to load the .NET
runtime into a process that only wants to run Python, and you get startup
errors that read like anything but the real cause.
What are the two 500 errors that will cost you a day?
Both are HTTP 500.19, both are about web.config, and both are maddening
because the page tells you almost nothing. The first time I hit them I lost the
better part of a day, so here they are with the win32 codes that actually
identify them.
The first is a permissions problem. IIS runs your app under a virtual identity
named IIS AppPool\IntakeApp, and that identity has to be able to read the app
folder. If it cannot, you get 500.19 with win32 error 0x80070005, access
denied. The fix is one command granting that identity read and execute on the
directory:
icacls 'D:\IntakeApp' /grant 'IIS AppPool\IntakeApp:(OI)(CI)RX'
The second one is pure cruelty. If you put a double-dash inside an XML comment
in web.config, the file is invalid XML, and IIS answers 500.19 with win32
error 0x00000015. A double-dash is illegal inside an XML comment body, and it
is the most natural thing in the world to type when you are annotating a config.
Do not comment with double-dashes. I now avoid them in web.config entirely.
| Symptom | Cause | Fix |
|---|---|---|
500.19, win32 0x80070005 |
App pool identity cannot read the folder | Grant IIS AppPool\<App> read and execute |
500.19, win32 0x00000015 |
Double-dash inside an XML comment | Remove double-dashes from all comments |
| 500.21 | Python import error at startup | Read logs\wfastcgi.log for the traceback |
| Pool stops immediately | Missing secret or a bad import | Check Event Viewer, Windows Logs, Application |
How do you deploy an update without breaking things?
Copy the source, skip the virtualenv, then recycle the pool. IIS does not watch
your Python files, so new code sitting on disk changes nothing until the pool
restarts. My deploy is a robocopy that mirrors the tree, protects the things
that must stay on the server, and then recycles.
$source = $PSScriptRoot
$appServer = 'your-app-server' # the IIS host
$dest = "\\$appServer\D$\IntakeApp" # admin share to the app folder
# Mirror source; keep the server's venv, logs, and caches out of the mirror
robocopy $source $dest /MIR /XD .venv __pycache__ .git logs tests /XF *.pyc *.ps1 /R:3 /W:5
if ($LASTEXITCODE -ge 8) { throw "Robocopy failed (exit $LASTEXITCODE)" }
# The step people forget: recycle so wfastcgi loads the new code
Invoke-Command -ComputerName $appServer -ScriptBlock {
Import-Module WebAdministration
Restart-WebAppPool -Name 'IntakeApp'
}
robocopy exit codes below 8 are success, not failure, which trips up everyone
the first time they check $LASTEXITCODE. The /MIR switch mirrors, so a file
you delete locally is deleted on the server too. That is exactly why the /XD
exclusions exist: without them, /MIR would happily delete the server’s
virtualenv and logs on the next run.
When requirements.txt changes, the recycle is not enough. Delete .venv on
the server, rebuild it with python -m venv, pip install -r requirements.txt,
then recycle. Code-only changes just need the recycle.
What broke, and what I would tell past me
The secret key that would not take. My apps read FLASK_SECRET_KEY from a
machine environment variable so it never lives in source. I set it, recycled the
pool, and the app kept dying at startup insisting the key was missing. The
recycle was the mistake. A machine environment-variable change is invisible to
the IIS worker process until the Windows Process Activation Service restarts, so
you need a full iisreset, not a pool recycle. I now keep a one-line rule taped
to that runbook: env var changed, run iisreset.
The deploy that “did nothing.” Early on I copied new code up, refreshed the
page, and saw the old behavior, and briefly doubted my own robocopy. The copy
was fine. I had not recycled the pool, so wfastcgi was still serving the
Python process it started an hour earlier. IIS auto-detects a changed
web.config, but it will never notice a changed .py. That asymmetry is the
one thing to internalize about running Python under IIS.
If I were starting over, I would build the deploy script and the recycle into one command on day one, before the first manual copy taught me a bad habit. I would also expose a tiny read-only status page that prints the build stamp, so “did my deploy land” is a glance at a URL instead of a guess. I built both eventually. I just paid for them in confusion first.
Was it worth skipping Linux?
For a one-person shop running internal tools, yes, without hesitation. The apps
that provisioning and intake work depends on, like the internal web app behind
my onboarding intake form, all run this way. They
sit on the Windows Server I was already going to patch on Tuesday regardless. I
did not add an operating system, a container runtime, or a second thing to
secure. I reused the web server I already run and already understand, and the
total new surface area was one web.config and a deploy script.
The lesson generalizes past Flask. When a tool needs a home, the cheapest correct answer is often the boring server already sitting in the rack, not the stack a tutorial assumes. Meet the infrastructure you have.
FAQ
Can you really run Flask on IIS in production?
Yes. IIS hands requests to wfastcgi, which runs your Flask app through the
standard WSGI interface, the same interface gunicorn or uWSGI would use on
Linux. For internal tools and modest traffic it is stable and low-maintenance.
The main caveat is that wfastcgi is old and lightly maintained, so if that
worries you, run the app as a standalone Windows service instead with the same
code.
Why does my Flask app return HTTP 500.19 on IIS?
Almost always a web.config problem. If the win32 code is 0x80070005, the app
pool identity cannot read the app folder, so grant IIS AppPool\<AppName> read
and execute on the directory. If it is 0x00000015, you have a double-dash
inside an XML comment, which is invalid XML. Remove the double-dashes.
Do I need to restart IIS after every deploy?
No, but you must recycle the app pool. IIS does not watch your .py files, so
new code on disk does nothing until the pool restarts. A full iisreset is only
needed when you change a machine environment variable, because the worker
process will not see the new value until the activation service restarts.
Should I use IIS or a Linux VM for a small Flask app?
If you already run Windows Server and no Linux, IIS avoids adding an operating system to patch, back up, and secure for one app. If you already operate Linux, gunicorn behind nginx is the more conventional path with more community support. The deciding factor is which platform you already run well, not which is theoretically better.