Deploy a FastAPI app with Gunicorn, Uvicorn workers and systemd

Run a FastAPI app in a virtualenv under Gunicorn with Uvicorn workers, keep it up with a systemd service, and put nginx in front as a reverse proxy.

Tested on Ubuntu 24.04 LTS

Before you start

  • A server running Ubuntu 24.04
  • Root or a user with sudo
  • A domain name pointed at the server (for HTTPS)
  • Your FastAPI app's code

To deploy a FastAPI app, install it in a Python virtual environment, run it with Gunicorn using Uvicorn workers (from the uvicorn-worker package), keep Gunicorn running with a systemd service, and put nginx in front to take the traffic on ports 80 and 443. This tutorial does each step on Ubuntu 24.04 with a small example app, and shows how to reload new code without dropping requests.

Gunicorn is the process manager: it starts several worker processes, restarts any that crash or hang, and reloads them gracefully. Each worker is a Uvicorn server running your async app. nginx handles HTTPS, slow clients and request size limits, and passes requests to Gunicorn on 127.0.0.1.

What In this tutorial
The app's system user api
The app's directory /srv/api, with the code in /srv/api/app
Where Gunicorn listens 127.0.0.1:8000
The domain api.example.com

Install Python and nginx

Ubuntu 24.04 ships Python 3.12. Install the venv module and nginx:

bash
sudo apt update
sudo apt install -y python3-venv python3-pip nginx
python3 --version

Install packages into a virtual environment, never with sudo pip: Ubuntu blocks system-wide pip installs (externally-managed-environment) so they can't break the system's own Python tools.

Create a user and directory for the app

A system user with no login shell runs the app, so a bug in it can't touch the rest of the server:

bash
sudo useradd --system --create-home --home-dir /srv/api --shell /usr/sbin/nologin api

Put your code in /srv/api/app (with git clone, rsync or scp) and give it to the api user:

bash
sudo chown -R api:api /srv/api

If you want a small app to follow along with, create /srv/api/app/main.py:

python
from fastapi import FastAPI

app = FastAPI()


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


@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
    return {"item_id": item_id, "q": q}

Add an empty /srv/api/app/__init__.py next to it, so app is a regular Python package.

Create the virtual environment

Create the venv as the api user and install FastAPI, Gunicorn and the Uvicorn worker:

bash
sudo -u api python3 -m venv /srv/api/.venv
sudo -u api /srv/api/.venv/bin/pip install fastapi gunicorn uvicorn-worker

For your own app, install from its requirements file instead (pip install -r /srv/api/app/requirements.txt) and make sure gunicorn and uvicorn-worker are in it.

Why uvicorn-worker: the worker class that used to ship inside Uvicorn, uvicorn.workers.UvicornWorker, is deprecated. Importing it in Uvicorn 0.54 prints:

text
DeprecationWarning: The `uvicorn.workers` module is deprecated. Please use `uvicorn-worker` package instead.

The replacement is the same worker, maintained separately, under the name uvicorn_worker.UvicornWorker.

Check it worked:

bash
sudo -u api /srv/api/.venv/bin/pip list | grep -iE "fastapi|gunicorn|uvicorn"

On 21 October 2026 that listed fastapi 0.159.1, gunicorn 26.2.0, uvicorn 0.54.0 and uvicorn-worker 0.4.0.

Configure Gunicorn

Settings in a file are easier to read and change than a long command line. Create /srv/api/gunicorn.conf.py:

python
bind = "127.0.0.1:8000"
workers = 2
worker_class = "uvicorn_worker.UvicornWorker"
forwarded_allow_ips = "127.0.0.1"
accesslog = "-"
errorlog = "-"
timeout = 60
  • bind keeps Gunicorn on localhost: only nginx can reach it, so nobody bypasses the proxy.
  • workers: each worker is a separate process with its own copy of the app. Start with one or two per CPU core and watch memory; async apps usually need fewer than sync ones. Two suits a server with 1 or 2 cores.
  • forwarded_allow_ips tells the workers to trust the X-Forwarded-* headers from nginx, so request.client.host is the visitor's address and request.url.scheme says https.
  • accesslog and errorlog set to - send logs to standard output, which systemd's journal collects.

Run it by hand once, from the app's parent directory:

bash
cd /srv/api
sudo -u api /srv/api/.venv/bin/gunicorn app.main:app -c /srv/api/gunicorn.conf.py

app.main:app means "the object app in the module app/main.py". In a second SSH session, check it worked:

bash
curl http://127.0.0.1:8000/items/5?q=test
json
{"item_id":5,"q":"test"}

Stop Gunicorn with Ctrl+C.

Run it as a systemd service

systemd starts Gunicorn at boot, restarts it if it dies and sends it the reload signal. Create /etc/systemd/system/api.service:

ini
[Unit]
Description=FastAPI app (gunicorn)
After=network.target

[Service]
Type=notify
NotifyAccess=main
User=api
Group=api
WorkingDirectory=/srv/api
ExecStart=/srv/api/.venv/bin/gunicorn app.main:app -c /srv/api/gunicorn.conf.py
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=30
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full

[Install]
WantedBy=multi-user.target
  • Type=notify with NotifyAccess=main: Gunicorn tells systemd when it is ready, so systemctl start waits until it really is. This follows Gunicorn's own systemd example.
  • ExecReload sends HUP, which makes Gunicorn start new workers with the new code and stop the old ones gracefully.
  • KillMode=mixed lets Gunicorn shut its workers down itself when the service stops.
  • NoNewPrivileges, PrivateTmp and ProtectSystem=full take away things a web app never needs.

If the app needs secrets (a database URL, an API key), put them in /etc/api.env, readable by root only (sudo chmod 600 /etc/api.env), and add EnvironmentFile=/etc/api.env under [Service]. systemd reads the file before it drops to the api user.

Start the service and enable it at boot:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now api

Check it worked:

bash
systemctl status api
curl http://127.0.0.1:8000/

The status is active (running) with a master and two worker processes, and curl prints {"status":"ok"}. Follow the logs with journalctl -u api -f.

Put nginx in front

Create /etc/nginx/sites-available/api:

nginx
server {
    listen 80;
    listen [::]:80;
    server_name api.example.com;

    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-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
    }
}

client_max_body_size caps uploads (nginx's default is 1 MB); proxy_read_timeout matches Gunicorn's timeout. If your app uses WebSockets, add the Upgrade and Connection headers as in the Node.js tutorial's nginx config.

Enable the site and reload nginx:

bash
sudo ln -s /etc/nginx/sites-available/api /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Check it worked: the API answers through nginx, and FastAPI's interactive docs load:

bash
curl -H "Host: api.example.com" http://127.0.0.1/items/7
curl -s -H "Host: api.example.com" http://127.0.0.1/docs | grep "<title>"

The second command prints <title>FastAPI - Swagger UI</title>. If you don't want the docs public, create the app with FastAPI(docs_url=None, redoc_url=None).

Open the firewall and add HTTPS

If ufw is active, allow web traffic (and keep port 8000 closed):

bash
sudo ufw allow "Nginx Full"

On VPSNine, a server with a panel firewall also needs TCP 80 and 443 allowed. Then follow Serve a site with nginx and HTTPS for the certificate: certbot --nginx -d api.example.com adds HTTPS to the server block above and renews it by itself.

Deploy new code

Update the code, install any new dependencies and reload:

bash
cd /srv/api/app
sudo -u api git pull
sudo -u api /srv/api/.venv/bin/pip install -r requirements.txt
sudo systemctl reload api

reload replaces the workers one set at a time, so requests in flight finish. Use sudo systemctl restart api instead after changing gunicorn.conf.py or upgrading Gunicorn itself.

Troubleshooting

ModuleNotFoundError: No module named 'main' (or 'app'). Gunicorn looks for the module relative to its working directory. With the layout here, run it from /srv/api (the service's WorkingDirectory) and name it app.main:app, not main:app.

Error: class uri 'uvicorn_worker.UvicornWorker' invalid or not found. The uvicorn-worker package isn't installed in the venv the service uses. Install it with /srv/api/.venv/bin/pip install uvicorn-worker.

502 Bad Gateway from nginx. Gunicorn isn't running or listens elsewhere. systemctl status api and journalctl -u api -n 50 show why it stopped; /var/log/nginx/error.log shows connect() failed (111: Connection refused) while connecting to upstream.

Workers keep restarting with WORKER TIMEOUT in the log. A request took longer than timeout seconds, usually a blocking call (a sync database driver, requests, time.sleep) inside an async def endpoint. Make the endpoint a plain def so FastAPI runs it in a thread, or use an async library.

Next steps

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee