Deploying Python Web Apps to Production: gunicorn, uvicorn, systemd, Docker

5 min read

Once development wraps up with python manage.py runserver or uvicorn main:app --reload, the temptation is to put exactly that on a server. Don’t. Development servers are single-process and built for debugging convenience: they cannot take concurrent traffic, and when they die, nothing brings them back. This post walks through the standard production stack for Python web apps, end to end.

The big picture: four layers #

The production stack is clearest as four layers with distinct roles.

Overall stack
client
  → nginx (reverse proxy: TLS, static files, buffering)
    → gunicorn (process manager: supervises multiple workers)
      → worker processes (uvicorn workers or sync workers: run the app)
        → application (Django, FastAPI, Flask)

Why each layer exists is the backbone of this post. Let’s descend one at a time.

WSGI and ASGI: the contract between app and server #

Python has standard interfaces between web servers and frameworks.

  • WSGI: the synchronous contract. Django (classic setup) and Flask live here, and gunicorn’s sync workers are the standard server.
  • ASGI: the asynchronous contract. FastAPI, Starlette, and async Django live here, with uvicorn as the flagship server.

Which side your app is on decides the server choice. When in doubt, whatever your framework’s deployment docs say is the answer.

gunicorn + uvicorn workers: the de facto standard combo #

uvicorn alone can serve traffic, but process management — restarting dead workers, managing worker counts, graceful restarts — is where gunicorn is mature. So the standard setup for ASGI apps is gunicorn as the process manager with uvicorn as the worker class.

Run
# FastAPI (ASGI)
gunicorn main:app \
  --worker-class uvicorn.workers.UvicornWorker \
  --workers 4 \
  --bind 127.0.0.1:8000 \
  --timeout 60 \
  --graceful-timeout 30

# Django/Flask (WSGI): no worker class needed
gunicorn myproject.wsgi:application --workers 4 --bind 127.0.0.1:8000

For the worker count, start from the old rule of thumb CPU cores × 2 + 1. It is a starting point, not an answer. Trim toward core count for CPU-bound apps, add a little for I/O-heavy ones, and remember two costs: each worker consumes that much more memory, and database connection pools multiply by worker count (the arithmetic covered in SQLAlchemy 2.0 #2). Load testing settles it.

systemd: hand the process to the OS #

Start gunicorn from a terminal, drop the SSH session, and the process goes with it. nohup and screen are stopgaps. On a Linux server, process lifecycle belongs to systemd.

/etc/systemd/system/myapp.service
# /etc/systemd/system/myapp.service
[Unit]
Description=myapp gunicorn service
After=network.target

[Service]
User=myapp
WorkingDirectory=/srv/myapp
Environment="DATABASE_URL=postgresql://..."
ExecStart=/srv/myapp/.venv/bin/gunicorn main:app \
  --worker-class uvicorn.workers.UvicornWorker --workers 4 \
  --bind 127.0.0.1:8000
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
Manage the service
sudo systemctl enable --now myapp   # start on boot + start now
sudo systemctl status myapp         # check status
sudo systemctl restart myapp        # restart after deploys
journalctl -u myapp -f              # follow logs

The single line Restart=always solves “bring it back when it dies.” The practical details: use the absolute path to gunicorn inside the virtual environment, and move secrets out of the unit file into an EnvironmentFile=.

nginx: why something stands in front of the app server #

There are concrete reasons not to expose gunicorn on port 80 and to put nginx in front instead.

  • TLS termination: nginx owns certificate management and HTTPS.
  • Static files: serving CSS, JS, and images from Python workers is a waste. nginx serves them directly.
  • Buffering: so a slow client cannot hold a Python worker hostage until the response is fully delivered, nginx takes the response and transmits it on the worker’s behalf. Worker occupancy drops sharply.
  • It also doubles as the defensive line: request size limits, timeouts, access logs.
nginx.conf
server {
    listen 443 ssl;
    server_name example.com;

    location /static/ {
        alias /srv/myapp/static/;
    }
    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

If you deploy with Docker #

In containers, the orchestrator (Docker Compose, Kubernetes, etc.) takes systemd’s seat, and inside the image gunicorn runs in the foreground.

Dockerfile
FROM python:3.13-slim

WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev

COPY . .

# in containers: logs to stdout, run in the foreground
CMD ["uv", "run", "gunicorn", "main:app", \
     "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--workers", "2", "--bind", "0.0.0.0:8000", \
     "--access-logfile", "-", "--error-logfile", "-"]
  • Separating the dependency-install layer from the code-copy layer keeps the build cache alive when only code changes, making builds fast. Lock-file reproducibility is covered in Python Packaging #5.
  • In containers, keep worker counts low (1〜2 per container) and scale with container count — the shape orchestrators are built around.

The final checklist: logs, health checks, shutdown #

  • Logs to stdout: emit to standard output instead of files, and let journald or the container runtime collect them. This designs away the “where are the logs” hunt entirely.
  • A health check endpoint: something like /healthz lets load balancers and orchestrators route traffic only to live processes. Distinguishing readiness (checks the DB too) from liveness (process is alive) is even better.
  • Graceful shutdown: to avoid cutting in-flight requests during a deploy restart, the server must stop accepting new requests, finish the current ones, then exit. gunicorn’s --graceful-timeout does this — and in containers, the prerequisite is the exec form (JSON array) CMD so that gunicorn receives SIGTERM directly.
  • Configuration via environment variables: database URLs and secret keys go in the environment, never in code or the image.

Summary #

  • The production stack is layered: nginx (proxy), gunicorn (process management), workers (app execution). Development servers never serve production.
  • WSGI apps use gunicorn sync workers; ASGI apps use gunicorn + uvicorn workers. Start worker counts at cores × 2 + 1 and settle with load tests.
  • Process survival belongs to systemd (or the container orchestrator); logs go to stdout; configuration moves to environment variables.
  • nginx is the front line that relieves Python workers of TLS, static files, and buffering.
  • Health checks and graceful shutdown are what make zero-downtime deploys real.
X