CLOUD
development

Deploying FastAPI to Production: Uvicorn, Gunicorn and Nginx Done Right

CLOUD|October 18, 2026|8 min read|138 views
Deploying FastAPI to Production: Uvicorn, Gunicorn and Nginx Done Right

Run FastAPI under Gunicorn with Uvicorn workers behind Nginx, start at two workers per vCPU, and keep secrets in a locked env file. On a 4 vCPU / 8 GB server, eight workers serve about 2,300 requests per second using just 1.2 GB of RAM.

The setup that holds up in production is simple: run FastAPI under Gunicorn with Uvicorn workers, put Nginx in front for TLS, buffering and timeouts, start with roughly 2 workers per vCPU for I/O bound APIs and 1 per vCPU for CPU heavy endpoints, keep secrets in a root owned environment file with 600 permissions, and expose a /health endpoint that systemd, Nginx and your uptime monitor can all hit. On a 4 vCPU / 8 GB server, eight workers of a typical JSON API sit around 1.2 GB of RAM in total and handle 1,500 to 2,500 simple requests per second with p95 latency below 40 ms, which leaves plenty of headroom for PostgreSQL and Redis on the same machine. Running uvicorn main:app by itself in a tmux session is fine for a demo, but it gives you one process, no automatic restart when a worker leaks memory, and no protection against slow clients holding sockets open. The rest of this guide walks through each layer with the exact files we use: the process manager, the worker math, the systemd unit, the Nginx block with timeouts tuned for AI inference, secrets handling, health checks, and real resource numbers so you can size the server before you buy it.

Uvicorn alone or Gunicorn with Uvicorn workers

Uvicorn is the ASGI server that actually speaks HTTP to your FastAPI app. Gunicorn is a mature process manager that forks workers, watches them, and replaces any worker that dies or stops responding within the --timeout window. When you combine them, Gunicorn handles supervision and Uvicorn handles the async event loop inside each worker.

Recent Uvicorn releases also support --workers, and for many small services that is enough. The difference shows up under pressure, as the table below makes clear.

Capabilityuvicorn --workersgunicorn + UvicornWorker
Restart a hung workerLimitedYes, after --timeout seconds
Recycle workers to contain leaksNoYes, --max-requests 2000 --max-requests-jitter 200
Graceful reload on deployBasicYes, kill -HUP on the master
Extra memory overheadNoneAbout 25 MB for the master process

For anything that carries real traffic or loads a machine learning model, the 25 MB overhead is a cheap price for automatic recovery. Install both with pip install "gunicorn==23.0.0" "uvicorn[standard]==0.32.0" and pin the versions in your requirements file so a deploy never pulls a surprise upgrade.

How many workers per core

The classic Gunicorn formula is (2 x cores) + 1, which was written for synchronous workers. Async workers handle many concurrent requests each, so you rarely need that many. A practical starting point looks like this:

  • I/O bound API (database queries, calls to other services): 2 workers per vCPU, so 8 workers on 4 vCPU.
  • CPU bound endpoints (image processing, PDF generation, heavy pandas work): 1 worker per vCPU, and move the heavy part to a background queue if it takes more than 2 seconds.
  • Model inference inside the app: every worker loads its own copy of the model, so a 1.5 GB model with 4 workers needs 6 GB of RAM before you serve a single request. Here you either run 1 or 2 workers, or split inference into its own service.

Measure, then adjust. Watch ps -o rss,cmd -C gunicorn after an hour of traffic. A plain FastAPI worker with SQLAlchemy usually settles between 90 and 160 MB. If a worker keeps growing past 400 MB, you have a leak, and --max-requests keeps it in check while you find it.

The systemd unit

systemd starts the app on boot, restarts it on failure and captures logs in journald. Create /etc/systemd/system/api.service:

[Unit]
Description=FastAPI app
After=network.target

[Service]
User=app
Group=app
WorkingDirectory=/srv/api
EnvironmentFile=/etc/api/api.env
ExecStart=/srv/api/.venv/bin/gunicorn main:app \
  -k uvicorn.workers.UvicornWorker \
  --workers 8 --bind 127.0.0.1:8000 \
  --timeout 60 --graceful-timeout 30 \
  --max-requests 2000 --max-requests-jitter 200
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=3
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

Bind to 127.0.0.1, never 0.0.0.0, so the only public entry point is Nginx. Run systemctl daemon-reload && systemctl enable --now api, and on each deploy use systemctl reload api for a zero downtime worker swap.

Nginx in front, with timeouts that fit AI workloads

Nginx terminates TLS, buffers slow uploads so your workers never wait on a 3G client, and enforces request size limits. The default proxy_read_timeout of 60 seconds is fine for normal endpoints but will cut off a long LLM response halfway, so give streaming routes their own location block:

upstream api { server 127.0.0.1:8000; keepalive 32; }

server {
    listen 443 ssl http2;
    server_name api.example.sa;
    client_max_body_size 20m;

    location / {
        proxy_pass http://api;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
    }

    location /v1/chat/stream {
        proxy_pass http://api;
        proxy_http_version 1.1;
        proxy_buffering off;
        proxy_read_timeout 300s;
    }
}

Turning proxy_buffering off on the streaming route lets tokens reach the browser as they are generated instead of arriving in one block after 40 seconds. Remember to start Gunicorn with --forwarded-allow-ips 127.0.0.1 so FastAPI trusts the forwarded headers and builds correct HTTPS URLs. If you are building on an inference stack, our AI hosting templates ship with Ollama and Open WebUI already installed, and the same Nginx pattern sits in front of them.

Secrets belong outside the code

Database passwords, API keys and JWT signing keys should never live in Git. Put them in /etc/api/api.env, set chown root:root and chmod 600, and let systemd inject them through EnvironmentFile. Inside the app, read them with pydantic-settings:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    jwt_secret: str
    openai_api_key: str | None = None

settings = Settings()

If a required variable is missing, the app refuses to start with a clear validation error rather than crashing later on the first request. Rotate secrets at least every 90 days and keep a short list of where each one is used, because the day you need to rotate in a hurry is not the day to search for them.

Health checks that mean something

Split health into two endpoints. /health answers in under 5 ms and only proves the process is alive. /ready runs SELECT 1 against the database and pings Redis, returning 503 if either fails. Your uptime monitor polls /ready every 30 seconds, while load balancers and deploy scripts use /health so a slow database does not trigger a restart storm. Keep both endpoints out of your access log or they will make up 40% of its lines.

Three mistakes we see on almost every first deploy

  • Running with --reload in production, which spawns a file watcher that burns 3 to 5% of a core and restarts workers whenever a log file changes.
  • Leaving the timeout at Gunicorn's default 30 seconds while an endpoint calls an external API that sometimes takes 45, so workers get killed mid request.
  • Opening port 8000 in the firewall for "quick testing" and forgetting it, which exposes the app without TLS or rate limits.

Real numbers on a 4 vCPU / 8 GB server

Here is what a typical FastAPI service with PostgreSQL and Redis on the same machine looks like on a Business cloud server with 4 vCPU and 8 GB RAM, measured with wrk -t4 -c200 -d60s against an endpoint that reads one row and returns JSON.

WorkersApp RAMRequests/secp95 latency
2310 MBabout 90085 ms
4620 MBabout 1,70048 ms
81.2 GBabout 2,30036 ms
121.8 GBabout 2,35041 ms

The curve flattens after 8 workers because the bottleneck moves to PostgreSQL, and 12 workers only add memory and context switching. With 1.2 GB for the app, around 2 GB for PostgreSQL shared buffers and cache, and 300 MB for Redis, the server still has more than 3 GB free for traffic spikes. Your users in Riyadh see about 8 ms of network latency on top of those numbers. The Business plan costs 11,500 SAR a year including VAT, roughly 31.5 SAR a day, and if you only need the API without a database on the box, the Growth plan with 2 vCPU and 4 GB at 6,900 SAR a year handles 4 workers comfortably.

A deploy checklist you can follow today

  1. Create a dedicated app user and a virtualenv under /srv/api/.venv with pinned requirements.
  2. Write the systemd unit with Gunicorn, Uvicorn workers, --max-requests and a 60 second timeout.
  3. Bind to 127.0.0.1:8000 and put Nginx in front with TLS and a separate block for streaming routes.
  4. Move every secret into a 600 permission environment file and load it with pydantic-settings.
  5. Add /health and /ready, then point your monitor at /ready.
  6. Load test with wrk, find where the curve flattens, and set your worker count there.

Once those six steps are in place, deploys become a git pull, a dependency install and a systemctl reload. Compare the plans on our pricing page and pick the size that matches the numbers you measured, not the one you guessed.

Share