Flask’s built-in development server prints a friendly warning every time you start it: “This is a development server. Do not use it in a production deployment.” A lot of people ignore that warning until something breaks under real traffic. I’ve been there. The fix is a well-worn, reliable combination: uWSGI to run your Flask app as a proper application server, and Nginx in front of it to handle the internet-facing side of things.
This guide walks through the whole stack, from a bare server to a Flask app running behind Nginx and uWSGI with a proper systemd service, SSL, and production-grade configuration.
Why Flask Needs Help in Production
Flask is a WSGI application — it implements the interface, but it doesn’t include a production-grade server to run that interface at scale. Flask’s dev server is single-threaded by default, doesn’t handle concurrent connections well, and has no process management (no automatic restarts on crash, no worker pooling). uWSGI solves all of that: it’s a mature, battle-tested application server that speaks WSGI natively, manages multiple worker processes, and integrates tightly with Nginx via its own efficient binary protocol.
Requirements
- Ubuntu 22.04/24.04 server with sudo access
- Python 3.8+ installed
- A Flask application, structured with an application factory or a simple
app = Flask(__name__)object exposed at module level pipandvenv
I’ll assume your project lives at /var/www/myflaskapp with the main Flask object accessible as app inside wsgi.py.
Step 1: Install System Packages
sudo apt update
sudo apt install python3-pip python3-venv nginx -y
Step 2: Set Up a Virtual Environment and Install Dependencies
cd /var/www/myflaskapp
python3 -m venv venv
source venv/bin/activate
pip install flask uwsgi
If your app has a requirements.txt, install from that instead:
pip install -r requirements.txt
pip install uwsgi
Step 3: Create the WSGI Entry Point
Create wsgi.py in your project root if it doesn’t already exist:
# wsgi.py
from myapp import create_app
app = create_app()
if __name__ == "__main__":
app.run()
If your app is a simple single-file Flask app rather than a factory pattern, this can be as simple as:
# wsgi.py
from app import app
if __name__ == "__main__":
app.run()
This file is the bridge uWSGI uses to find your actual Flask app object.
Step 4: Test uWSGI Directly
Before wiring in Nginx, confirm uWSGI can serve the app on its own:
uwsgi --socket 0.0.0.0:8000 --protocol=http -w wsgi:app
Visit http://your-server-ip:8000 in a browser. If your Flask app responds, uWSGI is correctly finding and running it. Stop it with Ctrl+C once confirmed — we don’t want it bound to a public port long-term.
Step 5: Create a uWSGI Configuration File
Rather than passing flags on the command line, use an .ini file for anything beyond a quick test:
nano /var/www/myflaskapp/myapp.ini
[uwsgi]
module = wsgi:app
master = true
processes = 4
threads = 2
socket = /var/www/myflaskapp/myapp.sock
chmod-socket = 660
vacuum = true
die-on-term = true
harakiri = 30
max-requests = 5000
buffer-size = 32768
A quick explanation of the less obvious settings:
master = trueenables the uWSGI master process, which manages worker processes and lets you reload gracefully.processes/threadscontrol concurrency. A common starting formula is(2 x CPU cores) + 1processes, adjusted based on whether your app is more I/O-bound (favor threads) or CPU-bound (favor processes).socket— this uses a Unix socket rather than a TCP port, similar to the Ruby/Puma setup; Nginx talks to uWSGI over this socket using the native uwsgi protocol, which is more efficient than proxying HTTP.chmod-socket = 660andvacuum = truecontrol socket permissions and ensure the socket file is cleaned up when uWSGI stops.harakiri = 30kills any worker that takes longer than 30 seconds to respond — a safety net against hung requests.max-requests = 5000recycles each worker after 5000 requests, which helps guard against slow memory leaks in long-running Python processes.
Step 6: Run uWSGI as a systemd Service
sudo nano /etc/systemd/system/myflaskapp.service
[Unit]
Description=uWSGI instance for myflaskapp
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/myflaskapp
Environment="PATH=/var/www/myflaskapp/venv/bin"
ExecStart=/var/www/myflaskapp/venv/bin/uwsgi --ini myapp.ini
Restart=always
KillSignal=SIGQUIT
Type=notify
NotifyAccess=all
[Install]
WantedBy=multi-user.target
Enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable myflaskapp
sudo systemctl start myflaskapp
sudo systemctl status myflaskapp
Check the socket file was created:
ls -la /var/www/myflaskapp/myapp.sock
Step 7: Configure Nginx
sudo nano /etc/nginx/sites-available/myflaskapp
server {
listen 80;
server_name example.com www.example.com;
client_max_body_size 10M;
location /static/ {
alias /var/www/myflaskapp/static/;
expires 30d;
add_header Cache-Control "public";
}
location / {
include uwsgi_params;
uwsgi_pass unix:/var/www/myflaskapp/myapp.sock;
uwsgi_read_timeout 60s;
uwsgi_send_timeout 60s;
}
error_page 502 503 /maintenance.html;
location = /maintenance.html {
root /var/www/myflaskapp/static;
}
}
Notice uwsgi_pass instead of proxy_pass — that’s the key difference from a typical HTTP reverse proxy setup. Nginx has a dedicated module for speaking the uwsgi binary protocol directly to the socket, and include uwsgi_params; pulls in a standard set of header mappings (Nginx ships this file at /etc/nginx/uwsgi_params by default) so uWSGI receives proper REMOTE_ADDR, HTTP_HOST, etc.
Enable the site:
sudo ln -s /etc/nginx/sites-available/myflaskapp /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
Step 8: Fix the www-data Permission Chain
A common snag: uWSGI runs as www-data and creates the socket owned by www-data, and Nginx’s worker processes also run as www-data by default (check /etc/nginx/nginx.conf for the user directive), so this usually just works. If you changed the systemd User= to something else (like deploy), make sure Nginx can still read/write the socket — either match the users, or add www-data to the app user’s group.
Step 9: Add HTTPS
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d example.com -d www.example.com
Certbot updates your server block automatically with the SSL certificate paths and an HTTP-to-HTTPS redirect.
Testing Your Setup
curl -I https://example.com
Expect a 200 (or your app’s actual root response). Test a dynamic route that hits your database or does real work, not just the homepage, to make sure the full request path — Nginx → socket → uWSGI → Flask → your logic — is functioning.
You can also watch uWSGI’s own logs live while testing:
sudo journalctl -u myflaskapp -f
And Nginx’s error log in another terminal:
sudo tail -f /var/log/nginx/error.log
Troubleshooting Common Issues
502 Bad Gateway — Check that the uWSGI service is actually running and the socket file exists with correct permissions. Also confirm the socket path in your Nginx config exactly matches the one in myapp.ini.
“ModuleNotFoundError” in uWSGI logs — Usually means uWSGI isn’t using your virtualenv’s Python. Add this to myapp.ini if needed:
virtualenv = /var/www/myflaskapp/venv
Static files 404 — Double check the alias path in the /static/ location block matches your actual static folder, and that file permissions allow the Nginx user to read them.
Long-running requests time out — Adjust both harakiri in myapp.ini and uwsgi_read_timeout in Nginx; they need to agree, or one will kill the request before the other expects it to end.
App changes not reflected after deploy — uWSGI’s master process needs a reload, not just a restart of Nginx:
sudo systemctl restart myflaskapp
Or, for zero-downtime reloads once you’re comfortable, use uWSGI’s touch-reload feature:
touch-reload = /var/www/myflaskapp/wsgi.py
Then a simple touch wsgi.py after a deploy triggers a graceful worker reload.
Security Considerations
- Never bind uWSGI’s socket to a public TCP port in production. Unix sockets, scoped to the local filesystem, are the safer default.
- Set
server_tokens off;innginx.confto avoid leaking version info. - Add standard security headers:
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- Use Flask’s
SECRET_KEYand session cookie settings correctly —SESSION_COOKIE_SECURE = TrueandSESSION_COOKIE_HTTPONLY = Truein your Flask config, so cookies aren’t sent over plain HTTP or exposed to JavaScript. - Rate limit sensitive endpoints (login, password reset, API) at the Nginx layer:
limit_req_zone $binary_remote_addr zone=flasklogin:10m rate=5r/m;
location /login {
limit_req zone=flasklogin burst=5 nodelay;
include uwsgi_params;
uwsgi_pass unix:/var/www/myflaskapp/myapp.sock;
}
- Never run Flask with
debug=Truein production. It exposes an interactive debugger that allows arbitrary code execution if reached by an attacker. Confirm this is off in your app config, independent of anything Nginx does.
Performance Tips
- Tune
processesandthreadsbased on real load testing, not guesswork. Use a tool likewrkorlocustto simulate traffic and watch CPU/memory while adjusting. - Enable gzip for JSON/text responses:
gzip on;
gzip_types text/plain application/json application/javascript text/css;
gzip_min_length 512;
- Cache static assets aggressively with long
expiresheaders, and use cache-busting filenames (hash-based) if your build process supports it. - Consider
uwsgi_cachefor GET-heavy endpoints that don’t change often — Nginx can cache uWSGI responses directly, skipping Python entirely for repeat requests within a TTL window. - Watch memory per worker. Flask apps with large in-memory caches or ML models loaded at startup can balloon memory usage; multiply per-worker RAM by
processesto sanity check total server memory needs.
Real-World Use Cases
- An internal analytics dashboard built in Flask, serving a small team — uWSGI with 2 processes was more than enough, fronted by Nginx purely for TLS termination and basic auth at the proxy layer.
- A public-facing REST API for a mobile app, where Nginx handled rate limiting per API key (via a custom header check) before requests ever reached the Flask/uWSGI layer.
- A machine learning inference service where each uWSGI worker loaded a model into memory at startup (
lazy-apps = trueto avoid loading the model multiple times unnecessarily during preload), with Nginx queuing and load-balancing requests across a modest worker pool sized to available GPU/CPU resources.
Best Practices Recap
- Use Unix sockets between Nginx and uWSGI, never a public TCP port for uWSGI.
- Manage uWSGI through systemd so it survives crashes and reboots.
- Keep
uwsgi_paramsincluded in your location block — don’t hand-roll header forwarding. - Set
harakiriand Nginx timeouts to agree with each other. - Recycle workers periodically with
max-requeststo guard against memory creep. - Always terminate SSL at Nginx and redirect HTTP to HTTPS.
- Load test before finalizing
processes/threadscounts — the right numbers depend entirely on your app’s actual workload.
With this setup in place, your Flask app gets everything it was missing from the dev server: real concurrency, process supervision, and a fast, secure front door courtesy of Nginx.