⚡
Hosting

Deploying FastAPI to a Server: Uvicorn, systemd and Nginx

08.11.2026
← All articles

FastAPI is built to be quick to write and quick to run, which is why it is increasingly chosen for mobile app backends, Telegram bots and internal services. Locally, uvicorn main:app --reload starts everything instantly, but that mode is for development: it watches files, runs a single process and does not come back after a reboot.

ASGI and why you need Uvicorn

FastAPI is an ASGI framework, supporting async requests, WebSockets and long-lived connections, so it needs an ASGI server such as Uvicorn. Uvicorn can run several worker processes, each handling many concurrent async requests, while Nginx in front handles TLS, slow clients and request size limits. Consider a Tashkent restaurant that ordered a delivery app: it calls a FastAPI service at api.misol.uz to load the menu, place orders and track the courier over a WebSocket. Lunchtime traffic spikes, so the service needs multiple workers, automatic restarts and a correctly configured proxy.

Preparing the project

sudo apt update
sudo apt install python3-venv nginx
sudo adduser --system --group --home /srv/api api
sudo -u api python3 -m venv /srv/api/venv
sudo -u api /srv/api/venv/bin/pip install -r /srv/api/requirements.txt "uvicorn[standard]"

The standard extra brings a faster event loop and WebSocket libraries. Keep the database password, JWT key and API tokens in /srv/api/.env with chmod 600. Test manually first with uvicorn app.main:app --host 127.0.0.1 --port 8000 and curl http://127.0.0.1:8000/health.

Production app settings

By default FastAPI publishes full API documentation at /docs and /redoc. For a private or commercial API, disable them in production or restrict them by IP, and add a health endpoint for monitoring:

import os
from fastapi import FastAPI

is_prod = os.getenv("APP_ENV") == "production"

app = FastAPI(
    docs_url=None if is_prod else "/docs",
    redoc_url=None if is_prod else "/redoc",
)

@app.get("/health")
def health():
    return {"status": "ok"}

Also avoid blocking calls, such as a synchronous HTTP client or database driver, inside async def endpoints: they stall the whole worker. Use async libraries, or declare the endpoint with plain def so FastAPI runs it in a thread pool.

Uvicorn as a systemd service

[Unit]
Description=FastAPI (Uvicorn) for api.misol.uz
After=network.target

[Service]
User=api
Group=api
WorkingDirectory=/srv/api
EnvironmentFile=/srv/api/.env
ExecStart=/srv/api/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2 --proxy-headers --forwarded-allow-ips=127.0.0.1
Restart=on-failure

[Install]
WantedBy=multi-user.target

Save it as /etc/systemd/system/api.service. Binding to 127.0.0.1 keeps the app reachable only through Nginx, and --proxy-headers with --forwarded-allow-ips makes Uvicorn trust X-Forwarded-For and X-Forwarded-Proto, so the app sees the real client IP and HTTPS. Enable it with sudo systemctl daemon-reload and sudo systemctl enable --now api.

Nginx proxy and WebSockets

WebSockets start as an HTTP upgrade, so Nginx must forward the Upgrade and Connection headers. Declare a helper in /etc/nginx/conf.d/upgrade.conf:

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

Then the site config:

server {
    listen 80;
    server_name api.misol.uz;
    client_max_body_size 10M;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        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 Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 300s;
    }
}

Enable it, run sudo nginx -t, reload, and issue a certificate with sudo certbot --nginx -d api.misol.uz. proxy_read_timeout stops Nginx from dropping idle WebSockets after a minute. The api subdomain's A record must point to the VPS first; you can add it in the DNS section of the sayt.uz cabinet.

Updates and troubleshooting

To release, update the code, install requirements, run migrations if needed and execute sudo systemctl restart api. On a 502, read journalctl -u api: a wrong module path, a missing variable in .env or a busy port are the usual suspects. If every client IP shows as 127.0.0.1, check --proxy-headers.

Related articles

🤖 Running a Python Bot 24/7 with systemd and PM2 🧠 Out of Memory (OOM): Why PHP and Python Processes Get Killed 🧠 "Allowed memory size exhausted": fixing the PHP memory error properly 🚀 Full-page caching: when it helps and when it gets in the way
🌐 Language
🇺🇿 O'zbek 🇺🇿 Ўзбек 🇷🇺 Русский 🇬🇧 English ✓