App Deployment·8 min read·

Uvicorn Workers for FastAPI and ASGI on a VPS

Run Uvicorn with multiple workers for FastAPI on Ubuntu, then bind it to localhost and proxy through nginx so ASGI stays fast and private on one VPS.

NB

Netbay Infrastructure Team

Netbay Engineering

On this page

FastAPI speaks ASGI, not WSGI. Gunicorn can host Uvicorn workers, but the straightforward production path on one Ubuntu box is Uvicorn's own multiprocess mode: uvicorn --workers N, bound to 127.0.0.1, with nginx in front. That is the same split as the WSGI post, with different worker semantics.

ASGI matters because FastAPI, Starlette, and Django 4+ async views expect an event loop. Running them under a sync WSGI server either blocks the loop or silently drops async benefits. Use an ASGI server. Uvicorn is the default. Hypercorn is fine too. The rest of this post is how you stop treating Uvicorn like a laptop --reload process.

One Process Versus Several Workers

Uvicorn's --reload flag is a development watcher. It must not run in production. Production uses --workers, which starts a supervisor plus N worker processes. Each worker has its own event loop and its own connection pool.

bash
sudo -u app -H /srv/app/.venv/bin/pip install "uvicorn[standard]==0.30.6" "fastapi==0.115.0"
sudo -u app -H /srv/app/.venv/bin/uvicorn app.main:app \
  --host 127.0.0.1 \
  --port 8000 \
  --workers 2 \
  --proxy-headers \
  --forwarded-allow-ips 127.0.0.1 \
  --timeout-keep-alive 5 \
  --no-access-log

[standard] pulls uvloop and httptools, which is what you want on Linux. --host 127.0.0.1 is mandatory. --proxy-headers plus --forwarded-allow-ips 127.0.0.1 lets nginx inject X-Forwarded-For and proto without letting the public internet spoof them. If you skip the allow-ips pin, any client can fake a client address.

Two workers is a better default than four on a 2 GB VPS. Async workers handle many sockets, so CPU count is not the same formula as Gunicorn sync workers. The limit is RAM, file descriptors, and database connections. Each worker is a full Python process with a copy of the app.

Keep access logs in nginx, not in every Uvicorn worker. Duplicate access logs triple disk writes and make journalctl noisy. Keep Uvicorn error output on stderr so systemd captures crashes.

Nginx for ASGI Is Almost the WSGI Config

WebSockets and long-lived SSE streams are the difference. You must turn off buffering and raise timeouts for those paths.

nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl;
    server_name api.example.com;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 120s;
    }
}

proxy_http_version 1.1 is required for Upgrade. Without it, WebSocket handshakes die with 400 or a silent hang. proxy_read_timeout 120s is a floor for SSE; set it higher if your stream is idle for minutes, or send a comment ping from FastAPI every 30 seconds so nginx does not need an hour-long timeout.

Do not run Uvicorn with --ssl-keyfile in production on this layout. Terminate TLS in nginx. Certificate renewal then reloads nginx, not the Python workers, and you keep one place for ciphers.

Lifespan, Shared State, and What Not to Store

Each worker runs startup lifespan independently. A cache you fill in @app.on_event("startup") is per process, not shared. In-memory rate limits, in-memory job counters, and module-global dicts will disagree across workers. If the data must be shared, put it in Redis or Postgres. If it is a read-only model you load once, loading it twice is fine and often faster than a network hop.

FastAPI dependency-injected database sessions must be closed per request. Async engines should use a small pool per worker: pool_size=5, max_overflow=5 is 10 connections per worker. Two workers is 20. Postgres on the same box with max_connections=100 still has room, but the next Celery worker will not if you start at 20 and guess upward.

Avoid --reload, debug=True, and dumping tracebacks to clients. Starlette debug pages leak paths and env. Set FastAPI(docs_url=None, redoc_url=None) on production if the OpenAPI UI should not be public.

Process Model and systemd

Uvicorn's multiprocess mode already has a supervisor. Point systemd at that one command with Restart=always. Do not wrap it in another process manager. Do not run two Uvicorn masters on the same port and hope SO_REUSEPORT saves you unless you have measured it.

On Intel Xeon Platinum VPS hardware in Lucknow, two workers plus nginx plus Postgres on 4 GB RAM is a comfortable FastAPI API. High-Speed SSD helps cold imports and bytecode, but the runtime bottleneck is almost always the database or an accidental blocking call inside async def. grep your code for time.sleep, requests.get, and CPU-heavy JSON work; those stall the event loop for every connection on that worker.

ASGI: nginx, Uvicorn master, two workers nginx TLS WS upgrade, proto Uvicorn master 127.0.0.1:8000 worker A event loop own DB pool worker B event loop own DB pool in-memory state is per worker share via Redis or Postgres

Takeaway

Uvicorn workers are separate processes with separate event loops. Bind localhost, trust forwarded headers only from nginx, size the worker count against RAM and database connections, and keep --reload off. FastAPI on a VPS is not a laptop command with more RAM.

You can spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds and run this ASGI layout — netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance