Deployment: WSGI Servers & Proxies

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Flask 3.1.3 (released 2026-02-19), Werkzeug 3.1.9 and Python 3.13, with Gunicorn 26.2.0, Waitress 3.0.2, pyuwsgi 2.0.30.post1 (uWSGI 2.0.30), mod_wsgi 6.1.0, Hypercorn 0.18.0, Uvicorn 0.54.0, Supervisor 4.3.0 and Tornado 6.5.10, checked on 2026-10-07. Flask 3.2 is not released yet; everything below describes 3.1.x. Every Python example was executed with those versions: the servers were started for real on loopback ports, queried over HTTP and stopped with signals, and the outputs shown come from those runs. The nginx and Apache httpd configuration files and the systemd units are shown as configuration, not Python: the Apache httpd configuration was checked against Apache 2.4.67 on macOS (including a proxied request to a Gunicorn socket), while the nginx files, the systemd units and the mod_wsgi commands could not be run in the verification environment and are marked as such. The page follows the production checklist of the official documentation: a topology, a WSGI server, a reverse proxy, the headers that tell Flask about that proxy, HTTPS, a supervisor, and then sizing, ASGI and the things you should not do.

The Bookshelf to Deploy

The running scenario is the Bookshelf application. Deployment does not change how the application is written, but it does change three habits: the application is created by a factory (so every server can import it by name), its secrets come from the environment (never from the source), and it is told how many proxies stand in front of it. The first example writes a small package and a wsgi.py entry point into a temporary directory, so that every server command on this page imports exactly the same code. The secret key is generated with secrets.token_hex() and passed through an environment variable; in a real deployment it lives in a root-owned file loaded by the supervisor (see Process Supervision).

import os
import pathlib
import secrets
import sys
import tempfile
import textwrap

root = pathlib.Path(tempfile.mkdtemp(prefix="bookshelf-deploy-"))
(root / "bookshelf").mkdir()

(root / "bookshelf" / "__init__.py").write_text(textwrap.dedent('''\
    import os

    from flask import Flask, request
    from werkzeug.middleware.proxy_fix import ProxyFix


    def create_app(config=None, proxy_hops=None):
        app = Flask(__name__)
        app.config.from_mapping(
            SECRET_KEY=os.environ["BOOKSHELF_SECRET_KEY"],
            SESSION_COOKIE_SECURE=os.environ.get("BOOKSHELF_HTTPS", "1") == "1",
        )
        app.config.update(config or {})

        # How many reverse proxies stand in front of this process (0 = none).
        if proxy_hops is None:
            proxy_hops = int(os.environ.get("BOOKSHELF_PROXY_HOPS", "0"))
        if proxy_hops:
            app.wsgi_app = ProxyFix(
                app.wsgi_app, x_for=proxy_hops, x_proto=proxy_hops, x_host=proxy_hops
            )

        @app.get("/healthz")
        def healthz():
            return {"status": "ok", "pid": os.getpid()}

        @app.get("/whoami")
        def whoami():
            return {
                "remote_addr": request.remote_addr,
                "scheme": request.scheme,
                "host": request.host,
            }

        return app
    '''))

(root / "wsgi.py").write_text(textwrap.dedent('''\
    from bookshelf import create_app

    # The name `application` is what mod_wsgi looks for; `app` suits Gunicorn and uWSGI.
    application = app = create_app()
    '''))

secret = secrets.token_hex(32)
os.environ["BOOKSHELF_SECRET_KEY"] = secret
sys.path.insert(0, str(root))

from bookshelf import create_app

client = create_app().test_client()
response = client.get("/healthz")
assert response.status_code == 200 and response.json["status"] == "ok"
print(response.json)

A server starts the application in one of two ways, and the rest of the page uses both. Servers that accept a module-and-callable argument (Gunicorn, Waitress, Hypercorn) can import the factory directly, as in 'bookshelf:create_app()'. Servers that want a variable (mod_wsgi’s application, uWSGI’s -w wsgi:app) import the small wsgi.py file above. The application-factory page explains why the factory is the better design; this page only needs the two entry points. Detail on the configuration keys read here is in Configuration.

Production Means Not Development

The official deployment page opens with a sentence worth taking literally: "production" means "not development", and that applies whether the application serves millions of users or one person on a private network. Three things that are convenient while you write code must not exist in production:

  • the development server that flask run and app.run() start: it is Werkzeug’s single-purpose server, "not designed to be particularly secure, stable, or efficient";

  • the interactive debugger, enabled by flask run --debug, the FLASK_DEBUG environment variable or app.run(debug=True): it serves a console on a traceback page, which is remote code execution for anyone who can reach the port (the PIN is a speed bump, not a defence);

  • the reloader, which restarts the process when files change and has no place in a supervised service.

The development server prints its own reminder, which the next example captures by starting flask run for a moment on a loopback port. The command is the documented current form, flask --app <module> run; the older FLASK_APP environment variable still works, and FLASK_ENV (used in many .flaskenv files and Dockerfiles of older books) was deprecated in 2.2 and removed in 2.3, so setting it today does nothing.

import subprocess
import time

env = {**os.environ, "PYTHONPATH": str(root)}
proc = subprocess.Popen(
    [sys.executable, "-m", "flask", "--app", "bookshelf", "run", "--port", "5871"],
    cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
time.sleep(2)
proc.terminate()
output, _ = proc.communicate(timeout=10)
print(output)
assert "development server" in output

The warning in that output is the whole message of this page: swap the server and put a proxy in front of it. A habit to unlearn is the Dockerfile whose last line is python app.py with app.run(host="0.0.0.0", debug=True) at the bottom of the module: it ships the debugger and the development server to production. A container runs Gunicorn or Waitress exactly as a virtual machine does (see Docker and Kubernetes for the container side).

Development convenience Production replacement Why

flask run, app.run()

A WSGI server: Gunicorn, Waitress, mod_wsgi, uWSGI

Real concurrency model, process management, hardened HTTP parser

Debugger (--debug, FLASK_DEBUG=1)

Error logging, an error tracker, 500 handlers (see Error Handling)

The debugger executes code in the browser

Reloader

A supervisor that restarts the service on deploy

No file watching, no half-reloaded state

SECRET_KEY literal in the source

A random key from the environment or a secrets manager

Anyone with the key can forge sessions (see Security Considerations)

localhost binding, plain HTTP

A reverse proxy with TLS and a firewall

The proxy is the only public listener

The Production Topology

A production deployment of a Flask application is several processes, each with one job. The picture is the topology the rest of this page builds, from left to right.

A client connects over HTTPS port 443 to a reverse proxy (nginx or Apache httpd) that terminates TLS, buffers slow clients, sets X-Forwarded headers, applies size and time limits and serves location /static/ from built static files without Python. The proxy passes other requests over a Unix socket to a Gunicorn master with workers 1, 2 and N, each running the Flask app with ProxyFix. The workers use a PostgreSQL connection pool per worker and Redis for cache and broker. Separate Celery workers take tasks from Redis and write results to the database. systemd or Supervisor starts, restarts and stops the proxy, Gunicorn and Celery, all as an unprivileged user.
Figure 1. A client reaches nginx over HTTPS; nginx serves static files itself and passes everything else to Gunicorn, whose workers run Flask. Celery workers are separate processes and a supervisor owns all of them.

Each box exists for a reason that comes from how WSGI servers work:

  • The reverse proxy speaks to the internet. A synchronous Gunicorn worker is tied up for as long as a client takes to send its request or read its response, so a client on a slow mobile link (or a deliberately slow one) can exhaust all workers. The proxy buffers the request until it is complete and the response until it is sent, so a worker is busy only for the time the application actually works. It also terminates TLS, serves static files, enforces body-size and timeout limits, and is the one place that sets the X-Forwarded-* headers.

  • The WSGI server speaks HTTP to the proxy and WSGI to Flask. It manages worker processes (and threads), restarts a worker that dies or hangs, and exposes the signals that make a graceful reload possible.

  • The application is plain Flask, wrapped in ProxyFix so it knows about the proxy.

  • The supervisor (systemd, Supervisor, or the container runtime) starts everything at boot and restarts what crashes.

  • Background workers such as Celery are not WSGI at all: they are separate programs started from the same code base (see Background Jobs, Celery & Email).

The shape is the same on a virtual machine, in Docker and in Kubernetes, where the reverse proxy becomes an ingress and the supervisor becomes the kubelet; the Docker & Kubernetes page of this section covers the container side, and the generic deployment concepts (HTTPS, restarts, replication, memory) are explained once for ASGI applications in FastAPI: Deployment.

Choosing a WSGI Server

The official documentation lists four self-hosted WSGI servers (plus gevent and ASGI variants). They all run the same Flask object; they differ in platform support, concurrency model and operational weight.

Server Concurrency model Platform and install Choose it when

Gunicorn 26.2

Pre-fork: a master supervises worker processes; worker classes sync, gthread, gevent

Pure Python, no compilation; no native Windows support (it runs under WSL)

The default choice on Linux and in containers

Waitress 3.0.2

One process, a pool of threads (4 by default)

Pure Python; works on Windows; buffers whole request bodies, so no streamed request uploads

Windows hosts, or when a single multithreaded process is enough

mod_wsgi 6.1 (and mod_wsgi-express)

Apache httpd runs the application in embedded or daemon processes

Needs a compiler and the Apache development headers; runs on Windows; can bind privileged ports then drop privileges

Apache is already the platform and one server for both HTTP and Python is wanted

uWSGI 2.0.30

Pre-fork master and workers, many extra features

pyuwsgi wheels, or a compiler; no native Windows support; the project is in maintenance mode

An existing uWSGI estate, or its nginx uwsgi_pass integration

Three further options appear in older tutorials and are not recommended. The Werkzeug development server is covered above. Tornado’s WSGIContainer is covered in its own section. And running Flask under an ASGI server through an adapter is possible but is not a way to make it faster (see ASGI Deployment). A simple rule covers most projects: Gunicorn behind nginx on Linux, Waitress on Windows, mod_wsgi when Apache is mandated, and uWSGI only when you inherit it. The same rule is why the later sections spend most of their words on Gunicorn.

Gunicorn

Gunicorn is a pure Python WSGI server with simple configuration and several worker implementations. It needs no compilation, integrates easily with hosting platforms and has built-in async worker support through gevent. It does not run on Windows natively, and it should not run as root.

Installing and running

Create a virtual environment, install the application, then install gunicorn. The only required argument says how to load the application, in the form module:variable. If the variable part is a call (with any arguments), Gunicorn evaluates it: that is how an application factory is served, with no wsgi.py file at all.

$ python -m venv .venv
$ . .venv/bin/activate
$ pip install .            # install your application
$ pip install gunicorn

# equivalent to 'from bookshelf import app'
$ gunicorn -w 4 'bookshelf:app'

# equivalent to 'from bookshelf import create_app; create_app()'
$ gunicorn -w 4 'bookshelf:create_app()'

Source: Flask: Gunicorn

Logs for each request are not shown by default, only worker information and errors; add --access-logfile=- to print access logs on standard output. The next example starts Gunicorn with two workers on a loopback port, queries it, and shows the startup log. Every example that starts a server uses the small helper below, which launches the process, waits until /healthz answers, and always stops it with SIGTERM.

import contextlib
import http.client
import json
import socket
import urllib.request

BASE_ENV = {**os.environ, "PYTHONPATH": str(root)}


def free_port():
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


def get(port, path="/healthz", headers=None):
    request = urllib.request.Request(f"http://127.0.0.1:{port}{path}", headers=headers or {})
    with urllib.request.urlopen(request, timeout=5) as response:
        return response.status, response.headers, json.load(response)


@contextlib.contextmanager
def running(argv, port, env=None, ready="/healthz"):
    """Start a server process, wait until it answers, stop it with SIGTERM."""
    proc = subprocess.Popen(
        argv, cwd=root, env={**BASE_ENV, **(env or {})},
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
    )
    try:
        for _ in range(150):
            try:
                get(port, ready)
                break
            except OSError:
                if proc.poll() is not None:
                    raise RuntimeError(proc.communicate()[0])
                time.sleep(0.1)
        else:
            raise RuntimeError("the server did not start")
        yield proc
    finally:
        proc.terminate()
        try:
            proc.log, _ = proc.communicate(timeout=20)
        except subprocess.TimeoutExpired:
            proc.kill()
            proc.log, _ = proc.communicate()


GUNICORN = [sys.executable, "-m", "gunicorn"]

port = free_port()
argv = GUNICORN + ["-w", "2", "-b", f"127.0.0.1:{port}", "bookshelf:create_app()"]
with running(argv, port) as proc:
    status, headers, body = get(port)
    print(status, headers["Server"], body)
print(proc.log)
assert status == 200 and headers["Server"] == "gunicorn"
assert "Using worker: sync" in proc.log

The default sync worker handles one request at a time per process. The documented starting point for the number of workers is CPU * 2 (the Gunicorn design page recommends (2 x CPU) + 1); the default is a single worker, "which is probably not what you want". The sizing section turns the rule into numbers.

Binding

Gunicorn binds to 127.0.0.1:8000 by default. It should not run as root, which means it cannot bind to ports 80 or 443: that is the proxy’s job. Two rules follow.

  • Behind a proxy, bind to loopback or a Unix socket, never to 0.0.0.0. The documentation warns that binding to all addresses when a reverse proxy is in use makes it possible to bypass the proxy (and its TLS, limits and logging).

  • A Unix socket needs no port and is the usual choice on one host: -b unix:/run/bookshelf/gunicorn.sock. The socket is a file, so file permissions decide who may connect: the proxy’s user must be able to read and write it.

import tempfile


class UnixHTTPConnection(http.client.HTTPConnection):
    def __init__(self, path):
        super().__init__("localhost")
        self.unix_path = path

    def connect(self):
        self.sock = socket.socket(socket.AF_UNIX)
        self.sock.connect(self.unix_path)


sock_dir = pathlib.Path(tempfile.mkdtemp(prefix="bs-sock-"))
sock = sock_dir / "gunicorn.sock"
argv = GUNICORN + ["-w", "1", "-b", f"unix:{sock}", "--umask", "0o007", "bookshelf:create_app()"]
proc = subprocess.Popen(argv, cwd=root, env=BASE_ENV, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
try:
    for _ in range(150):
        if sock.exists():
            break
        time.sleep(0.1)
    conn = UnixHTTPConnection(str(sock))
    conn.request("GET", "/healthz")
    reply = conn.getresponse()
    print(reply.status, json.load(reply), oct(sock.stat().st_mode & 0o777))
    assert reply.status == 200
finally:
    proc.terminate()
    proc.communicate(timeout=20)

The socket mode shown is the result of --umask 0o007: owner and group may use it, others may not. In a real deployment the group is the proxy’s group (often www-data or nginx) and systemd’s RuntimeDirectory= creates the directory (see Process Supervision).

The options that matter

gunicorn --help lists everything; the settings reference documents each one. These are the ones a Flask deployment actually sets. Every command-line flag has an equivalent name in a configuration file.

Flag (setting) Default Meaning and advice

-w, --workers (workers)

1, or WEB_CONCURRENCY if set

Number of worker processes. A positive integer, generally in the 2-4 x $(NUM_CORES) range. The WEB_CONCURRENCY environment variable is honoured because some platform-as-a-service providers set it.

--threads (threads)

1

Threads per worker. Only the gthread worker class uses it; in 26.2.0 a value above 1 selects gthread for you (the startup log says Using worker: gthread), but name the class explicitly.

-k, --worker-class (worker_class)

sync

sync, gthread, gevent, gevent_wsgi / gevent_pywsgi, tornado and asgi are bundled (see below).

-b, --bind (bind)

127.0.0.1:8000

HOST:PORT, unix:PATH or fd://N. Use loopback or a socket behind a proxy.

-t, --timeout (timeout)

30 seconds

A worker silent for longer than this is killed and restarted. See the pitfall below: this is a liveness limit, not a per-request limit for gthread and gevent.

--graceful-timeout (graceful_timeout)

30 seconds

How long a worker may finish its requests after a restart or shutdown signal.

--keep-alive (keepalive)

2 seconds

How long to wait for the next request on a keep-alive connection (not used by sync, which closes the connection after each response).

--max-requests and --max-requests-jitter

0 (disabled)

Restart a worker after this many requests (plus a random jitter so all workers do not restart together). A cheap defence against slow memory growth.

--preload (preload_app)

off

Import the application in the master before forking. Saves memory (copy-on-write) and startup time, but see the pitfall below.

--worker-tmp-dir (worker_tmp_dir)

the system temporary directory

Where workers write their heartbeat file. On disk-backed filesystems the heartbeat can block a worker; point it at a memory filesystem such as /dev/shm in containers.

--forwarded-allow-ips (forwarded_allow_ips)

FORWARDED_ALLOW_IPS, else 127.0.0.1,::1

The addresses (or CIDR networks) of front-end proxies whose secure-scheme headers Gunicorn believes. * trusts everyone and is only safe when nothing else can reach the port. Unix-socket connections are trusted unconditionally.

--access-logfile, --error-logfile, --log-level

none, -, info

- means standard output or error: the right target for systemd and containers, which collect them.

--user, --group, --umask, --pid, --chdir

Privilege drop (only when started as root), socket file permissions, PID file and working directory. Under systemd set User= in the unit instead.

--limit-request-line, --limit-request-fields, --limit-request-field_size

4094, 100, 8190

HTTP request size limits at the server level.

--check-config, --print-config

off

Validate the configuration and exit; print the fully resolved settings. Use them in deployment scripts.

--reload

off

Development only. It restarts workers on code changes and has no place in production.

Worker classes

The worker class decides how a worker handles concurrency. The Flask application code is identical for all of them.

Class How it works Use it for

sync (default)

One request at a time per worker process; no keep-alive. Predictable and simple.

Ordinary request/response applications behind a buffering proxy, where each request is short and CPU or database bound.

gthread

Each worker has a pool of threads (--threads) and supports keep-alive. Threads share memory.

Mixed workloads and applications that wait on I/O, when the code and its extensions are thread-safe. Flask itself is: contexts are per thread.

gevent

Greenlets in each worker; thousands of simultaneous connections. Requires gevent (the gunicorn[gevent] extra installs it with its other requirements, such as packaging) and monkey patching, which Gunicorn does for you.

Many long-lived or slow connections (long polling, Server-Sent Events) and I/O-bound code. See Async, gevent & Quart for the pitfalls.

tornado

A worker for applications written for Tornado’s own framework.

Not for Flask.

asgi

Gunicorn’s own asyncio worker for ASGI applications (new in the 25 and 26 series).

ASGI applications; see ASGI Deployment.

The installed 26.2.0 package lists exactly sync, gevent, gevent_wsgi / gevent_pywsgi, tornado, gthread and asgi among its bundled workers: there is no eventlet worker any more, which matches the Flask documentation’s statement that eventlet is no longer maintained. Anything in a book or tutorial that says -k eventlet should become -k gevent.

for worker_class, extra in (("gthread", ["--threads", "4"]), ("gevent", [])):
    port = free_port()
    argv = GUNICORN + ["-w", "1", "-k", worker_class, *extra, "-b", f"127.0.0.1:{port}", "bookshelf:create_app()"]
    with running(argv, port) as proc:
        status, _, body = get(port)
    print(worker_class, status, [l for l in proc.log.splitlines() if "Using worker" in l][0].split("] ")[-1])
    assert f"Using worker: {worker_class}" in proc.log

Source: Flask: Gunicorn

A configuration file

Past three or four flags, a file is easier to review and to keep in version control. Gunicorn reads gunicorn.conf.py from the current directory (or the file given with -c). It is a Python module whose top-level names are settings, so it can compute values from the environment. Hooks such as when_ready and post_fork are plain functions in the same file.

(root / "gunicorn.conf.py").write_text(textwrap.dedent('''\
    import multiprocessing
    import os

    bind = os.environ.get("GUNICORN_BIND", "127.0.0.1:8000")
    workers = int(os.environ.get("WEB_CONCURRENCY", multiprocessing.cpu_count() * 2 + 1))
    worker_class = "gthread"
    threads = int(os.environ.get("GUNICORN_THREADS", 2))
    timeout = 30
    graceful_timeout = 30
    max_requests = 2000
    max_requests_jitter = 200
    worker_tmp_dir = "/dev/shm" if os.path.isdir("/dev/shm") else None
    accesslog = "-"
    errorlog = "-"
    forwarded_allow_ips = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1")


    def when_ready(server):
        server.log.info("Bookshelf is ready: %s workers", server.cfg.workers)


    def post_fork(server, worker):
        server.log.info("Worker %s forked", worker.pid)
    '''))

check = subprocess.run(GUNICORN + ["-c", "gunicorn.conf.py", "--check-config", "bookshelf:create_app()"],
                       cwd=root, env=BASE_ENV, capture_output=True, text=True)
print("check-config exit status:", check.returncode)
assert check.returncode == 0, check.stderr

port = free_port()
argv = GUNICORN + ["-c", "gunicorn.conf.py", "-w", "2", "-b", f"127.0.0.1:{port}", "bookshelf:create_app()"]
with running(argv, port) as proc:
    get(port)
print(proc.log)
assert "Bookshelf is ready: 2 workers" in proc.log and "GET /healthz" in proc.log

Command-line flags override the file, which is why -w 2 -b ... could be added above; the environment variables read by the file (WEB_CONCURRENCY, GUNICORN_BIND) are what a container image or a systemd EnvironmentFile sets.

Preloading and fork safety

By default each worker imports the application itself after the fork. With --preload the master imports it once and the workers inherit it: memory is shared copy-on-write and the workers start faster, but the code in them can no longer be reloaded by HUP without a full restart, and anything created before the fork is shared between the workers: open database connections, threads, locks, random-number generator state. A connection pool opened at import time is the classic casualty, because two processes then talk on the same socket. The next example makes the difference visible: the module prints a line each time it is imported, once per worker without --preload and once (in the master) with it.

(root / "traced.py").write_text(textwrap.dedent('''\
    import os

    from bookshelf import create_app

    print(f"loading the application in pid {os.getpid()}", flush=True)
    app = create_app()
    '''))

for flags in ([], ["--preload"]):
    port = free_port()
    argv = GUNICORN + ["-w", "2", *flags, "-b", f"127.0.0.1:{port}", "traced:app"]
    with running(argv, port) as proc:
        pass
    loads = proc.log.count("loading the application")
    print(f"{' '.join(flags) or 'no --preload':>12}: application imported {loads} time(s)")
    assert loads == (1 if flags else 2)

When you must preload (for the memory saving, or to fail fast on import errors), do not open connections at import time and dispose of inherited pools in the child. SQLAlchemy documents the call: engine.dispose(close=False) in the new process discards the pool without closing the parent’s connections. Flask-SQLAlchemy opens connections lazily, so the problem appears mainly when code touches the database during import (for example create_all() in module scope); the fix is a post_fork hook in gunicorn.conf.py:

import sqlalchemy as sa

engine = sa.create_engine(f"sqlite:///{root / 'bookshelf.db'}")
with engine.connect() as connection:           # opened in the "master" before the fork
    connection.execute(sa.text("select 1"))


def post_fork(server, worker):
    # In the child: forget the parent's pooled connections without closing them.
    engine.dispose(close=False)


post_fork(None, None)
with engine.connect() as connection:           # the child opens a brand-new connection
    assert connection.execute(sa.text("select 1")).scalar() == 1
print("pool re-created after the fork")

Timeouts

--timeout is the most misunderstood setting. For the sync worker it is effectively a per-request limit: a worker that does not report back for 30 seconds is killed, the log says WORKER TIMEOUT, and the client gets a 502 or 504 from the proxy. For gthread and gevent the same timeout applies to the worker’s heartbeat rather than to one request, so a long request does not trip it, but a blocked event loop or a native call that never returns does. Raising --timeout to hide slow requests is a smell: move the work to a task queue (see Background Jobs, Celery & Email) or stream it (see Streaming & Server-Sent Events). Keep the three layers consistent: the proxy’s proxy_read_timeout (60 seconds by default in nginx) should be at least as long as Gunicorn’s timeout, otherwise the proxy gives up first and the user sees a 504 while the worker keeps working.

Logging and Gunicorn 25 and 26

Gunicorn writes its own error log and an optional access log; app.logger is Flask’s logger, which has no handler until you add one. To send application messages through Gunicorn’s handlers (and so to the same destination as the rest of the service logs), reuse its logger in the factory when running under Gunicorn; the logging page of this section covers formats and structured logs.

(root / "traced_logs.py").write_text(textwrap.dedent('''\
    import logging

    from bookshelf import create_app

    app = create_app()
    gunicorn_error = logging.getLogger("gunicorn.error")
    app.logger.handlers = gunicorn_error.handlers
    app.logger.setLevel(gunicorn_error.level)


    @app.get("/log")
    def log():
        app.logger.warning("the Bookshelf says hello from the application logger")
        return "ok"
    '''))

port = free_port()
argv = GUNICORN + ["-w", "1", "--access-logfile", "-", "-b", f"127.0.0.1:{port}", "traced_logs:app"]
with running(argv, port) as proc:
    urllib.request.urlopen(f"http://127.0.0.1:{port}/log").read()
print("\n".join(line for line in proc.log.splitlines() if "Bookshelf says" in line or "GET /log" in line))
assert "the Bookshelf says hello" in proc.log and '"GET /log HTTP/1.1" 200' in proc.log

Recent Gunicorn releases changed the surroundings of a Flask deployment, none of which affects application code. Version 25.1.0 added a control socket (default $XDG_RUNTIME_DIR/gunicorn.ctl, else $HOME/.gunicorn/gunicorn.ctl) that the gunicornc tool uses to inspect workers and change their number at run time; the startup log shows it, and --no-control-socket disables it, which is sensible in a container with no writable home directory. The 26.x series also bundles the asgi worker, HTTP/2 options (--http-protocols), a uWSGI-protocol option and "dirty" worker pools. They matter for ASGI applications, not for a Flask WSGI deployment behind nginx; the documentation has a page on each.

Waitress

Waitress is a pure Python WSGI server that is easy to configure and supports Windows directly. It uses a single process with several worker threads, and it always buffers a complete request body before calling the application: it does not support streaming request uploads. It needs no compilation. The only required argument to waitress-serve is the application in module:object form, and --call tells it to call the object to get the application, which is how a factory is served.

$ pip install waitress

# equivalent to 'from bookshelf import app'
$ waitress-serve --host 127.0.0.1 bookshelf:app

# equivalent to 'from bookshelf import create_app; create_app()'
$ waitress-serve --host 127.0.0.1 --call bookshelf:create_app
Serving on http://127.0.0.1:8080

Source: Flask: Waitress

Waitress listens on all addresses (0.0.0.0) and port 8080 by default, so a reverse-proxy setup must pass --host 127.0.0.1 (or --unix-socket). It does not log requests unless you configure Python logging. For anything beyond the command-line options, call waitress.serve() from a small script: it takes the settings as keyword arguments, and it is the only way to set the trusted-proxy options (the command-line runner of 3.0.2 has no --trusted-proxy flag; waitress-serve --help lists the options it does have, including --threads, --unix-socket, --url-scheme, --url-prefix, --channel-timeout and --max-request-body-size).

Argument Default Meaning

host, port, listen, unix_socket

0.0.0.0, 8080

Where to listen. listen takes space-separated host:port pairs; unix_socket replaces the TCP socket.

threads

4

Size of the worker thread pool. This is Waitress’s whole concurrency model: scale it for I/O wait.

max_request_body_size

1 GiB

Largest request body accepted. The default is far larger than any Flask application should accept; set a limit that matches MAX_CONTENT_LENGTH and the proxy’s body limit.

channel_timeout, connection_limit, backlog

120 s, 100, 1024

Idle connection timeout, maximum simultaneous connections (each uses at least one file descriptor) and the listen backlog.

ident

waitress

Value of the Server response header.

expose_tracebacks

False

Keep it off in production.

trusted_proxy

None

Address of the proxy whose forwarded headers Waitress believes. Without it, all X-Forwarded-* headers are ignored.

trusted_proxy_count, trusted_proxy_headers

1, the x-forwarded-proto header when trusted_proxy is set

How many proxies to trust and which headers to apply (x-forwarded-for, x-forwarded-host, x-forwarded-proto, x-forwarded-port, or the standard forwarded).

clear_untrusted_proxy_headers

True

Drop forwarded headers that were not trusted, so the application cannot be fooled by them.

Waitress can apply the forwarded headers itself, which makes ProxyFix unnecessary (applying both would count the proxy twice). The next example writes a launcher script and shows an application behind a trusted proxy seeing the client address, scheme and host that the proxy forwarded, with no middleware in Flask.

(root / "serve_waitress.py").write_text(textwrap.dedent('''\
    import os

    from waitress import serve

    from bookshelf import create_app

    serve(
        create_app(),
        host="127.0.0.1",
        port=int(os.environ["BOOKSHELF_PORT"]),
        threads=8,
        trusted_proxy="127.0.0.1",
        trusted_proxy_count=1,
        trusted_proxy_headers={"x-forwarded-for", "x-forwarded-proto", "x-forwarded-host"},
        clear_untrusted_proxy_headers=True,
    )
    '''))

port = free_port()
argv = [sys.executable, "serve_waitress.py"]
forwarded = {
    "X-Forwarded-For": "6.6.6.6, 203.0.113.7",   # a forged value, then the address nginx saw
    "X-Forwarded-Proto": "https",
    "X-Forwarded-Host": "bookshelf.example.com",
}
with running(argv, port, env={"BOOKSHELF_PORT": str(port)}) as proc:
    status, headers, body = get(port, "/whoami", forwarded)
print(headers["Server"], body)
assert body == {"remote_addr": "203.0.113.7", "scheme": "https", "host": "bookshelf.example.com"}

mod_wsgi and mod_wsgi-express

mod_wsgi is a WSGI server integrated into Apache httpd. The modern mod_wsgi-express command configures and starts Apache for you, with no hand-written httpd configuration. It is tightly integrated with Apache, supports Windows, needs a compiler and the Apache development headers to install (the installation fails with an error otherwise), and, unlike the other servers, does not need a reverse proxy in front of it. Install it into the virtual environment that holds the application. The only argument is a script that defines a variable called application; that is what the wsgi.py file written earlier is for.

$ pip install mod_wsgi

# the script must define a variable named 'application'
$ mod_wsgi-express start-server wsgi.py --processes 4

Source: Flask: mod_wsgi

--processes sets the number of worker processes (CPU * 2 is a starting value). Request logs do not appear in the terminal; on an error the information goes to the error log whose path is printed at startup. Binding a privileged port is the one place where running as root is acceptable, because mod_wsgi drops privileges for the workers: the official example creates a dedicated user and group, installs the application as that user, and starts the server with sudo.

$ sudo /home/bookshelf/.venv/bin/mod_wsgi-express start-server \
    /home/bookshelf/wsgi.py \
    --user bookshelf --group bookshelf --port 80 --processes 4

Source: Flask: mod_wsgi

When Apache httpd is already installed and managed with its own configuration (the style of the older books, which configured mod_wsgi by hand), use daemon mode. The application then runs in a separate group of processes, so code changes need no server restart: touching the WSGI script file makes mod_wsgi reload the daemon processes. In the default embedded mode the application runs inside the Apache worker processes and every code change requires an Apache restart. The directives are WSGIScriptAlias (mount the script), WSGIDaemonProcess (define the group, with processes, threads and python-home for the virtual environment) and WSGIProcessGroup.

# not run: needs Apache httpd with mod_wsgi installed
<VirtualHost *:80>
    ServerName bookshelf.example.com

    WSGIDaemonProcess bookshelf processes=2 threads=15 python-home=/srv/bookshelf/.venv
    WSGIProcessGroup bookshelf
    WSGIScriptAlias / /srv/bookshelf/wsgi.py

    <Directory /srv/bookshelf>
        Require all granted
    </Directory>
</VirtualHost>

Even here, a ProxyFix is only needed when Apache itself sits behind another proxy. Because mod_wsgi runs inside Apache, the Python code must be built for the same Python and architecture as the module; the usual cause of an Internal Server Error after an upgrade is a virtual environment created with a different interpreter than the one mod_wsgi was compiled against, so rebuild the module whenever the Python version changes.

uWSGI

uWSGI is a fast, compiled server suite with extensive configuration. It can be very performant, but it is complex to configure beyond the basics, it does not run natively on Windows, and its documentation states that the project is in maintenance mode (bug fixes and updates for new language APIs only), so new projects usually prefer Gunicorn. The simplest way to install it is the pyuwsgi package, which ships precompiled wheels; it has no SSL support, which is supplied by a reverse proxy anyway. If a compiler is available, pip install uwsgi (or pip install --no-binary pyuwsgi pyuwsgi) builds a version with SSL.

$ pip install pyuwsgi

# start an HTTP server, a master process, 4 workers, load wsgi:app
$ uwsgi --http 127.0.0.1:8000 --master -p 4 -w wsgi:app

Source: Flask: uWSGI

--http starts uWSGI’s own HTTP server (so that a browser or curl can talk to it), --master enables the standard worker manager, -p is the number of worker processes and -w says how to import the application. With an application factory you need the small wsgi.py file above, because -w wants a variable. In production the more common setup is not HTTP between nginx and uWSGI but uWSGI’s own binary protocol: uWSGI listens with socket (or http-socket for plain HTTP on a socket) and nginx uses uwsgi_pass with include uwsgi_params (the ngx_http_uwsgi_module), which is slightly more efficient and keeps the HTTP parser out of the picture. A configuration file keeps all of that in one place; the example below uses http-socket so that the verification can speak plain HTTP.

(root / "uwsgi.ini").write_text(textwrap.dedent('''\
    [uwsgi]
    module = wsgi:app
    master = true
    processes = 2
    die-on-term = true
    need-app = true
    vacuum = true
    harakiri = 30
    max-requests = 2000
    '''))

port = free_port()
argv = [str(pathlib.Path(sys.executable).parent / "uwsgi"), "--ini", "uwsgi.ini", "--http-socket", f"127.0.0.1:{port}"]
with running(argv, port) as proc:
    status, headers, body = get(port)
print(status, body)
assert status == 200 and "spawned uWSGI worker 2" in proc.log

Source: uWSGI: Options

The options in the file, as described by uwsgi --help: die-on-term makes uWSGI exit on SIGTERM instead of doing a brutal reload (systemd and containers stop services with SIGTERM, so without it a stop request restarts the workers); need-app exits when no application can be loaded; vacuum removes the files and sockets uWSGI created; harakiri kills a worker stuck for longer than that many seconds; max-requests recycles workers; lazy-apps (not set here) loads the application in each worker instead of in the master, which has the same fork-safety motivation as Gunicorn’s non-preloaded default. Nginx also speaks to uWSGI directly with uwsgi_pass, and Apache with mod_proxy_uwsgi.

Reverse Proxies

WSGI servers have HTTP servers built in, and the Flask documentation notes that "a dedicated HTTP server may be safer, more efficient, or more capable". Putting one in front is called a reverse proxy. The proxy listens on ports 80 and 443, runs as a user allowed to bind them, and forwards requests to the WSGI server on a loopback address or a Unix socket. Whatever proxy you pick must do four things.

Job Detail

Forward the real client information

X-Forwarded-For (client address), X-Forwarded-Proto (http or https), X-Forwarded-Host (the name the browser used) and, when the application is mounted below a path, X-Forwarded-Prefix. The application then needs ProxyFix (see the next section).

Limit and buffer

A maximum request body, read and send timeouts, and buffering of slow clients.

Terminate TLS

Certificates, protocol versions, HSTS and the redirect from HTTP to HTTPS (see HTTPS).

Serve static files

Straight from disk, without Python (see Static Files from the Proxy).

nginx

nginx is a fast production HTTP server. The Flask documentation’s minimal configuration is a server block with a proxy_pass to the WSGI server and the four proxy_set_header lines. The version below is the production shape for the Bookshelf: two server blocks (one only redirects to HTTPS), an upstream that points at the Gunicorn socket, TLS, a body limit that matches the application’s, the static location, and the forwarded headers. It is shown as configuration and was not run (no nginx binary was available).

# not run: needs nginx
upstream bookshelf {
    server unix:/run/bookshelf/gunicorn.sock fail_timeout=0;
}

server {
    listen 80;
    server_name bookshelf.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name bookshelf.example.com;

    ssl_certificate     /etc/letsencrypt/live/bookshelf.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/bookshelf.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    client_max_body_size 8m;          # keep in step with Flask's MAX_CONTENT_LENGTH

    location /static/ {
        alias /srv/bookshelf/current/bookshelf/static/;
        expires 7d;
        access_log off;
    }

    location / {
        proxy_pass http://bookshelf;
        proxy_redirect off;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_read_timeout 60s;
    }
}

Source: Flask: nginx

The details that matter: http2 on; is a directive of its own since nginx 1.25.1 (the http2 parameter of listen is deprecated); client_max_body_size defaults to 1m, so uploads of more than a megabyte fail with 413 at the proxy unless you raise it; proxy_read_timeout defaults to 60 seconds; and $proxy_add_x_forwarded_for appends the address nginx saw to whatever X-Forwarded-For the client sent, which is exactly why the number of trusted hops must be configured in Flask (see the next section). When nginx is the outermost proxy you can instead overwrite the header with proxy_set_header X-Forwarded-For $remote_addr;, so that no client-supplied value ever reaches the application. For a server-sent-events or other streaming response, nginx buffers by default: the application can send an X-Accel-Buffering: no header, or the location can set proxy_buffering off.

Apache httpd

Apache httpd works as a reverse proxy with mod_proxy and mod_proxy_http. ProxyPass sets X-Forwarded-For and X-Forwarded-Host itself; X-Forwarded-Proto must be set with RequestHeader because ProxyPass does not know the scheme the client used. The configuration below proxies to the same Gunicorn socket, keeps /static/ out of the proxy with ProxyPass /static/ ! (rules are evaluated in order, so the exclusion comes first) and serves it with Alias. A proxied request through this configuration to a real Gunicorn socket was verified against Apache 2.4.67 on macOS (with the paths and the TLS block adapted); the TLS lines are not run.

# the proxy section was run against Apache 2.4.67; the TLS lines were not
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http_module modules/mod_proxy_http.so
LoadModule headers_module modules/mod_headers.so
LoadModule ssl_module modules/mod_ssl.so

<VirtualHost *:443>
    ServerName bookshelf.example.com
    SSLEngine on
    SSLCertificateFile    /etc/letsencrypt/live/bookshelf.example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/bookshelf.example.com/privkey.pem
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"

    Alias /static/ /srv/bookshelf/current/bookshelf/static/
    <Directory /srv/bookshelf/current/bookshelf/static/>
        Require all granted
    </Directory>
    ProxyPass /static/ !

    ProxyPreserveHost On
    RequestHeader set X-Forwarded-Proto "https"
    ProxyPass        / "unix:/run/bookshelf/gunicorn.sock|http://localhost/"
    ProxyPassReverse / "unix:/run/bookshelf/gunicorn.sock|http://localhost/"
</VirtualHost>

Telling Flask It Is Behind a Proxy

From the WSGI server’s and Flask’s point of view, every request now comes from the proxy, on the proxy’s address and over plain HTTP, to the proxy’s idea of a host name. The consequences are all the same bug in different clothes:

  • request.remote_addr is always 127.0.0.1 (or the proxy’s address), so rate limiting, audit logs and strong session protection in Flask-Login treat every user as one;

  • request.scheme is http although the browser used HTTPS, so url_for(..., _external=True) and redirect() produce http:// URLs, OAuth redirect URIs do not match what the provider registered, and a "redirect to HTTPS" rule loops;

  • request.host is the proxy’s upstream name rather than the public name, which breaks absolute URLs and host checks;

  • the application’s mount path is invisible, so url_for generates links without the /shelf prefix.

The proxy tells the application the truth in X-Forwarded-* headers. Werkzeug ships the middleware that reads them, werkzeug.middleware.proxy_fix.ProxyFix, and the Flask documentation’s recipe wraps app.wsgi_app (not app, so the Flask object keeps its methods). The headers can be forged by any client, so the middleware is told how many proxies set each header and trusts exactly that many entries, counting from the right (the closest proxy’s entry is the last one in a list).

Argument Default What it trusts and what it changes

x_for

1

Number of proxies setting X-Forwarded-For; sets REMOTE_ADDR (request.remote_addr).

x_proto

1

Number of proxies setting X-Forwarded-Proto; sets wsgi.url_scheme (request.scheme).

x_host

0

X-Forwarded-Host; sets HTTP_HOST, SERVER_NAME and SERVER_PORT (request.host).

x_port

0

X-Forwarded-Port; sets HTTP_HOST and SERVER_PORT.

x_prefix

0

X-Forwarded-Prefix; sets SCRIPT_NAME (request.script_root and url_for prefixes).

Two details deserve emphasis. First, x_for and x_proto default to 1, not 0: ProxyFix(app.wsgi_app) with no arguments already trusts one proxy for the address and the scheme, but not for the host or the prefix. Second, the middleware must only be used if the application really is behind the proxies you configured; used on an application that clients can reach directly, it lets anyone choose their own address. The original values are kept in the environ under werkzeug.proxy_fix.orig for logging.

Counting hops

The example sends the same request through the ProxyFix settings that three different topologies need. In every case the real client is 203.0.113.7, a hostile client adds a forged X-Forwarded-For: 6.6.6.6 of its own, and nginx (10.0.0.5) appends the address it saw with $proxy_add_x_forwarded_for, so the application receives 6.6.6.6, 203.0.113.7. Only a hop count of one is right for a single nginx; one too many believes the forgery; none sees only the proxy.

def client_address(hops, forwarded_for, remote="10.0.0.5"):
    app = create_app(proxy_hops=hops)
    response = app.test_client().get(
        "/whoami",
        headers={"X-Forwarded-For": forwarded_for},
        environ_base={"REMOTE_ADDR": remote},
    )
    return response.json["remote_addr"]


forged = "6.6.6.6, 203.0.113.7"          # what nginx passes on when a client forges the header
print("one nginx, hops=0:", client_address(0, forged))   # the proxy's own address
print("one nginx, hops=1:", client_address(1, forged))   # correct
print("one nginx, hops=2:", client_address(2, forged))   # trusts the forgery
assert client_address(0, forged) == "10.0.0.5"
assert client_address(1, forged) == "203.0.113.7"
assert client_address(2, forged) == "6.6.6.6"

# A CDN (198.51.100.9) in front of nginx: the CDN appends the client, nginx appends the CDN.
chained = "203.0.113.7, 198.51.100.9"
print("CDN + nginx, hops=1:", client_address(1, chained))   # the CDN's address: safe but wrong
print("CDN + nginx, hops=2:", client_address(2, chained))   # correct
assert client_address(1, chained) == "198.51.100.9"
assert client_address(2, chained) == "203.0.113.7"

The hop count is a property of the infrastructure, so it belongs in configuration (the factory above reads BOOKSHELF_PROXY_HOPS), and it must be revisited when the infrastructure changes: adding a CDN or an AWS load balancer in front of nginx means one more hop. The usual counts are:

Topology x_for Note

Gunicorn bound to loopback behind one nginx or Apache httpd

1

The common case.

Cloud load balancer, then Gunicorn directly (no nginx)

1

The balancer appends the client address; check its documentation for X-Forwarded-Proto.

CDN, then nginx, then Gunicorn

2

Each proxy appends one entry.

Kubernetes ingress controller, then the pod

1 or more

Depends on the controller and on any cloud load balancer in front of it; read the headers actually received.

Developer machine, tests, flask run

0

Do not apply ProxyFix.

Host, scheme and prefix

The same middleware fixes the other three problems. This example mounts the application below /shelf the way a proxy that strips the prefix would, and checks that absolute URLs come out as the browser sees them.

from flask import Flask, url_for
from werkzeug.middleware.proxy_fix import ProxyFix

demo = Flask(__name__)
demo.wsgi_app = ProxyFix(demo.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)


@demo.get("/books")
def books():
    return {"self": url_for("books", _external=True)}


reply = demo.test_client().get("/books", headers={
    "X-Forwarded-Proto": "https",
    "X-Forwarded-Host": "bookshelf.example.com",
    "X-Forwarded-Prefix": "/shelf",
})
print(reply.json)
assert reply.json["self"] == "https://bookshelf.example.com/shelf/books"

Complements and alternatives

ProxyFix is not the only layer that reads these headers, and doubling up causes confusion rather than safety.

  • Gunicorn looks at secure_scheme_headers (by default X-Forwarded-Proto: https, X-Forwarded-Protocol: ssl, X-Forwarded-Ssl: on) and sets wsgi.url_scheme to https only when the request comes from an address in forwarded_allow_ips. It does not touch the client address. The next example proves it by sending X-Forwarded-Proto: https straight to a Gunicorn started without ProxyFix. Because the default allow-list is loopback, a proxy on another host needs --forwarded-allow-ips set to its address, never to * on an exposed port.

  • Waitress does it all itself with trusted_proxy (see above).

  • Uvicorn has --proxy-headers (on by default) and --forwarded-allow-ips; other ASGI servers, Hypercorn included, need their own configuration (see ASGI Deployment).

  • Flask can reject requests whose Host is not on a list: the TRUSTED_HOSTS configuration key (new in 3.1) takes host patterns, and Flask answers 400 for any other Host header. Combine it with x_host=1 so that the checked host is the forwarded one. See Security Considerations for the other host-header attacks.

port = free_port()
argv = GUNICORN + ["-w", "1", "-b", f"127.0.0.1:{port}", "bookshelf:create_app()"]
with running(argv, port) as proc:
    _, _, body = get(port, "/whoami", {"X-Forwarded-Proto": "https", "X-Forwarded-For": "6.6.6.6"})
print(body)
assert body["scheme"] == "https"            # Gunicorn trusted the loopback "proxy" for the scheme...
assert body["remote_addr"] == "127.0.0.1"   # ...but left the address alone

strict = create_app(config={"TRUSTED_HOSTS": ["bookshelf.example.com"]}, proxy_hops=0)
ok = strict.test_client().get("/healthz", base_url="http://bookshelf.example.com")
bad = strict.test_client().get("/healthz", base_url="http://evil.example.net")
print(ok.status_code, bad.status_code)
assert (ok.status_code, bad.status_code) == (200, 400)

HTTPS

The WSGI server speaks plain HTTP; the proxy terminates TLS. Gunicorn can serve TLS itself (--certfile, --keyfile), and Flask’s development ssl_context exists, but neither is the right place for certificates in production: a proxy renews certificates, supports modern protocols and does it in a process that runs nothing of yours. The checklist is short.

  • Get a certificate from a public CA. Let’s Encrypt with Certbot automates issuance and renewal, and the nginx and Apache configurations above already point at the certificate paths Certbot uses.

  • Redirect port 80 to 443 in the proxy (the first server block above), not in Flask, so that no application worker is spent on it.

  • Send Strict-Transport-Security from the proxy (or with Flask-Talisman). HSTS tells the browser never to use plain HTTP for the site again for max-age seconds, so start with a short value and add includeSubDomains only when every subdomain is ready. See Security Considerations for the other security headers.

  • Tell Flask the request was secure (X-Forwarded-Proto and x_proto), so that request.is_secure, request.scheme and every external URL say https, and set SESSION_COOKIE_SECURE = True so that browsers only send the session cookie over HTTPS. Flask adds the Secure attribute to the cookie whenever the setting is on, whatever scheme it thinks the request used; the browser enforces it. The consequence is that the setting must be off for plain-HTTP development (which is why the factory above reads it from the environment) and on in production.

  • Redirect loops are almost always a missing X-Forwarded-Proto or a missing ProxyFix: the application sees http, redirects to https, the proxy forwards the redirect target as a new request, and so on.

from flask import request, session

secure = Flask(__name__)
secure.config.update(SECRET_KEY=secrets.token_hex(32), SESSION_COOKIE_SECURE=True)
secure.wsgi_app = ProxyFix(secure.wsgi_app, x_for=1, x_proto=1)


@secure.get("/login")
def login():
    session["user"] = "ana"
    return "ok"


client = secure.test_client()
@secure.get("/scheme")
def scheme():
    return {"is_secure": request.is_secure, "scheme": request.scheme}


tls = client.get("/login", headers={"X-Forwarded-Proto": "https"})
cookie = tls.headers["Set-Cookie"]
print("cookie attributes:", "; ".join(cookie.split("; ")[1:]))
print("behind TLS:", client.get("/scheme", headers={"X-Forwarded-Proto": "https"}).json)
print("plain HTTP:", client.get("/scheme", headers={"X-Forwarded-Proto": "http"}).json)
assert "Secure" in cookie and "HttpOnly" in cookie
assert client.get("/scheme", headers={"X-Forwarded-Proto": "https"}).json["is_secure"] is True

Process Supervision

Nothing in the topology restarts itself. A supervisor starts the services at boot, restarts them when they crash, stops them cleanly on deploy, and keeps their logs. On a Linux host the standard supervisor is systemd; Supervisor is the alternative that runs without root and is the tool the Cookbook-style recipes use; in containers the runtime does the job and the application runs in the foreground.

systemd

A unit for Gunicorn uses Type=notify (Gunicorn tells systemd when it is ready), runs as an unprivileged user, creates the socket directory with RuntimeDirectory=, and reloads gracefully with SIGHUP. The environment file is owned by root with mode 0600, outside the repository, and holds the secrets. Gunicorn’s own documentation also shows socket activation with a gunicorn.socket unit, which lets systemd own the socket across restarts. Not run: the verification host is not Linux.

# /etc/systemd/system/bookshelf.service  (not run: needs systemd)
[Unit]
Description=Bookshelf (Gunicorn)
After=network.target

[Service]
Type=notify
User=bookshelf
Group=www-data
RuntimeDirectory=bookshelf
WorkingDirectory=/srv/bookshelf/current
EnvironmentFile=/etc/bookshelf/bookshelf.env
ExecStart=/srv/bookshelf/venv/bin/gunicorn --config /srv/bookshelf/current/gunicorn.conf.py 'bookshelf:create_app()'
ExecReload=/bin/kill -s HUP $MAINPID
Restart=on-failure
TimeoutStopSec=35
PrivateTmp=true
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target

TimeoutStopSec is a little longer than graceful_timeout, so systemd waits for Gunicorn’s graceful shutdown before it sends SIGKILL. /etc/bookshelf/bookshelf.env contains lines such as BOOKSHELF_SECRET_KEY=... and DATABASE_URL=...; create it once on the server (generate the key with python -c 'import secrets; print(secrets.token_hex(32))') and never put it in version control. A Celery worker gets a unit of its own, with the same EnvironmentFile.

Supervisor

Supervisor is a process-control system written in Python. A [program:...] section describes each service; autorestart keeps it alive, and %(ENV_NAME)s expansions let the file read variables from the environment of the supervisord process, so the secrets still live outside the file. The next example writes a configuration for the Bookshelf with local paths, starts supervisord, shows supervisorctl status, sends a request through the supervised Gunicorn, and shuts everything down.

sup = pathlib.Path(tempfile.mkdtemp(prefix="bs-sup-"))
port = free_port()
conf = sup / "supervisord.conf"
conf.write_text(textwrap.dedent(f'''\
    [unix_http_server]
    file={sup}/supervisor.sock

    [supervisord]
    logfile={sup}/supervisord.log
    pidfile={sup}/supervisord.pid
    childlogdir={sup}

    [rpcinterface:supervisor]
    supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

    [supervisorctl]
    serverurl=unix://{sup}/supervisor.sock

    [program:bookshelf]
    command={sys.executable} -m gunicorn -w 2 -b 127.0.0.1:{port} 'bookshelf:create_app()'
    directory={root}
    autostart=true
    autorestart=true
    stopsignal=TERM
    stopwaitsecs=35
    environment=PYTHONPATH="{root}",BOOKSHELF_SECRET_KEY="%(ENV_BOOKSHELF_SECRET_KEY)s"
    '''))

bin_dir = pathlib.Path(sys.executable).parent
subprocess.run([str(bin_dir / "supervisord"), "-c", str(conf)], check=True, env=os.environ)
try:
    for _ in range(100):
        try:
            get(port)
            break
        except OSError:
            time.sleep(0.2)
    for _ in range(50):                      # RUNNING after "startsecs" (1 second by default)
        status = subprocess.run([str(bin_dir / "supervisorctl"), "-c", str(conf), "status"],
                                capture_output=True, text=True).stdout
        if "RUNNING" in status:
            break
        time.sleep(0.2)
    print(status.strip())
    print(get(port)[2])
    assert "RUNNING" in status
finally:
    subprocess.run([str(bin_dir / "supervisorctl"), "-c", str(conf), "shutdown"], capture_output=True)

Supervisor’s own web interface and inet_http_server are not needed and should stay off; the control socket above is a file. In a container none of this applies: run Gunicorn as the main process, let the platform restart the container, and make sure the process handles SIGTERM (Gunicorn does) and runs as PID 1 or under a small init.

Static Files from the Proxy

Flask serves its static folder, but it spends a worker on every image, stylesheet and script. In production the proxy serves them: nginx’s location /static/ { alias ...; } (and the Apache Alias plus ProxyPass /static/ !) in the configurations above. Three consequences:

  • the proxy must be able to read the files, so deploy the built assets to a directory the web user can read, and keep them in step with the release (a directory per release behind a current symlink is the usual layout);

  • url_for("static", ...) still builds the URLs, with a version query string or a hashed file name for cache busting, and the proxy sets the long Cache-Control / expires lifetime;

  • files that need authorization are not static. The proxy can still send them without Python: the application checks permission and answers with an internal-redirect header (X-Accel-Redirect for nginx, X-Sendfile for Apache) naming the file, which the proxy then serves.

The static-files page has the Flask side in full (static_folder, static_url_path, cache headers, X-Sendfile, CDNs and Vite manifests): see Static Files & Assets. Uploaded files that users submit are a different matter; see File Uploads for where they should live.

Sizing Workers and Threads

There is no formula that replaces measurement, but the rules of thumb are good starting points and they cap the damage of a wrong guess.

  • CPU-bound code (templates, serialization, image work): one worker per core, a few more at most. More processes than cores only add context switches.

  • I/O-bound code (waiting for a database, an HTTP call, a cache): either more sync workers (2 x CPU + 1 is the documented rule), or fewer workers with threads (gthread) or greenlets (gevent), which hold many waiting requests in little memory. The Python GIL still serializes Python bytecode inside one process.

  • Memory limits the count: every worker holds a full copy of the application. Measure the resident size of one worker under load, multiply by the number of workers, and keep the total below the memory you can afford (minus what the database, proxy and Celery use). --preload shares the imported modules between workers.

  • The database limits it more often than the CPU. Each worker process has its own connection pool (SQLAlchemy’s default QueuePool allows 5 connections plus 10 overflow), so the number of connections the application may open is workers x threads-or-pool x (pool_size + max_overflow), to be compared with the database’s connection limit (PostgreSQL’s max_connections). Shrink the pools or use a pooler such as PgBouncer before adding workers; see Flask-SQLAlchemy for the engine options.

The next example turns the advice into two small functions and checks them with the numbers of a typical small server.

import multiprocessing


def recommended_workers(cores):
    """The documented starting point for sync workers: (2 x cores) + 1."""
    return 2 * cores + 1


def memory_limited(workers, rss_mb, budget_mb):
    """Never start more workers than the memory budget allows."""
    return min(workers, budget_mb // rss_mb)


def max_db_connections(workers, pool_size=5, max_overflow=10):
    """Worst case for one pool per process (sync workers use one connection at a time)."""
    return workers * (pool_size + max_overflow)


cores = 4
workers = memory_limited(recommended_workers(cores), rss_mb=120, budget_mb=1024)
print("this machine has", multiprocessing.cpu_count(), "cores; the example server has", cores)
print("workers:", workers, "| worst-case database connections:", max_db_connections(workers))
assert workers == 8                       # 9 would be allowed by the rule, memory allows 8
assert max_db_connections(workers) == 120 # more than a default PostgreSQL max_connections of 100
assert max_db_connections(workers, pool_size=2, max_overflow=1) == 24

The second assertion is the point: eight workers with default pools can ask a default PostgreSQL for more connections than it allows, so size the pool to the worker (a sync worker needs one connection at a time) and the worker count to the database. Verify with a load test (wrk, hey, Locust), watch the workers' resident size and the queue in the proxy’s logs, and change one number at a time. Rolling restarts with --max-requests keep slow leaks from accumulating.

Reloading and Zero-Downtime Deploys

A deploy should not drop requests. Gunicorn’s signals cover it: HUP reloads the configuration, starts new workers and gracefully stops the old ones (and, if the application is not preloaded, reloads the application code too); TERM is a graceful shutdown that waits up to graceful_timeout; USR2 starts a new master and workers for a binary upgrade (then TERM the old master); TTIN and TTOU add and remove a worker; USR1 reopens log files. systemd’s ExecReload above sends HUP, so systemctl reload bookshelf is the deploy command for code changes without a preload. With --preload, send USR2 or restart the service. Database migrations are the other half of a deploy: apply migrations that are compatible with both the old and the new code before the reload (see Database Migrations).

import signal

port = free_port()
argv = GUNICORN + ["-w", "2", "-b", f"127.0.0.1:{port}", "bookshelf:create_app()"]
with running(argv, port) as proc:
    first = {get(port)[2]["pid"] for _ in range(20)}
    proc.send_signal(signal.SIGHUP)               # reload: new workers replace the old ones
    time.sleep(2)
    second = {get(port)[2]["pid"] for _ in range(20)}
print("worker pids before the reload:", sorted(first))
print("worker pids after the reload :", sorted(second))
assert not first & second, "all workers should have been replaced"
assert "Handling signal: hup" in proc.log

ASGI Deployment

Flask is a WSGI application. To run it on an ASGI server you need adapter middleware; the Flask documentation recommends asgiref’s WsgiToAsgi, because it integrates with the event loop Flask uses for its own async def support. Wrap the application and serve the wrapper:

(root / "asgi_app.py").write_text(textwrap.dedent('''\
    from asgiref.wsgi import WsgiToAsgi

    from bookshelf import create_app

    asgi_app = WsgiToAsgi(create_app())
    '''))

results = {}
for name, server in (
    ("Hypercorn", [sys.executable, "-m", "hypercorn", "asgi_app:asgi_app", "--bind", "{bind}"]),
    ("Uvicorn", [sys.executable, "-m", "uvicorn", "asgi_app:asgi_app", "--host", "127.0.0.1", "--port", "{port}"]),
    ("Gunicorn", GUNICORN + ["-k", "asgi", "-w", "1", "-b", "{bind}", "--no-control-socket", "asgi_app:asgi_app"]),
):
    port = free_port()
    argv = [part.format(bind=f"127.0.0.1:{port}", port=port) for part in server]
    with running(argv, port) as proc:
        status, headers, body = get(port, "/whoami", {"X-Forwarded-Proto": "https"})
    print(f"{name:9}", status, headers.get("server"), body)
    results[name] = body
    assert status == 200
assert results["Uvicorn"]["scheme"] == "https"      # Uvicorn applied X-Forwarded-Proto itself
assert results["Hypercorn"]["scheme"] == "http"     # Hypercorn did not

Source: Flask: ASGI

The output shows a difference worth remembering: Uvicorn trusts X-Forwarded-Proto from loopback by default and reported https, while Hypercorn left the scheme as http, so each ASGI server needs its own proxy-header configuration (or a ProxyFix around the Flask application, but not both). This works, and it is useful in two situations: a platform that only runs ASGI servers, and a project that wants Flask and an ASGI application (a FastAPI sub-application, a WebSocket endpoint) in one process. It is not a performance feature. The adapter runs each Flask request in a worker thread of the ASGI server, so the application still handles one request per thread, and its blocking calls still block that thread. The concurrency model of async def views and its limits are in Async, gevent & Quart, including when to move to Quart instead. To serve ASGI from Gunicorn, the 26 series has its own asgi worker class (gunicorn -k asgi asgi_app:asgi_app, run in the example above); the older uvicorn.workers module is deprecated upstream and the maintained package is uvicorn-worker, as the FastAPI deployment page explains. The same adapter idea runs in the other direction too: FastAPI can mount a Flask application with WSGIMiddleware, covered in FastAPI: Sub-Applications, WSGI & GraphQL.

Forwarded headers still apply: under ASGI the server fills the scope’s client and scheme (Uvicorn honours X-Forwarded-Proto from trusted addresses by default through --proxy-headers and --forwarded-allow-ips), the adapter translates the scope into the WSGI environ, and a ProxyFix inside the Flask application keeps working because the X-Forwarded-* request headers are passed through. Apply one mechanism, not both.

Tornado’s WSGIContainer, and Why Not

The Flask Cookbook’s deployment chapter includes a recipe that serves Flask with Tornado: wrap the application in tornado.wsgi.WSGIContainer and start Tornado’s HTTP server. It works, so it is worth knowing, and the Tornado documentation itself explains why it is not a production recommendation: WSGI is a synchronous interface and Tornado is a single-threaded asynchronous server, so by default the whole application runs on the event-loop thread (one request at a time per process, and every blocking call stalls every connection), and Tornado’s own strengths (long polling, WebSockets) are unavailable to the WSGI part. For a WSGI-only application the Tornado documentation recommends a dedicated WSGI server such as Gunicorn or uWSGI; WSGIContainer exists so that WSGI applications and native Tornado handlers can live in one process. Since Tornado 6.3 the container accepts an executor argument; running the application in a ThreadPoolExecutor removes the worst of the problem, and Tornado "highly recommends" it for any thread-safe application. The Tornado section’s WSGI Interoperability page covers the integration in depth; here is the shape, run for real.

(root / "serve_tornado.py").write_text(textwrap.dedent('''\
    import asyncio
    import os
    from concurrent.futures import ThreadPoolExecutor

    from tornado.httpserver import HTTPServer
    from tornado.wsgi import WSGIContainer

    from bookshelf import create_app


    async def main():
        container = WSGIContainer(create_app(), executor=ThreadPoolExecutor(8))
        HTTPServer(container).listen(int(os.environ["BOOKSHELF_PORT"]), address="127.0.0.1")
        await asyncio.Event().wait()


    asyncio.run(main())
    '''))

port = free_port()
with running([sys.executable, "serve_tornado.py"], port, env={"BOOKSHELF_PORT": str(port)}) as proc:
    status, headers, body = get(port)
print(status, headers["Server"], body)
assert status == 200

Pitfalls

Symptom Cause and fix

Users are logged out at random

Each worker generated its own SECRET_KEY at import time (secrets.token_hex() in the factory), so a cookie signed by one worker fails verification in another. Generate the key once, outside the process, and share it through the environment. The example below reproduces the bug.

502 Bad Gateway

The proxy cannot reach the WSGI server: it is not running, the socket path differs, or the proxy’s user cannot read the socket (check the socket directory and --umask).

504 Gateway Timeout, WORKER TIMEOUT in the log

A request ran longer than --timeout or the proxy’s proxy_read_timeout. Move the work to a task queue; do not just raise the number.

Every request comes from 127.0.0.1

No ProxyFix (or the wrong hop count), or the proxy does not send X-Forwarded-For.

Redirects and external URLs use http://

The proxy does not send X-Forwarded-Proto, or x_proto is 0.

413 Request Entity Too Large before Flask sees the request

The proxy’s client_max_body_size (default 1m in nginx) is smaller than the upload.

The application is reachable on the server’s port, bypassing the proxy

The WSGI server is bound to 0.0.0.0. Bind to loopback or a Unix socket.

Static files return 404 after a deploy

The proxy’s alias path points at the old release directory, or lacks the trailing slash that matches the location.

A streamed response arrives all at once

The proxy buffers it. Set X-Accel-Buffering: no or proxy_buffering off for that route.

Changes are not picked up

The WSGI server was not reloaded (HUP), or --preload is on, or mod_wsgi runs in embedded mode.

Database errors that only appear in production

A connection pool was opened before the fork (--preload) or the pools are larger than the database allows.

The first row deserves its demonstration. Two application objects stand for two workers that each draw their own random key at start-up; a session cookie issued by one is unreadable to the other. The fix is not to avoid random keys but to create the key once and hand it to every worker.

def broken_factory():
    """One key per process: the bug."""
    app = Flask(__name__)
    app.secret_key = secrets.token_hex(32)

    @app.get("/set")
    def set_value():
        session["user"] = "ana"
        return "set"

    @app.get("/who")
    def who():
        return {"user": session.get("user")}

    return app


worker_a, worker_b = broken_factory(), broken_factory()
browser = worker_a.test_client()
browser.get("/set")
cookie = browser.get_cookie("session")
other = worker_b.test_client()
other.set_cookie("session", cookie.value, domain="localhost")
print("worker A:", browser.get("/who").json, "| worker B:", other.get("/who").json)
assert browser.get("/who").json == {"user": "ana"}
assert other.get("/who").json == {"user": None}

Where the Books Are Outdated

The Flask Cookbook’s deployment chapter (and the containers chapter of the same book, and the deployment chapter of the full-stack book) is the reason this page exists; its structure is sound and a lot of its detail has aged. The page above replaces these points.

In the books Today

Apache httpd with mod_wsgi, configured by hand, in embedded mode

mod_wsgi-express for a self-contained server, or daemon mode (WSGIDaemonProcess) when Apache is managed separately.

uWSGI with nginx as the standard recommendation

uWSGI is in maintenance mode; Gunicorn is the usual default. pyuwsgi removes the compiler requirement.

Gunicorn with Supervisor, -k eventlet or -k gevent

Still a valid recipe (see Process Supervision), but systemd is the common supervisor on Linux, and there is no eventlet worker in Gunicorn 26.2.0 (eventlet is unmaintained); use -k gevent.

python app.py (with app.run(debug=True)) as a container’s command, or in a Dockerfile

Always a production WSGI server; never the development server or the debugger.

FLASK_ENV=production in .flaskenv or the Dockerfile

FLASK_ENV was deprecated in 2.2 and removed in 2.3, and does nothing; production is simply the absence of --debug.

FLASK_APP=app.py flask run

flask --app bookshelf run for development only.

Tornado WSGIContainer as a deployment target

Not recommended (see above); use it only to mix Tornado handlers and a WSGI application, with an executor.

Heroku (free tier) and Elastic Beanstalk as the deployment platforms

The Heroku free tier is gone; see the hosting platforms of the official documentation in the cloud-platforms page of this section.

Hard-coded SECRET_KEY in the application module

A key generated once and kept in the environment or a secrets manager.

Self-signed or hand-managed certificates, TLS in the application

Certificates and TLS in the proxy, renewed automatically with an ACME client such as Certbot.

Hosting platforms (PythonAnywhere, Google App Engine, Cloud Run, AWS Elastic Beanstalk, Azure App Service), the container build and its CI, and the monitoring tools that the book’s same chapter covers are in the pages Docker & Kubernetes and Cloud Platforms & Monitoring of this section. For the cloud services themselves, see Cloud. File uploads to object storage (the chapter’s S3 recipe) are in File Uploads.

Recent Changes

Version Change

Flask 2.2

flask --app <module> run --debug becomes the documented command; FLASK_ENV is deprecated.

Flask 2.3

FLASK_ENV, ENV and app.env are removed.

Flask 3.1

TRUSTED_HOSTS configuration key rejects unexpected Host headers.

Tornado 6.3

WSGIContainer(executor=...); the thread-pool default is planned for Tornado 7.

Gunicorn 25.1

A control socket is enabled by default (disable with --no-control-socket); the 26 series adds the bundled asgi worker and HTTP/2 options and bundles no eventlet worker.

Nginx 1.25.1

http2 on; replaces the http2 parameter of listen.

Flask 3.2 (unreleased)

No deployment-specific changes are documented at the time of writing.