Deploying a Flask PostgreSQL Nginx stack is the standard path for taking a Python web app from your laptop to a live production server. This guide walks through every layer: a PostgreSQL database, Gunicorn as the WSGI application server, systemd to keep the process running, and Nginx as the reverse proxy that faces the internet. By the end you will have a hardened, production-ready setup you can build on.
Prerequisites
- Ubuntu 22.04 or 24.04 server (root or sudo access)
- A domain name pointed at the server’s IP (optional for SSL later)
- Basic familiarity with Linux command line and Python
- A Flask application ready to deploy (or use the minimal example in this guide)
Installing System Dependencies
Start by refreshing the package index and installing everything you need in one pass:
sudo apt update && sudo apt upgrade -y
sudo apt install -y python3 python3-pip python3-venv \
postgresql postgresql-contrib \
nginx curl
Verify the installs:
python3 --version # Python 3.10+
psql --version # psql 14+
nginx -v # nginx/1.22+
Setting Up PostgreSQL
Create a Database and User
Switch to the postgres system account and open the interactive shell:
sudo -u postgres psql
Inside psql, create a dedicated user and database. Never use the default postgres superuser for application connections:
CREATE USER flaskapp WITH PASSWORD 'StrongPassw0rd!';
CREATE DATABASE flaskdb OWNER flaskapp;
GRANT ALL PRIVILEGES ON DATABASE flaskdb TO flaskapp;
\q
Test the Connection
Confirm connectivity before wiring it into Flask:
psql -U flaskapp -h 127.0.0.1 -d flaskdb -c "\conninfo"
You should see confirmation that the connection succeeded. If it fails, check that pg_hba.conf allows md5 or scram-sha-256 auth for local connections:
sudo nano /etc/postgresql/14/main/pg_hba.conf
# Ensure this line exists:
# host all all 127.0.0.1/32 scram-sha-256
sudo systemctl restart postgresql
Deploying the Flask Application
Project Layout
Place your application in /srv/flaskapp. Using /srv keeps web apps separate from system files:
/srv/flaskapp/
├── venv/
├── app/
│ ├── __init__.py
│ └── models.py
├── wsgi.py
├── .env
└── requirements.txt
Create the Virtual Environment
sudo mkdir -p /srv/flaskapp
sudo chown $USER:$USER /srv/flaskapp
cd /srv/flaskapp
python3 -m venv venv
source venv/bin/activate
Install Python Packages
pip install flask flask-sqlalchemy psycopg2-binary gunicorn python-dotenv
pip freeze > requirements.txt
Minimal Flask Application
Create app/__init__.py:
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import os
db = SQLAlchemy()
def create_app():
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ["DATABASE_URL"]
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]
db.init_app(app)
@app.route("/")
def index():
return "Flask + PostgreSQL + Nginx is running."
return app
Create wsgi.py (the entry point Gunicorn calls):
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run()
Environment Variables
Store secrets in /srv/flaskapp/.env. Never commit this file to version control:
DATABASE_URL=postgresql://flaskapp:StrongPassw0rd!@127.0.0.1:5432/flaskdb
SECRET_KEY=replace-with-a-long-random-string
FLASK_ENV=production
Restrict permissions immediately:
chmod 600 /srv/flaskapp/.env
Test Gunicorn Manually
Before setting up systemd, verify Gunicorn can start your app:
source /srv/flaskapp/venv/bin/activate
cd /srv/flaskapp
gunicorn --workers 3 --bind 0.0.0.0:8000 wsgi:app
Visit http://your-server-ip:8000 — you should see the Flask response. If so, stop Gunicorn with Ctrl+C and move to systemd.
Gunicorn Systemd Service
Running Gunicorn under systemd gives you automatic restarts on crash, startup on boot, and centralized logging via journalctl.
Choosing Worker Count
| Server RAM | Recommended Workers |
|---|---|
| 512 MB | 2 |
| 1 GB | 3 |
| 2 GB | 5 |
| 4 GB+ | 9 |
A common formula: (2 × CPU cores) + 1. Three workers is a safe default for most small VPS instances.
Create the Service Unit File
sudo nano /etc/systemd/system/flaskapp.service
Paste the following — adjust paths and user as needed:
[Unit]
Description=Gunicorn instance for Flask app
After=network.target postgresql.service
[Service]
User=www-data
Group=www-data
WorkingDirectory=/srv/flaskapp
EnvironmentFile=/srv/flaskapp/.env
ExecStart=/srv/flaskapp/venv/bin/gunicorn \
--workers 3 \
--bind unix:/run/flaskapp/gunicorn.sock \
--access-logfile /var/log/flaskapp/access.log \
--error-logfile /var/log/flaskapp/error.log \
wsgi:app
RuntimeDirectory=flaskapp
RuntimeDirectoryMode=0755
[Install]
WantedBy=multi-user.target
Key fields explained:
EnvironmentFile— loads your.envsecrets into the process environmentRuntimeDirectory— systemd automatically creates/run/flaskapp/and cleans it on stopAfter=postgresql.service— ensures the database is up before the app startsunix:socket — faster than TCP for local Nginx-to-Gunicorn communication
Create the log directory and adjust ownership:
sudo mkdir -p /var/log/flaskapp
sudo chown www-data:www-data /var/log/flaskapp
sudo chown -R www-data:www-data /srv/flaskapp
Enable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable flaskapp
sudo systemctl start flaskapp
sudo systemctl status flaskapp
You should see Active: active (running). Check the socket was created:
ls -la /run/flaskapp/gunicorn.sock
Nginx Reverse Proxy Configuration
Nginx sits in front of Gunicorn, handles incoming HTTP/HTTPS traffic, and passes application requests to the Unix socket.
Create the Site Configuration
sudo nano /etc/nginx/sites-available/flaskapp
server {
listen 80;
server_name example.com www.example.com;
# Static files served directly by Nginx — no Gunicorn overhead
location /static/ {
alias /srv/flaskapp/app/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}
location / {
proxy_pass http://unix:/run/flaskapp/gunicorn.sock;
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;
# Prevent slow-client attacks from tying up Gunicorn workers
proxy_read_timeout 90;
proxy_connect_timeout 90;
}
}
Enable the site and test the configuration:
sudo ln -s /etc/nginx/sites-available/flaskapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Add SSL with Certbot
For production, always terminate TLS at Nginx. Certbot automates certificate issuance and renewal:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Certbot rewrites your Nginx config with SSL directives and sets up automatic renewal via a systemd timer.
Real-World Scenario
You have a production SaaS application built with Flask that processes customer orders stored in PostgreSQL. Traffic peaks at noon with ~200 concurrent users. Without Nginx buffering, slow mobile clients would hold Gunicorn workers open for 30+ seconds each, causing request timeouts. With the setup in this guide:
- Nginx accepts the connection immediately and buffers the slow client
- It forwards the complete request body to Gunicorn in milliseconds
- Gunicorn processes the request and writes the response back to Nginx
- Nginx streams the response to the slow client while Gunicorn is already free for the next request
Your three Gunicorn workers can now serve far more than three concurrent users. Add more workers or switch to gevent async workers when you need further scale.
Gotchas and Edge Cases
Unix socket permissions: The Nginx www-data user must be able to read the Gunicorn socket. Running both under www-data (as shown above) is the simplest fix. If they run as different users, add www-data to the app group and set the socket to 660.
DATABASE_URL with special characters: Passwords containing @, /, or % must be percent-encoded in the URL. Alternatively, use individual environment variables (PGUSER, PGPASSWORD, etc.) and build the URL in Python.
Missing python-dotenv in production: EnvironmentFile in systemd reads the .env directly — you do not need python-dotenv for the systemd path. You do need it if you run flask run locally.
App factory pattern vs. module-level app: Gunicorn’s wsgi:app expects a callable. With the factory pattern, expose the created app at module level in wsgi.py as shown above. Do not call create_app() inside a function without exposing the result.
PostgreSQL max_connections: Each Gunicorn worker opens its own database connection. With 9 workers on 4 servers you could hit the default 100-connection limit. Use PgBouncer as a connection pooler in front of PostgreSQL for high-worker-count deployments.
Troubleshooting
502 Bad Gateway from Nginx:
Check the Gunicorn service is running and the socket exists:
sudo systemctl status flaskapp
ls /run/flaskapp/gunicorn.sock
Also check Nginx error logs: sudo tail -50 /var/log/nginx/error.log
[CRITICAL] WORKER TIMEOUT in Gunicorn logs:
A request is taking longer than the default 30-second timeout. Increase with --timeout 120 or optimize the slow query triggering it.
OperationalError: could not connect to server:
PostgreSQL is not running or the DATABASE_URL is wrong. Test the connection string directly with psql $DATABASE_URL.
Permission denied on the socket:
User mismatch between Nginx and Gunicorn. Verify both run as www-data:
ps aux | grep -E "gunicorn|nginx" | awk '{print $1, $11}'
Static files returning 404:
The alias path in Nginx must match your actual static directory. Note: alias requires a trailing slash; root does not.
Summary
- Install PostgreSQL, create a dedicated user and database, and restrict
.envfile permissions to keep credentials safe - Run Flask under Gunicorn with a Unix socket — never expose Gunicorn directly to the internet
- Use a systemd
[Service]unit withEnvironmentFileandRuntimeDirectoryfor clean process management and automatic restart - Configure Nginx to proxy
/to the Gunicorn socket and serve/static/directly to eliminate unnecessary application server load - Add SSL via Certbot immediately — plain HTTP is never acceptable in production
- Watch for connection-pool exhaustion with PostgreSQL when scaling Gunicorn workers; use PgBouncer if needed