Deploy SaaS with Nginx + Gunicorn

The essential playbook for implementing deploy saas with nginx + gunicorn in your SaaS.

Intro

Use Nginx + Gunicorn when you want a simple production stack for a Flask or FastAPI app on a single VPS.

Nginx handles public HTTP traffic, TLS termination, buffering, static files, and reverse proxying. Gunicorn runs your Python app behind it. systemd keeps the app running across crashes and reboots.

This setup is a good default for MVPs and small SaaS products that need predictable deployment without adding container orchestration.

Client
Nginx
Gunicorn
App
Database/Redis

Process Flow


Quick Fix / Quick Setup

bash
# Ubuntu example
sudo apt update && sudo apt install -y nginx python3-venv python3-pip

# app layout
sudo mkdir -p /srv/myapp
sudo chown -R $USER:$USER /srv/myapp
cd /srv/myapp
python3 -m venv .venv
source .venv/bin/activate
pip install gunicorn flask

# minimal Flask app
cat > app.py <<'PY'
from flask import Flask
app = Flask(__name__)

@app.route('/')
def health():
    return {'status': 'ok'}
PY

# gunicorn systemd service
sudo tee /etc/systemd/system/myapp.service > /dev/null <<'UNIT'
[Unit]
Description=Gunicorn for myapp
After=network.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/srv/myapp
Environment="PATH=/srv/myapp/.venv/bin"
ExecStart=/srv/myapp/.venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
UNIT

sudo chown -R www-data:www-data /srv/myapp
sudo systemctl daemon-reload
sudo systemctl enable --now myapp

# nginx site
sudo tee /etc/nginx/sites-available/myapp > /dev/null <<'NGINX'
server {
    listen 80;
    server_name _;

    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;
    }
}
NGINX

sudo ln -sf /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
sudo nginx -t && sudo systemctl reload nginx

# verify
curl -I http://127.0.0.1:8000
curl -I http://localhost

This is the shortest working path for a single-server deploy. Replace the demo app with your actual WSGI or ASGI entry point, then add HTTPS, environment variables, static/media handling, and domain config before launch.

Related setup pages:


What’s happening

  • Nginx listens on public ports 80 and 443 and forwards requests to Gunicorn on a private local port or Unix socket.
  • Gunicorn runs multiple worker processes for your app.
  • For Flask, the Gunicorn target is usually module:app, for example app:app.
  • For FastAPI, Gunicorn should usually run Uvicorn workers, for example:
bash
gunicorn -w 3 -k uvicorn.workers.UvicornWorker main:app
  • systemd manages startup, restart, and service lifecycle.
  • This pattern keeps your app server off the public internet and gives Nginx a clean place for TLS, compression, buffering, and static files.
Client
Nginx
App
Database

Architecture Overview


Step-by-step implementation

1. Prepare the VPS

Install base packages:

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

Optional but recommended:

  • create a non-root deploy user
  • configure SSH keys
  • enable a firewall
  • disable password SSH login

If you have not done server prep yet, use Environment Setup on VPS.

2. Create an app directory

bash
sudo mkdir -p /srv/myapp
sudo chown -R $USER:$USER /srv/myapp
cd /srv/myapp

Use a stable path. Do not deploy into a random home directory if you want predictable service config.

3. Create the virtual environment and install dependencies

bash
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip

For Flask:

bash
pip install flask gunicorn

For FastAPI:

bash
pip install fastapi uvicorn gunicorn

If you use a requirements file:

bash
pip install -r requirements.txt

4. Confirm the app entry point

Typical Flask example:

python
# app.py
from flask import Flask
app = Flask(__name__)

@app.get("/")
def index():
    return {"ok": True}

Gunicorn target:

bash
gunicorn app:app

Typical FastAPI example:

python
# main.py
from fastapi import FastAPI
app = FastAPI()

@app.get("/")
def index():
    return {"ok": True}

Gunicorn target:

bash
gunicorn -k uvicorn.workers.UvicornWorker main:app

Test locally before using systemd:

bash
# Flask
gunicorn -w 3 -b 127.0.0.1:8000 app:app

# FastAPI
gunicorn -w 3 -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 main:app

Then verify:

bash
curl -I http://127.0.0.1:8000

5. Create the systemd service

Flask example:

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

[Service]
User=www-data
Group=www-data
WorkingDirectory=/srv/myapp
Environment="PATH=/srv/myapp/.venv/bin"
ExecStart=/srv/myapp/.venv/bin/gunicorn \
  -w 3 \
  -b 127.0.0.1:8000 \
  --access-logfile - \
  --error-logfile - \
  app:app
Restart=always
RestartSec=5
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target
UNIT

FastAPI example:

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

[Service]
User=www-data
Group=www-data
WorkingDirectory=/srv/myapp
Environment="PATH=/srv/myapp/.venv/bin"
ExecStart=/srv/myapp/.venv/bin/gunicorn \
  -w 3 \
  -k uvicorn.workers.UvicornWorker \
  -b 127.0.0.1:8000 \
  --access-logfile - \
  --error-logfile - \
  main:app
Restart=always
RestartSec=5
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target
UNIT

If your app needs environment variables, add them directly:

ini
Environment="APP_ENV=production"
Environment="SECRET_KEY=replace-me"
Environment="DATABASE_URL=postgresql://user:pass@host/db"

Or use an environment file:

ini
EnvironmentFile=/etc/myapp.env

Example env file:

bash
sudo tee /etc/myapp.env > /dev/null <<'ENV'
APP_ENV=production
SECRET_KEY=replace-me
DATABASE_URL=postgresql://user:pass@host/db
ENV
sudo chmod 600 /etc/myapp.env

6. Fix ownership and start the service

bash
sudo chown -R www-data:www-data /srv/myapp
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp --no-pager -l

Check logs:

bash
sudo journalctl -u myapp -n 100 --no-pager

Confirm Gunicorn is listening:

bash
ss -ltnp | grep 8000
curl -I http://127.0.0.1:8000

7. Create the Nginx server block

bash
sudo tee /etc/nginx/sites-available/myapp > /dev/null <<'NGINX'
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.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_redirect off;
    }
}
NGINX

Enable it:

bash
sudo ln -sf /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
sudo nginx -t
sudo systemctl reload nginx

Test:

bash
curl -I http://localhost
curl -H 'Host: yourdomain.com' http://127.0.0.1

8. Static files and media

If your app has built static assets, serve them with Nginx.

Example:

nginx
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    client_max_body_size 10M;

    location /static/ {
        alias /srv/myapp/static/;
        expires 7d;
        add_header Cache-Control "public, max-age=604800";
    }

    location /media/ {
        alias /srv/myapp/media/;
    }

    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;
    }
}

Notes:

  • use Nginx for local static assets
  • prefer object storage for user uploads if possible
  • increase client_max_body_size if uploads fail with 413 Request Entity Too Large

9. Open the right ports only

If using UFW:

bash
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status

Do not expose port 8000 publicly. Keep Gunicorn bound to 127.0.0.1 or a Unix socket.

10. Add HTTPS

After HTTP works, add TLS and force HTTPS. Use a separate guide for that step:

If you use Certbot or another ACME client, validate plain HTTP first before attempting certificate issuance.

Use this deployment matrix to confirm each layer is configured correctly:

ComponentPath / targetPrimary commandValidation check
Gunicorn service/etc/systemd/system/myapp.servicesudo systemctl enable --now myappsudo systemctl status myapp --no-pager -l is active (running)
Gunicorn listener127.0.0.1:8000 (or Unix socket)`ss -ltnpgrep 8000`
Nginx site config/etc/nginx/sites-available/myappsudo nginx -t && sudo systemctl reload nginxcurl -I http://localhost returns app response via proxy
Nginx enabled symlink/etc/nginx/sites-enabled/myappsudo ln -sf /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myappnginx -t passes and no duplicate server block conflicts
Environment variablesEnvironment= or EnvironmentFile=/etc/myapp.envsudo systemctl daemon-reload && sudo systemctl restart myappapp boots without missing-env errors in journalctl -u myapp
TLS + domain routingserver_name yourdomain.com + ACME cert filessudo certbot --nginx -d yourdomain.com -d www.yourdomain.comcurl -I https://yourdomain.com returns valid certificate and HTTPS response

Common causes

  • Gunicorn service fails because ExecStart points to the wrong module or virtualenv binary.
  • Nginx returns 502 because Gunicorn is not running or the upstream port/socket is wrong.
  • systemd starts without required environment variables.
  • File permissions prevent www-data or the configured service user from reading the app or writing runtime files.
  • Static assets are not exposed through Nginx.
  • Firewall or cloud security group blocks 80/443 or exposes 8000.
  • FastAPI is started without Uvicorn workers under Gunicorn.
  • The app is still using development settings in production.

For upstream errors, use:


Debugging tips

Test each layer independently.

App and Gunicorn

bash
sudo systemctl status myapp --no-pager -l
sudo journalctl -u myapp -n 200 --no-pager
ps aux | grep gunicorn
ss -ltnp | grep 8000
curl -I http://127.0.0.1:8000
ls -la /srv/myapp

If Gunicorn does not start:

  • verify WorkingDirectory
  • verify the virtualenv path
  • verify the module target such as app:app or main:app
  • verify environment variables exist in systemd, not only in your shell

Nginx

bash
sudo nginx -t
sudo systemctl status nginx --no-pager -l
sudo tail -n 100 /var/log/nginx/error.log
sudo tail -n 100 /var/log/nginx/access.log
curl -I http://localhost
curl -H 'Host: yourdomain.com' http://127.0.0.1

If Nginx returns 502:

  • Gunicorn may be down
  • the upstream address may be wrong
  • permissions may block a Unix socket
  • Nginx may be pointing to an old port

Network and DNS

bash
sudo ufw status
dig +short yourdomain.com

If 127.0.0.1:8000 works but the domain does not, check:

  • DNS records
  • Nginx server_name
  • firewall rules
  • cloud provider security group

Restart both layers cleanly

bash
sudo systemctl daemon-reload && sudo systemctl restart myapp && sudo systemctl reload nginx
Start
Process
End

troubleshooting flowchart for 502, 404, 403, timeout.

For logging setup, use:

For 502 troubleshooting:


Checklist

  • Gunicorn runs under systemd.
  • Gunicorn restarts on reboot.
  • Gunicorn is bound to 127.0.0.1 or a Unix socket, not a public interface.
  • The app entry point is correct.
  • WorkingDirectory and virtualenv paths are correct.
  • Environment variables are loaded via Environment= or EnvironmentFile=.
  • Nginx server block is enabled.
  • nginx -t passes before reload.
  • Static and media handling are defined.
  • Firewall allows only required ports.
  • Port 8000 is not public.
  • HTTP works locally and through Nginx.
  • HTTPS is added before launch.
  • Logs are readable in journalctl and Nginx logs.
  • Health check returns 200.

Full launch validation:



Related guides


FAQ

What is the minimum setup for deploying Flask with Nginx and Gunicorn?

A VPS with Python and Nginx installed, a virtualenv with gunicorn, a systemd service that runs Gunicorn against your Flask entry point, and an Nginx server block that proxies requests to Gunicorn on localhost.

How is FastAPI different in this setup?

Nginx is mostly unchanged. The difference is the Gunicorn worker class:

bash
gunicorn -w 3 -k uvicorn.workers.UvicornWorker main:app

Should Gunicorn listen on 0.0.0.0?

Usually no. Bind Gunicorn to 127.0.0.1 or a Unix socket and let Nginx be the only public-facing service.

Why does it work in my shell but fail under systemd?

systemd does not inherit your shell session. Missing PATH, WorkingDirectory, or env variables are the most common causes.

Port or Unix socket?

Start with a localhost TCP port like 127.0.0.1:8000. It is easier to debug. Move to a Unix socket later if needed.

How many Gunicorn workers should I use?

Start with 2 to 4 workers on a small VPS. Increase only after checking CPU, memory, and request latency.

Can Nginx serve static files directly?

Yes. That is the normal production setup for local static assets.

Do I need Docker for this stack?

No. Nginx + Gunicorn + systemd is a valid production path for small SaaS apps.

What should I configure right after this page?

Next priorities:

  • HTTPS
  • logging and error tracking
  • static/media handling
  • documented deploy and restart procedure

Use:


Final takeaway

The core pattern is simple:

  • keep Gunicorn private
  • let systemd manage it
  • let Nginx face the internet

Most deployment failures come from wrong paths, missing env vars, bad permissions, incorrect upstream config, or missing proxy headers.

Validate each layer independently:

  1. app process
  2. Gunicorn listener
  3. Nginx proxy
  4. domain and DNS

Start with a boring single-server deploy. Add HTTPS, monitoring, and deployment automation after the base path is stable.