GICT knowledge base
How to Deploy a FastAPI App with Gunicorn and Nginx
Published
Running fastapi dev or uvicorn main:app in a terminal is fine while you build an API, but it stops when you close the SSH session and does not come back after a reboot. A production setup needs a process manager that keeps the app running, a server that runs several copies of it, and a web server in front that handles HTTPS.
This setup has four parts. Gunicorn manages the app’s worker processes, restarts any that crash, and replaces them gracefully when you deploy new code. Each worker runs your FastAPI app with Uvicorn, which provides the async event loop FastAPI needs. Nginx receives visitors on ports 80 and 443, terminates HTTPS, and passes requests to Gunicorn on a local port. systemd starts the whole stack at boot.
Before you start
You need:
- An Ubuntu 24.04 server with sudo access. A small app like the one in this guide runs comfortably on 1 GB of RAM; size up for heavier code or more workers. An InterServer VPS with 2 cores and 2 GB RAM is a good starting point.
- A domain or subdomain, such as
api.example.com, with an A record pointing to the server. Replace it everywhere below.
The guide calls the app myapi. If you deploy your own code, keep the same layout and replace main:app with your module and app variable.
Install the packages
Ubuntu 24.04 ships Python 3.12, which current FastAPI supports. Install the virtual environment module, Nginx and Certbot, and open the firewall for SSH and web traffic:
$ sudo apt-get update
$ sudo apt-get install -y python3-venv nginx certbot python3-certbot-nginx
$ sudo ufw allow OpenSSH
$ sudo ufw allow 'Nginx Full'
$ sudo ufw enable
The app’s own port, 8000, stays closed. Gunicorn listens only on 127.0.0.1, so the app is reachable only through Nginx.
Create a user and folders for the app
Run the app as its own system user with no login shell:
$ sudo useradd --system --home-dir /srv/myapi --shell /usr/sbin/nologin myapi
$ sudo mkdir -p /srv/myapi/app /etc/myapi
The layout keeps code, settings and data apart:
/srv/myapi/appholds the code and/srv/myapi/venvthe Python packages. Both stay owned by root, so the app can read them but a bug or attacker in the app cannot rewrite them./etc/myapiholds the Gunicorn configuration and the app’s secrets./var/lib/myapiis created by systemd later and is the one place the app can write files.
Add the application
Create /srv/myapi/app/main.py:
import os
from fastapi import FastAPI, Request
app = FastAPI(title="myapi")
@app.get("/")
async def index():
return {"message": os.environ.get("GREETING", "Hello from FastAPI")}
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/whoami")
async def whoami(request: Request):
return {
"client": request.client.host,
"scheme": request.url.scheme,
"url": str(request.url_for("health")),
}
/health gives monitoring something cheap to check. /whoami shows what the app believes about each request, which is useful later to confirm that Nginx passes the visitor’s address and HTTPS correctly.
Create /srv/myapi/app/requirements.txt with pinned versions, so every server and every redeploy gets the same packages:
fastapi==0.142.2
gunicorn==26.2.0
uvicorn[standard]==0.54.0
uvicorn-worker==0.4.0
uvicorn-worker provides the Uvicorn worker class for Gunicorn. The older uvicorn.workers module that many guides still use is deprecated.
Create the virtual environment
Install the packages into a virtual environment that belongs to this app only:
$ sudo python3 -m venv /srv/myapi/venv
$ sudo /srv/myapi/venv/bin/pip install -r /srv/myapi/app/requirements.txt
A virtual environment separates application packages from Ubuntu-managed Python packages. It does not isolate the interpreter or native libraries from operating-system updates. Record dependencies and test application startup and requests after either type of update.
Configure Gunicorn
Create /etc/myapi/gunicorn.conf.py:
bind = "127.0.0.1:8000"
workers = 2
worker_class = "uvicorn_worker.UvicornWorker"
timeout = 30
graceful_timeout = 30
accesslog = "-"
errorlog = "-"
control_socket = "/run/myapi/gunicorn.ctl"
What the settings do:
bindlistens on the local interface only.workersis the number of app processes. Each worker handles many requests at once through async code, so start with one per CPU core. Every worker holds its own copy of the app in memory.timeoutrestarts a worker that stops responding for 30 seconds, andgraceful_timeoutgives running requests 30 seconds to finish when workers are replaced.accessloganderrorlogset to-send all logs to the systemd journal.control_socketmoves Gunicorn’s management socket into the app’s runtime folder. Without it, Gunicorn 26 tries to create the socket in the app user’s home directory, and logs a permission error because that directory belongs to root.
Check the configuration before you use it:
$ cd /srv/myapi/app
$ sudo -u myapi /srv/myapi/venv/bin/gunicorn --config /etc/myapi/gunicorn.conf.py --check-config main:app
No output means the configuration loads and the app imports correctly. A typo in the module or app name ends with an error such as ModuleNotFoundError: No module named 'app'.
Store settings and secrets
Keep database passwords, API keys and other settings out of the code. Create /etc/myapi/myapi.env:
GREETING=Hello from myapi
Only root needs to read it, because systemd loads the file before it starts the app:
$ sudo chmod 600 /etc/myapi/myapi.env
Run the app with systemd
Create /etc/systemd/system/myapi.service:
[Unit]
Description=myapi FastAPI application
After=network.target
[Service]
Type=notify
NotifyAccess=main
User=myapi
Group=myapi
WorkingDirectory=/srv/myapi/app
EnvironmentFile=/etc/myapi/myapi.env
ExecStart=/srv/myapi/venv/bin/gunicorn --config /etc/myapi/gunicorn.conf.py main:app
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=35
Restart=on-failure
RestartSec=2
RuntimeDirectory=myapi
StateDirectory=myapi
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.target
The important lines:
Type=notifylets Gunicorn tell systemd when it is ready.ExecReloadmapssystemctl reloadto Gunicorn’s graceful reload, used for deployments below.Restart=on-failurebrings the app back if Gunicorn itself exits with an error. Gunicorn already replaces individual workers that crash.RuntimeDirectorycreates/run/myapifor the control socket, andStateDirectorycreates the writable/var/lib/myapi.- The last four lines restrict what the app can touch: no privilege escalation, a private
/tmp, read-only system folders and no access to home directories.
Start the service and enable it at boot:
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now myapi
$ systemctl status myapi
$ curl http://127.0.0.1:8000/health
The status shows active (running), and within a second or two the master process and two workers. The last command prints:
{"status":"ok"}
Put Nginx in front
Create /etc/nginx/sites-available/myapi:
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_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_redirect off;
}
}
The proxy_set_header lines pass on the original host name, the visitor’s IP address and whether the request used HTTPS. Without them, your app sees every request as coming from 127.0.0.1 over plain HTTP. client_max_body_size raises Nginx’s 1 MB upload limit; adjust it to what your API accepts.
Enable the site and reload Nginx:
$ sudo ln -s /etc/nginx/sites-available/myapi /etc/nginx/sites-enabled/myapi
$ sudo nginx -t
$ sudo systemctl reload nginx
Enable HTTPS
Request a Let’s Encrypt certificate. Certbot adds the HTTPS settings to the site and redirects HTTP to HTTPS:
$ sudo certbot --nginx -d api.example.com --redirect
Certbot installs a timer that renews the certificate automatically. Then check what the app sees, from your own computer:
$ curl https://api.example.com/whoami
The reply shows your own public IP address, the https scheme, and an https:// URL:
{"client":"203.0.113.25","scheme":"https","url":"https://api.example.com/health"}
The Uvicorn worker accepts X-Forwarded-For and X-Forwarded-Proto only from 127.0.0.1, that is, from Nginx on the same server. A visitor who sends a fake X-Forwarded-For header still appears with their real address. FastAPI’s interactive documentation is now at https://api.example.com/docs; turn it off with FastAPI(docs_url=None, redoc_url=None) if your API is not meant to be public.
Deploy updates and check availability
To deploy new code, copy it into the app folder, install any new packages, check that the app still loads, and reload. If your code is in ~/myapi on the server, for example from git pull:
$ sudo rsync -a --delete --exclude .git --chown=root:root --chmod=D755,F644 ~/myapi/ /srv/myapi/app/
$ sudo /srv/myapi/venv/bin/pip install -r /srv/myapi/app/requirements.txt
$ cd /srv/myapi/app
$ sudo -u myapi /srv/myapi/venv/bin/gunicorn --config /etc/myapi/gunicorn.conf.py --check-config main:app
$ sudo systemctl reload myapi
Do not skip the check. If the new code cannot be imported, for example because of a syntax error or a missing package, the new workers fail to start and Gunicorn shuts down completely instead of keeping the old ones. The check catches these errors while the old version is still serving.
A reload keeps Gunicorn’s listening socket open, starts new workers with the new code, and then stops the old workers after they finish their current requests. It also applies changes to gunicorn.conf.py, such as a different number of workers. Under very heavy traffic, the occasional request that reaches an old worker exactly as it stops can still fail, so clients should retry idempotent requests.
Two kinds of change need a full restart instead, which briefly refuses connections:
- Changes to
/etc/myapi/myapi.env, because systemd reads the file only when the service starts. - Upgrades of Gunicorn, Uvicorn or
uvicorn-worker, because the Gunicorn master process loads them once and keeps running across reloads. Upgrades of FastAPI and your other packages take effect with a reload.
$ sudo systemctl restart myapi
Read the logs
Gunicorn’s access and error logs, and anything your app prints, go to the journal:
$ sudo journalctl -u myapi -f
Each request appears as a line with the visitor’s address and the status code, such as 203.0.113.25:0 - "GET /health HTTP/1.0" 200. The port shows as 0 and the protocol as HTTP/1.0 because that is what Nginx uses for the hop to Gunicorn. Nginx logs requests that never reached the app, such as 502 errors, in /var/log/nginx/error.log.
To see the current workers, ask Gunicorn through its control socket:
$ sudo /srv/myapi/venv/bin/gunicornc -s /run/myapi/gunicorn.ctl -c "show workers"
It lists each worker’s process ID and its last heartbeat. With the Uvicorn worker, the BOOTED column reads no even for healthy workers; a recent LAST_BEAT is the reliable sign.
Troubleshooting
- The browser shows
502 Bad Gateway: Nginx cannot reach Gunicorn./var/log/nginx/error.logshowsconnect() failed (111: Connection refused) while connecting to upstream. Checksystemctl status myapi, thenjournalctl -u myapi -n 50for the reason. - After a reload, the site returns 502 and the journal shows
Worker failed to boot: the new code fails to import, and systemd keeps restarting the service without success. The traceback just above that line names the error. Fix the code, run the--check-configcommand until it passes, then runsudo systemctl restart myapi. - The service keeps restarting with
ModuleNotFoundError: No module named 'main': Gunicorn cannot import the app.WorkingDirectorymust be the folder that contains the module, andmain:appmust match the file name and the variable that holdsFastAPI(). Connection in use: ('127.0.0.1', 8000): another program already uses port 8000. Find it withsudo ss -ltnp | grep 8000, then stop it or changebindandproxy_passto another port.WORKER TIMEOUTin the journal, and slow requests end in 502: anasync defendpoint is running blocking code, such astime.sleep(), a synchronous database driver or a large file read. That blocks the worker’s event loop, so Gunicorn kills the worker after about 30 seconds. Declare such endpoints with plaindef, which FastAPI runs in a thread pool, or use an async library.- The app sees
127.0.0.1as every client, or buildshttp://links behind HTTPS: theX-Forwarded-FororX-Forwarded-Protoline is missing from the Nginx site. Add it, runsudo nginx -t, and reload Nginx. PermissionErrorwhen the app writes a file: the code folder is read-only for the app user. Write uploads, caches and SQLite databases to/var/lib/myapi.
You now have a FastAPI app that starts at boot, runs several workers behind Nginx with HTTPS, and takes new code with a single reload. For tighter isolation, add more of systemd’s security settings to the service once you know which files and network access your app needs.
Graceful worker replacement reduces disruption but is not a zero-downtime guarantee. Test long requests, worker startup failures and schema compatibility in staging. Keep a recoverable release and verify both the health endpoint and a representative application request after deployment.
Reference: FastAPI deployment workers. Check the documentation for your installed version before applying changes.
Related guides
Learn How to Modify Python Dictionary Values Easily
Learn how to modify Python dictionary values effectively with various methods, including direct assignment, update(), and comprehensions, along with common mistakes to...
PythonPython Set Copy Tutorial: Avoid Common Mistakes and Data Errors
Learn how to copy sets in Python safely and efficiently while avoiding common mistakes. Explore methods like copy(), set(), and deepcopy().
PythonDifferent Methods to Loop Through Sets in Python (Beginner-Friendly Guide)
Learn diverse methods to loop through sets in Python, including practical examples and use cases, ideal for beginners looking to master set operations.
PythonHow to Remove Items from a Set in Python (Without Errors)
Discover effective methods for safely removing items from a Python set. Learn to use remove(), discard(), pop(), clear(), and difference_update() with examples.
