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:
sudo apt update
sudo apt install -y python3-venv python3-pip nginx
python3 --versionInstall 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:
sudo useradd --system --create-home --home-dir /srv/api --shell /usr/sbin/nologin apiPut your code in /srv/api/app (with git clone, rsync or scp) and give it to the api user:
sudo chown -R api:api /srv/apiIf you want a small app to follow along with, create /srv/api/app/main.py:
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:
sudo -u api python3 -m venv /srv/api/.venv
sudo -u api /srv/api/.venv/bin/pip install fastapi gunicorn uvicorn-workerFor 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:
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:
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:
bind = "127.0.0.1:8000"
workers = 2
worker_class = "uvicorn_worker.UvicornWorker"
forwarded_allow_ips = "127.0.0.1"
accesslog = "-"
errorlog = "-"
timeout = 60bindkeeps 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_ipstells the workers to trust theX-Forwarded-*headers from nginx, sorequest.client.hostis the visitor's address andrequest.url.schemesayshttps.accessloganderrorlogset to-send logs to standard output, which systemd's journal collects.
Run it by hand once, from the app's parent directory:
cd /srv/api
sudo -u api /srv/api/.venv/bin/gunicorn app.main:app -c /srv/api/gunicorn.conf.pyapp.main:app means "the object app in the module app/main.py". In a second SSH session, check it worked:
curl http://127.0.0.1:8000/items/5?q=test{"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:
[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.targetType=notifywithNotifyAccess=main: Gunicorn tells systemd when it is ready, sosystemctl startwaits until it really is. This follows Gunicorn's own systemd example.ExecReloadsendsHUP, which makes Gunicorn start new workers with the new code and stop the old ones gracefully.KillMode=mixedlets Gunicorn shut its workers down itself when the service stops.NoNewPrivileges,PrivateTmpandProtectSystem=fulltake 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:
sudo systemctl daemon-reload
sudo systemctl enable --now apiCheck it worked:
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:
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:
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 nginxCheck it worked: the API answers through nginx, and FastAPI's interactive docs load:
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):
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:
cd /srv/api/app
sudo -u api git pull
sudo -u api /srv/api/.venv/bin/pip install -r requirements.txt
sudo systemctl reload apireload 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
- Give the app a database: Install PostgreSQL on Ubuntu 24.04, or Redis for caching and background jobs.
- Several apps on one server: Caddy as a reverse proxy for several apps.
- Keep an eye on memory as you add workers: Monitor your server.
- References: FastAPI deployment concepts, Gunicorn settings, uvicorn-worker.