MHOSTMHOST

Python and FastAPI: production deployment

By the end of this guide your FastAPI app will run under Gunicorn with Uvicorn worker processes, start automatically on reboot via systemd, and serve external requests through nginx — ready for real traffic, not just uvicorn main:app --reload on your laptop.

What you'll need

  • An mHost VPS on one of these OS templates: Ubuntu 22.04/24.04, Debian 11/12, AlmaLinux/Rocky Linux 8/9.
  • SSH access as root or a user with sudo.
  • A domain pointing at the server's IP — for nginx's server_name. If you don't have one yet, the server's IP address works too.
  • A FastAPI app of your own (or use the minimal example in this guide, just to verify the whole chain from venv to nginx).
Tip: there's no ready-made recipe specifically for FastAPI in VMmanager 6. The closest one is Django (Django + uWSGI + Nginx, ID 1) — but it's built for Django, not for the FastAPI/Gunicorn/Uvicorn combination. See Ready recipes for VMmanager 6 for the full catalog of one-click installs. Below is the manual setup, which works for any FastAPI application.

Step 1. Set up a user, venv, and dependencies

The app will run not as root but as a dedicated system user with no login shell — this limits the damage if a vulnerability is ever found in the app itself.

Install Python and the virtual environment module:

Ubuntu / Debian

bash
sudo apt update
sudo apt install python3-venv python3-pip

AlmaLinux / Rocky Linux

bash
sudo dnf install python3 python3-pip

Create a dedicated system user for the app. The --create-home flag also creates its home directory, /opt/myapp, where both the code and the venv will live:

bash
sudo useradd --system --create-home --home-dir /opt/myapp --shell /usr/sbin/nologin myapp

Switch to that user (it has no login shell, so start bash as an explicit command) and create the virtual environment:

bash
sudo -u myapp -H bash
cd /opt/myapp
python3 -m venv venv
source venv/bin/activate

Install FastAPI, Uvicorn, and Gunicorn:

bash
pip install --upgrade pip
pip install fastapi "uvicorn[standard]" gunicorn uvicorn-worker
Tip: the uvicorn-worker package isn't optional filler. The uvicorn.workers.UvicornWorker class that used to ship inside uvicorn itself is marked deprecated in Uvicorn's official docs and has been moved into a separate package, uvicorn-worker (class uvicorn_worker.UvicornWorker). That's the one to pass to --worker-class — the old import path may still work on your installed Uvicorn version, but it's no longer something to rely on.

Create a minimal main.py — if you already have a working app, skip this step and copy your own code into /opt/myapp instead:

bash
cat > /opt/myapp/main.py << 'EOF'
from fastapi import FastAPI

app = FastAPI()


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

Check that the app actually starts — directly through Uvicorn, without Gunicorn yet:

bash
uvicorn main:app --host 127.0.0.1 --port 8000 &
sleep 1
curl http://127.0.0.1:8000/
kill %1

This should return {"status":"ok"}.

Step 2. Run Gunicorn with Uvicorn workers

Gunicorn acts as the process manager: it spins up several parallel workers, restarts any that crash, and can upgrade the app with no downtime — but Gunicorn only understands the ASGI protocol that FastAPI speaks through the uvicorn_worker.UvicornWorker worker class.

The worker count is set with the -w/--workers flag. Gunicorn's official recommendation is 2–4 workers per CPU core (run nproc to see how many cores you have). For a small 1-2 core VPS, 3-4 workers is usually enough.

Test the combination right now, while you're still in the myapp user's session with the venv active:

bash
gunicorn main:app \
    --workers 3 \
    --worker-class uvicorn_worker.UvicornWorker \
    --bind 127.0.0.1:8000 &
sleep 1
curl http://127.0.0.1:8000/
kill %1
exit

The exit at the end takes you back to your sudo user's session — everything from here on runs under that account.

Step 3. Set up a systemd unit

Create the unit at /etc/systemd/system/myapp.service:

bash
sudo tee /etc/systemd/system/myapp.service > /dev/null << 'EOF'
[Unit]
Description=Gunicorn (Uvicorn workers) for myapp
After=network.target

[Service]
Type=notify
NotifyAccess=main
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/myapp/venv/bin/gunicorn main:app \
    --workers 3 \
    --worker-class uvicorn_worker.UvicornWorker \
    --bind 127.0.0.1:8000 \
    --access-logfile - \
    --error-logfile - \
    --timeout 60
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=5
Restart=on-failure
RestartSec=5
PrivateTmp=true

[Install]
WantedBy=multi-user.target
EOF
Tip: Type=notify is Gunicorn's officially recommended setting (supported since version 20.0: Gunicorn notifies systemd it's ready via sd_notify). If the unit hangs in activating and then times out after you start it, switch Type=notify to Type=simple — that's a known edge case on some versions.

Enable and start the service:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp

Check the logs (including the request logs Gunicorn writes) through journald:

bash
sudo journalctl -u myapp -f

Step 4. Configure nginx as a reverse proxy

Install nginx:

Ubuntu / Debian

bash
sudo apt install nginx

AlmaLinux / Rocky Linux

bash
sudo dnf install nginx
sudo systemctl enable --now nginx

Create the site config. The file path differs by distro — pick the right CONF= line:

bash
# Ubuntu / Debian:
CONF=/etc/nginx/sites-available/myapp
# AlmaLinux / Rocky Linux:
CONF=/etc/nginx/conf.d/myapp.conf

sudo tee "$CONF" > /dev/null << 'EOF'
upstream myapp {
    server 127.0.0.1:8000 fail_timeout=0;
}

server {
    listen 80;
    server_name example.com;

    client_max_body_size 20m;

    location / {
        proxy_pass http://myapp;
        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;
    }
}
EOF

# Ubuntu / Debian only — enable the site with a symlink:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp

Replace example.com with your own domain (or the server's IP address if you don't have a domain yet). fail_timeout=0 on the upstream block means "don't mark the backend as unavailable after a single failed attempt, keep retrying it" — which makes sense when there's only one Gunicorn process behind the upstream and nothing else to fail over to.

Check the configuration and reload nginx:

bash
sudo nginx -t
sudo systemctl reload nginx

Verify from outside:

bash
curl http://example.com/

This should return {"status":"ok"}.

Tip (AlmaLinux/Rocky Linux): by default SELinux doesn't let nginx open outgoing network connections — the reverse proxy will respond with 502 Bad Gateway instead. Check the httpd_can_network_connect boolean and enable it if needed:
bash
getsebool httpd_can_network_connect
sudo setsebool -P httpd_can_network_connect on
Important: for port 80 to be reachable from outside, you need to open it in the firewall — see step 5 below, or the Setting up a firewall (ufw) article.

Step 5. Open the port in the firewall

Gunicorn itself only listens on 127.0.0.1 and is never reachable from outside — the only port you actually need to open is nginx's.

Important: before enabling the firewall, make sure the SSH port (usually 22/tcp) is definitely allowed — otherwise you can lock yourself out of the server.

Ubuntu / Debian (ufw)

bash
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw enable
sudo ufw status verbose

AlmaLinux / Rocky Linux (firewalld)

bash
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --reload
sudo firewall-cmd --list-all
Tip: on a fresh image SSH is usually already allowed in firewalld by default, but adding the rule again is harmless — it won't error. If you add HTTPS later (for example, Let's Encrypt/certbot), remember to open 443/tcp too (ufw allow 443/tcp or --add-service=https). See the Setting up a firewall (ufw) article for more on managing the firewall.
Done: the app is reachable from outside at http://example.com/ (or by IP), runs under systemd — surviving reboots and individual worker crashes — and sits behind nginx.

What's next

  • The logical next step is to get a TLS certificate and turn on HTTPS (for example, via Let's Encrypt/certbot) on top of the nginx setup you now have — that's a separate topic outside the scope of this guide.
  • Updating the app is now simple: update the code in /opt/myapp (for example, git pull as the myapp user) and restart the service with sudo systemctl restart myapp.
  • For the full catalog of one-click installs in VMmanager 6, see Ready recipes for VMmanager 6; firewall management is covered in more detail in Setting up a firewall (ufw).