Deploying Python web apps to production: Gunicorn for Django/Flask, Uvicorn for FastAPI, Nginx as reverse proxy, PostgreSQL as database.
TL;DR — Quick Answer
To deploy a Python app to a VPS in production: (1) spin up an Ubuntu/Debian VPS with at least 1 vCPU / 1 GB RAM; (2) install Python 3.12, PostgreSQL, Nginx; (3) create a venv and pip install your dependencies; (4) run Gunicorn for Django/Flask (WSGI) or Uvicorn for FastAPI (ASGI) behind a systemd service; (5) configure Nginx as a reverse proxy to a Unix socket; (6) get SSL with certbot; (7) lock down UFW and run it through Cloudflare. The whole setup takes 15–25 minutes per app and you will be serving real production traffic on your own $8.80/month VPS from LuckVM.
1. Why Deploy Python on a VPS vs PaaS
Platform-as-a-Service options like Heroku, Render, Fly.io, Railway and PythonAnywhere look tempting — push to Git and you are live in minutes. The moment your app grows past "toy project" scale, however, the bill starts climbing: 1 GB RAM dynos are $25+/month, Redis adds more, background workers add more, custom domains and SSL are paywalled, and you cannot tune kernel parameters or install arbitrary system packages.
A $8.80/month VPS from LuckVM gives you a full root Linux machine on fast NVMe SSD, 1 Gbps network, and a public IPv4 + IPv6 address you fully control. You can host multiple Django/Flask/FastAPI apps, Redis, PostgreSQL, Celery workers and a side project or two on the same box, while paying a flat predictable monthly fee. When you hit scale you can upgrade in place to 2/4/8 vCPU plans in 60 seconds without migrating.
.png)
Figure 1 — The production request flow. Nginx terminates SSL and serves static files directly; Gunicorn (WSGI) or Uvicorn (ASGI) runs your Python code; PostgreSQL holds your data.
2. The Production Stack Explained
The development server that ships with Django (runserver) and Flask (app.run()) is single-threaded, not security-audited, and explicitly documented as not for production use. You need three extra layers in front of it:
.png)
Figure 2 — Never use the built-in dev server in production. Choose Gunicorn for WSGI apps (Django/Flask) and Uvicorn for ASGI apps (FastAPI).
- Nginx — Reverse proxy that terminates TLS/HTTPS, serves static files (CSS/JS/images) directly from disk without hitting Python, buffers slow clients, and limits request size.
- Gunicorn (Green Unicorn) — The most widely used WSGI server for Django/Flask. It forks
2×CPU+1worker processes that each handle one sync request at a time. - Uvicorn — An ASGI server for async Python (FastAPI/Starlette). Supports WebSockets, Server-Sent Events and long-lived connections natively. Run it through Gunicorn in production using
uvicorn.workers.UvicornWorker. - PostgreSQL — The production-grade database Django recommends officially. Avoid SQLite for anything beyond local testing (it locks the whole DB on writes).
- systemd — Linux's built-in service manager. Auto-starts Gunicorn on boot, restarts it after crashes, collects logs.
3. Step 1 — Prepare the VPS
Spin up an Ubuntu 22.04/24.04 VPS and SSH in as root. First, create a non-root sudo user (never run your app as root), enable SSH key login, and install system packages.
# Update system packages
apt update && apt upgrade -y
apt install -y python3 python3-pip python3-venv python3-dev build-essential libpq-dev nginx curl ufw git
# Install Python 3.12 from deadsnakes PPA (Ubuntu 22.04)
apt install -y software-properties-common
add-apt-repository -y ppa:deadsnakes/ppa
apt update
apt install -y python3.12 python3.12-venv python3.12-dev
/usr/bin/python3 — always create a virtual environment.4. Step 2 — Install & Configure PostgreSQL
apt install -y postgresql postgresql-contrib
systemctl enable --now postgresql
# Create a database and dedicated user (never use 'postgres' for your app!)
sudo -u postgres psql -c "CREATE USER myappuser WITH PASSWORD 'pick-a-strong-password';"
sudo -u postgres psql -c "CREATE DATABASE myappdb OWNER myappuser;"
sudo -u postgres psql -c "ALTER ROLE myappuser SET client_encoding TO 'utf8';"
sudo -u postgres psql -c "ALTER ROLE myappuser SET default_transaction_isolation TO 'read committed';"
sudo -u postgres psql -c "ALTER ROLE myappuser SET timezone TO 'UTC';"
Store the DB credentials in a .env file at the project root, never commit secrets to Git.
5. Step 3 — Deploy Your App Code
# Create an app directory and system user
useradd -r -s /bin/false myapp
mkdir -p /opt/myapp
cd /opt/myapp
# Clone your repo (or upload with rsync/scp)
git clone https://github.com/you/your-repo.git .
chown -R myapp:myapp /opt/myapp
# Create a virtual environment & install deps
sudo -u myapp python3.12 -m venv venv
sudo -u myapp venv/bin/pip install --upgrade pip
sudo -u myapp venv/bin/pip install -r requirements.txt gunicorn uvicorn psycopg2-binary
# Install production-only extras (example)
sudo -u myapp venv/bin/pip install whitenoise # static file serving for Django
.png)
Figure 3 — Framework-specific steps during deployment. Plan for 15–25 minutes from VPS spin-up to serving traffic.
Environment Variables (.env)
# /opt/myapp/.env (chmod 600, owned by myapp:myapp)
DEBUG=False
SECRET_KEY=generate-a-long-random-string-here
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
DATABASE_URL=postgres://myappuser:your-password@localhost:5432/myappdb
.env to .gitignore. A leaked SECRET_KEY lets attackers forge sessions and reset passwords.6. Step 4 — Gunicorn / Uvicorn & systemd
Gunicorn for Django / Flask (WSGI)
# /opt/myapp/gunicorn.conf.py
import multiprocessing
bind = "unix:/run/gunicorn.sock"
workers = multiprocessing.cpu_count() * 2 + 1
threads = 2
timeout = 30
max_requests = 1000
max_requests_jitter = 50
accesslog = "/var/log/gunicorn/access.log"
errorlog = "/var/log/gunicorn/error.log"
loglevel = "info"
.png)
Figure 5 — Worker count formulas. For 2 vCPU you get 5 Gunicorn workers (WSGI) or 3 Uvicorn workers (ASGI). Each worker is a separate OS process isolated from the others.
Uvicorn for FastAPI (ASGI)
# Run Uvicorn workers through Gunicorn for production process management
gunicorn main:app \
--worker-class uvicorn.workers.UvicornWorker \
--workers $(( $(nproc) + 1 )) \
--bind unix:/run/gunicorn.sock \
--access-logfile /var/log/gunicorn/access.log \
--error-logfile /var/log/gunicorn/error.log
systemd Service File
# /etc/systemd/system/gunicorn.service
[Unit]
Description=Gunicorn daemon serving myapp
After=network.target postgresql.service
[Service]
Type=notify
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/opt/myapp/.env
ExecStart=/opt/myapp/venv/bin/gunicorn -c gunicorn.conf.py myapp.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=5
PrivateTmp=true
Restart=always
[Install]
WantedBy=multi-user.target
mkdir -p /var/log/gunicorn
chown myapp:myapp /var/log/gunicorn
systemctl daemon-reload
systemctl enable --now gunicorn
systemctl status gunicorn # should show active (running)
7. Step 5 — Nginx Reverse Proxy
Create an Nginx server block. Nginx listens on ports 80/443, handles HTTPS, serves static files directly, and forwards dynamic requests to the Gunicorn socket.
# /etc/nginx/sites-available/myapp
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
client_max_body_size 20M;
keepalive_timeout 5;
location /static/ {
alias /opt/myapp/staticfiles/; # for Django: run collectstatic first
expires 30d;
add_header Cache-Control "public, immutable";
}
location /media/ {
alias /opt/myapp/media/;
expires 30d;
}
location / {
proxy_set_header Host $http_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;
proxy_pass http://unix:/run/gunicorn.sock;
}
}
ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx # always run nginx -t before reloading!
proxy_set_header Upgrade $http_upgrade; and proxy_set_header Connection "upgrade"; inside the location / block, otherwise WebSocket connections will drop after 60 seconds.8. Step 6 — SSL with Let's Encrypt (Certbot)
apt install -y certbot python3-certbot-nginx
certbot --nginx -d yourdomain.com -d www.yourdomain.com
# Auto-renewal is set up automatically by certbot. Test it:
certbot renew --dry-run
After this step your site is reachable on https://yourdomain.com with an A+ SSL Labs rating. Nginx config is rewritten to redirect HTTP → HTTPS and enable HSTS.
9. Step 7 — Firewall & Hardening
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP (for certbot renewal & redirect)
ufw allow 443/tcp # HTTPS
ufw enable
ufw status
DEBUG=Falsein Django settingsALLOWED_HOSTScontains your domain, not*- Gunicorn listens on a Unix socket, not on
0.0.0.0:8000(UFW cannot block sockets) - Static files work (check browser dev tools for 404s on /static/)
- PostgreSQL accepts connections only on 127.0.0.1
certbot renew --dry-runsucceeds- Uptime Kuma or an external monitor is pinging the /health endpoint
10. Framework-Specific Tips
Django
- Run
python manage.py collectstaticafter every deploy so Nginx can serve static files - Add
SecurityMiddleware,SECURE_SSL_REDIRECT=True,SESSION_COOKIE_SECURE=True,CSRF_COOKIE_SECURE=Truein settings - Use
django-environorpython-dotenvto load .env - Use Django's built-in
CONN_MAX_AGE=60for DB connection pooling
Flask
- Set
FLASK_ENV=production— never leave debug mode on (it exposes a Python console in the browser on errors) - Use
waitressas an alternative to Gunicorn on Windows, but stick with Gunicorn on Linux VPS - Use
Flask-SQLAlchemywith PostgreSQL connection pooling - Enable
ProxyFixmiddleware sorequest.remote_addrshows real client IPs behind Nginx
FastAPI
- Always use Uvicorn workers through Gunicorn (
uvicorn.workers.UvicornWorker) — standalone uvicorn is for dev - Use
async deffor IO-bound endpoints (DB calls, external HTTP); use regulardeffor CPU-bound work - Configure CORS middleware with specific origins, not
allow_origins=["*"] - Use
HTTPBeareror OAuth2 for auth; never roll your own JWT without reading the RFC
.png)
Figure 4 — VPS sizes matched to typical Python app traffic. A $26.40/mo 2 vCPU / 4 GB VPS handles most production Django/FastAPI apps comfortably.
11. Optional: Deploy with Docker Compose
For teams that prefer containerized deploys, use this docker-compose.yml as a starting point:
# docker-compose.yml
version: "3.9"
services:
web:
build: .
command: gunicorn -c gunicorn.conf.py myapp.wsgi:application
volumes:
- static:/opt/myapp/staticfiles
- media:/opt/myapp/media
environment:
- DATABASE_URL=postgres://myappuser:pass@db:5432/myappdb
depends_on:
- db
- redis
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: myappuser
POSTGRES_PASSWORD: pass
POSTGRES_DB: myappdb
redis:
image: redis:7-alpine
nginx:
image: nginx:1.27-alpine
ports: ["80:80","443:443"]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- static:/var/www/static
depends_on: [web]
volumes:
pgdata: static: media:
With Docker, you still want Nginx + Certbot in front of the containers for SSL termination. For most single-server deployments plain systemd + apt packages are simpler, faster, and easier to debug.
12. Picking the Right VPS Size at LuckVM
Gunicorn workers each take about 80–120 MB RAM depending on your framework. A 1 GB Starter VPS ($8.80/mo) runs about 5 Gunicorn workers plus PostgreSQL comfortably — enough for side projects and small Flask/FastAPI APIs. Move up to a Standard plan (2 vCPU / 4 GB RAM for $26.40) once you start seeing sustained traffic or need Celery + Redis workers alongside Django. For multi-app setups or FastAPI async workloads at scale, our Professional plan (4 vCPU / 8 GB) or Enterprise plan (8 vCPU / 16 GB) gives you headroom for 5,000+ requests per second.
All LuckVM plans include NVMe SSD storage (fast DB performance is IO-bound), a 1 Gbps network port, a native IPv4 address, a /64 IPv6 block, and free 24/7 Chinese + English support. Hong Kong, Los Angeles, Japan, Singapore, and Korea locations are available — pick the closest to your users (see our VPS latency guide).
Ready to deploy?
Spin up a LuckVM VPS from $8.80/month. NVMe SSD, native IPv4, 1 Gbps port, multiple Asia-Pacific and US locations, 24/7 support.
Deploy Your App on LuckVM →13. Frequently Asked Questions
Do I really need Gunicorn? Can't I just use Flask's built-in server?
The Flask dev server handles one request at a time, has no timeout protection, no worker isolation, and is explicitly not security-audited for production. Gunicorn gives you multiple workers, graceful restarts, memory leak recycling via max_requests, and automatic respawning if a worker crashes. It takes 3 minutes to configure.
WSGI vs ASGI — which one am I using?
Django (pre-4.2 async views) and Flask are WSGI — use Gunicorn. FastAPI, Starlette and Django Channels are ASGI — use Uvicorn workers via Gunicorn. When in doubt, Gunicorn with sync workers is the safe default for any Django/Flask app.
How many Gunicorn workers should I use?
The classic formula is workers = 2 × CPU_cores + 1 for WSGI. On a 2 vCPU VPS that gives 5 workers, which is the standard recommendation. For ASGI/Uvicorn use workers = CPU_cores + 1. Add threads=2-4 if your app makes many slow external API calls.
Why use a Unix socket instead of TCP 127.0.0.1:8000?
Unix sockets bypass the TCP/IP stack inside the same machine and are roughly 20-30% faster for local IPC. They also cannot be reached from outside the server even if you accidentally open a port in UFW, which is a small security win.
Static files return 404 after deploy — why?
For Django you need to run python manage.py collectstatic and make sure the STATIC_ROOT setting matches the Nginx alias path. Also check that file permissions let the www-data user read the files (chmod -R 755).
Should I use SQLite for production?
No. SQLite locks the entire database file on every write. Two concurrent writes = database is locked errors. Install PostgreSQL — it takes 3 commands and saves you from hard-to-debug outages later.
Can I run multiple Python apps on one VPS?
Yes. Each app gets its own system user, its own virtual environment, its own Gunicorn systemd service listening on a different Unix socket, and a separate Nginx server block. See our guide to hosting multiple websites on one VPS for the full setup.
How do I see logs when something breaks?
journalctl -u gunicorn -f for real-time Gunicorn logs, /var/log/gunicorn/error.log for Python exceptions, /var/log/nginx/error.log for Nginx errors, and /var/log/postgresql/ for database errors. For production monitoring add Netdata or Prometheus — see our VPS monitoring guide.
Last updated October 2026. All commands verified on Ubuntu 22.04 LTS, Debian 12, Python 3.12, Gunicorn 22, Nginx 1.24, PostgreSQL 16. Prices based on LuckVM.com as of September 2026; check the pricing page for current rates.



