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.
Process Flow
Quick Fix / Quick Setup
# 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://localhostThis 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
80and443and 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 exampleapp:app. - For FastAPI, Gunicorn should usually run Uvicorn workers, for example:
gunicorn -w 3 -k uvicorn.workers.UvicornWorker main:appsystemdmanages 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.
Architecture Overview
Step-by-step implementation
1. Prepare the VPS
Install base packages:
sudo apt update
sudo apt install -y nginx python3-venv python3-pip gitOptional 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
sudo mkdir -p /srv/myapp
sudo chown -R $USER:$USER /srv/myapp
cd /srv/myappUse 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
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pipFor Flask:
pip install flask gunicornFor FastAPI:
pip install fastapi uvicorn gunicornIf you use a requirements file:
pip install -r requirements.txt4. Confirm the app entry point
Typical Flask example:
# app.py
from flask import Flask
app = Flask(__name__)
@app.get("/")
def index():
return {"ok": True}Gunicorn target:
gunicorn app:appTypical FastAPI example:
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def index():
return {"ok": True}Gunicorn target:
gunicorn -k uvicorn.workers.UvicornWorker main:appTest locally before using systemd:
# 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:appThen verify:
curl -I http://127.0.0.1:80005. Create the systemd service
Flask example:
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
UNITFastAPI example:
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
UNITIf your app needs environment variables, add them directly:
Environment="APP_ENV=production"
Environment="SECRET_KEY=replace-me"
Environment="DATABASE_URL=postgresql://user:pass@host/db"Or use an environment file:
EnvironmentFile=/etc/myapp.envExample env file:
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.env6. Fix ownership and start the service
sudo chown -R www-data:www-data /srv/myapp
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp --no-pager -lCheck logs:
sudo journalctl -u myapp -n 100 --no-pagerConfirm Gunicorn is listening:
ss -ltnp | grep 8000
curl -I http://127.0.0.1:80007. Create the Nginx server block
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;
}
}
NGINXEnable it:
sudo ln -sf /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
sudo nginx -t
sudo systemctl reload nginxTest:
curl -I http://localhost
curl -H 'Host: yourdomain.com' http://127.0.0.18. Static files and media
If your app has built static assets, serve them with Nginx.
Example:
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_sizeif uploads fail with413 Request Entity Too Large
9. Open the right ports only
If using UFW:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw statusDo 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:
| Component | Path / target | Primary command | Validation check |
|---|---|---|---|
| Gunicorn service | /etc/systemd/system/myapp.service | sudo systemctl enable --now myapp | sudo systemctl status myapp --no-pager -l is active (running) |
| Gunicorn listener | 127.0.0.1:8000 (or Unix socket) | `ss -ltnp | grep 8000` |
| Nginx site config | /etc/nginx/sites-available/myapp | sudo nginx -t && sudo systemctl reload nginx | curl -I http://localhost returns app response via proxy |
| Nginx enabled symlink | /etc/nginx/sites-enabled/myapp | sudo ln -sf /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp | nginx -t passes and no duplicate server block conflicts |
| Environment variables | Environment= or EnvironmentFile=/etc/myapp.env | sudo systemctl daemon-reload && sudo systemctl restart myapp | app boots without missing-env errors in journalctl -u myapp |
| TLS + domain routing | server_name yourdomain.com + ACME cert files | sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com | curl -I https://yourdomain.com returns valid certificate and HTTPS response |
Common causes
- Gunicorn service fails because
ExecStartpoints to the wrong module or virtualenv binary. - Nginx returns
502because Gunicorn is not running or the upstream port/socket is wrong. systemdstarts without required environment variables.- File permissions prevent
www-dataor 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/443or exposes8000. - 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
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/myappIf Gunicorn does not start:
- verify
WorkingDirectory - verify the virtualenv path
- verify the module target such as
app:appormain:app - verify environment variables exist in
systemd, not only in your shell
Nginx
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.1If 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
sudo ufw status
dig +short yourdomain.comIf 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
sudo systemctl daemon-reload && sudo systemctl restart myapp && sudo systemctl reload nginxtroubleshooting 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.1or a Unix socket, not a public interface. - ✓ The app entry point is correct.
- ✓
WorkingDirectoryand virtualenv paths are correct. - ✓ Environment variables are loaded via
Environment=orEnvironmentFile=. - ✓ Nginx server block is enabled.
- ✓
nginx -tpasses before reload. - ✓ Static and media handling are defined.
- ✓ Firewall allows only required ports.
- ✓ Port
8000is not public. - ✓ HTTP works locally and through Nginx.
- ✓ HTTPS is added before launch.
- ✓ Logs are readable in
journalctland Nginx logs. - ✓ Health check returns
200.
Full launch validation:
Related guides
- Environment Setup on VPS
- Logging Setup (Application + Server)
- 502 Bad Gateway Fix Guide
- SaaS Production Checklist
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:
gunicorn -w 3 -k uvicorn.workers.UvicornWorker main:appShould 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
systemdmanage 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:
- app process
- Gunicorn listener
- Nginx proxy
- domain and DNS
Start with a boring single-server deploy. Add HTTPS, monitoring, and deployment automation after the base path is stable.