The production server
Werkzeug’s development server tells you not to use it in production, in bold red, on every start. Then it starts.
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.pyWhy 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.
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.
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:
grep -rnE '^\s*(CMD|ENTRYPOINT)' Dockerfile*grep -rnE 'command:' docker-compose*.y*mlAnd look for the warning in your logs — it is printed at every start, so its presence in a production log stream is conclusive:
grep -c 'This is a development server' /var/log/app/*.logThe 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.
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.
Related
Section titled “Related”- The Werkzeug debugger console — what this invocation makes reachable
- Debug mode — the flag, and why the invocation matters more than the flag