Skip to content

The production server

Severity: mediumApplies to: Flask 3.1.3Applies to: Werkzeug 3.1.8Facts last verified 2026-07-29 against Flask 3.1.3 · Werkzeug 3.1.8

Werkzeug’s development server tells you not to use it in production, in bold red, on every start. Then it starts.

if you see this
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.

Printed by Werkzeug on every start of app.run() or flask run, in bold red. It is a warning rather than a refusal, so the process starts and serves — which is why the line ends up scrolled past in a container log nobody reads.

Read in: werkzeug werkzeug/serving.py

Why it matters here rather than in a performance guide

Section titled “Why it matters here rather than in a performance guide”

The usual argument against the development server is throughput, and that is not this site’s concern. The security argument is narrower and stronger.

It is the only invocation that can enable the debugger. A WSGI server imports your application object and calls it; it never calls app.run(), so there is no code path that switches on the interactive console. Running under gunicorn or uwsgi means FLASK_DEBUG=1 in the environment cannot produce a Python console on a public port. Running app.run() means it can.

It has no request limits worth the name. No worker recycling, no request timeout, no connection cap you would want on an internet-facing port. That turns an ordinary slow request into an availability problem.

It is not audited as a production server, because it is not one. Werkzeug’s own documentation says so, and treating that as a performance note rather than a scope statement is the mistake.

the fix
Terminal window
gunicorn --bind 127.0.0.1:8000 --workers 4 --timeout 30 'app:create_app()'

Bind to loopback and let the reverse proxy own the public port. If you run in a container where the proxy is a separate host, bind to 0.0.0.0 and make the network boundary do that work instead — but make that a decision rather than a default.

The application factory form ('app:create_app()') is worth adopting for its own sake: it means configuration is chosen at startup by the factory rather than at import by module-level code, so there is no global app carrying whatever the environment happened to hold when the module loaded.

Keep the development entry point clearly separate:

if __name__ == "__main__":
# local only; production runs through gunicorn
create_app().run(debug=True)

That block never executes under a WSGI server, which is the property you want.

verify it workedRun this in: shell
Terminal window
ps -o args= -C python 2>/dev/null || ps aux | grep -E '[p]ython|[g]unicorn|[u]wsgi'

You want to see gunicorn or uwsgi master and worker processes. python app.py, flask run or a bare python -m flask is the development invocation.

Check the container’s command rather than only the running process, since that is what survives a restart:

Terminal window
grep -rnE '^\s*(CMD|ENTRYPOINT)' Dockerfile*
grep -rnE 'command:' docker-compose*.y*ml

And look for the warning in your logs — it is printed at every start, so its presence in a production log stream is conclusive:

Terminal window
grep -c 'This is a development server' /var/log/app/*.log

The reverse proxy is doing more than you think

Section titled “The reverse proxy is doing more than you think”

Once a real WSGI server is in place, the proxy in front of it is usually what provides the request body cap, the connection limits, the timeouts and TLS termination. That is the right division of labour and it is why this cluster does not try to configure those things in Python.

It does mean the application’s own limits still matter for anything that reaches the application port without passing the proxy — which is the same reasoning as trusted hosts and request size limits.

before you ship this

Moving to gunicorn changes process behaviour in ways that surface as bugs rather than as errors.

Module-level state stops being shared. Anything cached in a global — a counter, an in-memory rate limiter, a warmed dictionary — now exists once per worker, so behaviour becomes inconsistent between requests. That includes any in-process session or cache store.

Scheduled work runs N times. A BackgroundScheduler started at import fires in every worker. It needs moving to a single process, or to a real job runner.

Startup code runs per worker. Migrations at import become a race. Move them to a release step.

The reloader is gone, so local development feels different. Keep flask run for local work — this control is about what ships.

Start with --workers 1 to isolate the port and process changes from the concurrency changes, confirm the application is healthy, then scale up.