The dashboard said 502 Bad Gateway. Sixteen of them in five minutes, clustered around 14:32 UTC when the reporting job kicked in. I stared at Nginx’s error log for an hour, convinced I’d botched proxy_pass or the upstream block. Restarted Nginx twice. Tweaked client_max_body_size. Nothing.
The actual problem sat in Gunicorn’s logs, which I hadn’t checked because “Nginx throws the 502, so Nginx is broken.” Gunicorn’s default 30-second timeout was killing workers that got stuck on a database query we’d recently slowed from 8 seconds to 45. The workers died. Nginx got nothing back. 502. Cost us a rollback and three hours of on-call sleep I’ll never get back.
Most tutorials that teach this stack stop at “it runs.” They don’t tell you how the pieces fail together, or how to read the logs when they do. This is the guide I needed at 2am.
- Flask’s built-in server handles one request at a time and warns explicitly against production use.
- Gunicorn runs multiple Python worker processes; its default sync worker and 30-second timeout need tuning for real workloads.
- Nginx handles SSL termination, static files, and request buffering so your Python processes don’t waste cycles on I/O.
- A common starting worker count is (2 × CPU cores) + 1, but memory limits and request patterns matter more than the formula.
- The 502 Bad Gateway almost always means Gunicorn isn’t responding, not that Nginx is misconfigured—check `journalctl -u gunicorn` first.
Before you start: You’ll need Python 3.10+, Flask 3.x, and a Linux server (Ubuntu 22.04 or 24.04 commands shown). We’ll use Gunicorn 23.x and Nginx 1.24+. Root or sudo access required for service configuration.
Why Flask Needs Gunicorn and Nginx for Production
To run Flask with Gunicorn and Nginx, you create a WSGI entry point for Gunicorn, configure Gunicorn to serve your app with multiple workers, and set up Nginx as a reverse proxy. Nginx handles static files, SSL termination, and load balancing, while Gunicorn manages the Python application processes. This combination provides a secure, scalable production environment.
That’s the architecture in one breath. But “secure and scalable” only makes sense once you understand why each piece exists. I’ve seen engineers skip this and pay for it later.
The Development Server Limitation
Flask’s built-in server is exactly what it claims to be: a development convenience. It runs on Werkzeug’s run_simple, which handles one request at a time per process. No threading by default. No worker processes. If your request takes five seconds, everything else queues behind it.
The warning in your terminal isn’t boilerplate. 'Do not use it in a production deployment.' means it lacks request timeouts, connection limits, and resilience against slowloris attacks or malformed headers. I once load-tested a Flask dev server at 10 concurrent requests. It fell over at 7. The socket backlog filled, connections hung, and the process had to be killed from outside.
You need something that speaks HTTP properly and manages multiple requests without blocking your Python interpreter.
What Gunicorn Provides
Gunicorn is a WSGI HTTP server. It translates between HTTP and Python’s WSGI protocol, and crucially, it runs your app in multiple worker processes. The default worker class is sync—each worker handles one request at a time, which sounds limiting until you realize you can run dozens of them.
Workers are separate OS processes, so one segfault or infinite loop doesn’t bring down your site. Gunicorn’s master process monitors them, restarts crashed ones, and handles graceful reloads so you can deploy without dropping connections.
But Gunicorn is not a full HTTP server. It doesn’t handle SSL certificates efficiently. It buffers uploads in memory by default, which hurts with large files. And it serves static files through Python code, which is roughly an order of magnitude slower than letting a dedicated server do it. That’s where Nginx enters.
What Nginx Adds
Nginx sits in front. It terminates SSL connections using optimized C code, not Python. It buffers slow clients—say, a user on 3G uploading a 10MB image—so your Gunicorn workers aren’t tied up waiting for the bytes to arrive. It serves /static/ and /media/ directly from disk with sendfile, bypassing your application entirely.
The proxy_pass directive forwards dynamic requests to Gunicorn, typically over a local socket or 127.0.0.1:8000. Nginx adds headers so your Flask app sees the real client IP, not 127.0.0.1. It handles compression with gzip_types. It rate-limits abusive clients before they reach your Python processes.
Without Nginx, Gunicorn faces the raw internet. With it, Gunicorn sees only fast, clean requests from a trusted local peer. That’s the architecture. The rest is implementation detail—and the places where it breaks.
Creating Your Flask Application and WSGI Entry Point
Three years in, I’ve learned that deployment day isn’t when you figure out your app structure. It’s when you discover your app.py has seventeen circular imports and your config loads from six different places. I hit this on my second production deploy—lesson learned. Here’s the minimal setup that actually works.
Basic Flask App Structure
Keep it flat to start. One directory, clear separation between application code and the server entry point. My typical project looks like this:
myproject/
├── app/
│ ├── __init__.py # Creates the Flask application factory
│ └── routes.py # Your actual endpoints
├── requirements.txt # Production dependencies
└── wsgi.py # The entry point Gunicorn will use
The __init__.py uses a factory pattern. It’s not fancy; it’s defensible. I’ve debugged too many “app context” errors at 2am to do it any other way.
from flask import Flask
def create_app():
application = Flask(__name__)
from app.routes import main
application.register_blueprint(main)
return application
The WSGI Entry Point File
Gunicorn needs a module-level variable named app. Not application. Not flask_app. The convention matters because you’ll forget the exact flag override six months from now.
from app import create_app
app = create_app()
Test it locally before touching Gunicorn. I skipped this once and spent forty minutes chasing a typo that flask --app app run would’ve caught in seconds:
$ python -c "from wsgi import app; print(app.url_map)"
If that prints your routes, you’re solid. If it throws ModuleNotFoundError, fix your PYTHONPATH or virtual environment now. Not during the systemd setup. Not at 2am when the deploy pipeline fails.
Installing and Configuring Gunicorn
Gunicorn is a WSGI server. Not a web server. The distinction matters when something breaks and you’re reading logs wondering which layer failed.
Installing Gunicorn in Your Environment
Always in a virtual environment. System Python on Ubuntu 22.04 still ships with pip warnings about breaking system packages. I’ve seen people --break-system-packages their way into dependency hell. Don’t.
$ python -m venv venv
$ source venv/bin/activate
$ pip install gunicorn flask
Pin your versions in requirements.txt. I got bitten by a minor Gunicorn upgrade that changed default worker behavior. The diff in my git history: gunicorn==21.2.0 now locked.
Basic Gunicorn Command
The command that actually runs your app:
$ gunicorn -w 4 -b 127.0.0.1:8000 wsgi:app
Break it down. -w 4 spawns four worker processes. Each is a separate Python interpreter with your Flask app loaded. -b 127.0.0.1:8000 binds to localhost only—we’re not exposing Gunicorn directly. wsgi:app imports app from wsgi.py.
The default bind is actually 127.0.0.1:8000 if you just run gunicorn wsgi:app, but I never trust defaults in production configs. Explicit is debuggable.
Key Gunicorn Configuration Options
Four flags I always set, learned from watching workers get killed:
| Flag | What I use | Why |
|---|---|---|
-w | (2 * cores) + 1 | The Gunicorn docs formula. For a 2-core VPS, that’s 5. Not 20. Memory isn’t free. |
--timeout | 60 | Default is 30. Database queries, external APIs—stuff happens. |
--access-logfile | /var/log/gunicorn/access.log | Or - for stdout if you’re using systemd journal. |
--error-logfile | /var/log/gunicorn/error.log | Where your tracebacks go. |
I configure via a gunicorn.conf.py file now. Command-line flags become unreadable past four options. The file lives next to wsgi.py, not in /etc/—deployment artifact, not system configuration.
import multiprocessing
bind = "127.0.0.1:8000"
workers = multiprocessing.cpu_count() * 2 + 1
timeout = 60
accesslog = "-"
errorlog = "-"
The multiprocessing import matters. Hardcoding worker counts breaks when you resize your Droplet or container limits change.
Setting Up Nginx as a Reverse Proxy
Nginx configuration is where “it works on my machine” dies. The syntax looks forgiving until you miss a semicolon and spend twenty minutes in nginx -t hell.
Installing Nginx
Ubuntu makes this easy. Too easy—I’ve seen people skip the step where they actually start the service.
$ sudo apt update
$ sudo apt install nginx
$ sudo systemctl start nginx
$ sudo systemctl enable nginx
Verify it’s running: curl -I http://localhost should return HTTP/1.1 200 OK with the default Nginx welcome page. If you get connection refused, check ufw or cloud provider security groups. I lost an hour to an AWS security group once. The config was perfect; the packet never arrived.
Configuring the Nginx Server Block
Nginx on Ubuntu uses sites-available/ and sites-enabled/. The separation is intentional—disable sites without deleting configs. I keep old versions with dates in the filename.
Create /etc/nginx/sites-available/myapp:
server {
listen 80;
server_name api.example.com;
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;
}
location /static/ {
alias /var/www/myapp/static/;
}
client_max_body_size 10M;
}
Enable it:
$ sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
$ sudo nginx -t
$ sudo systemctl reload nginx
Always nginx -t before reloading. A syntax error in the default config can bring down every site on the server. I learned this the hard way on a shared staging box.
Key Directives Explained
The proxy_pass line forwards everything to Gunicorn. Nginx handles the HTTP parsing, buffering, and connection management. Your Flask app sees clean WSGI requests.
proxy_set_header lines matter for Flask’s request.remote_addr. Without them, every IP is 127.0.0.1, which breaks rate limiting and audit logs.
The /static/ location block uses alias, not root. The difference:
root /var/www/myapp/static/+location /static/→ looks for files at/var/www/myapp/static/static/alias /var/www/myapp/static/→ looks at/var/www/myapp/static/
I spent thirty minutes debugging 404s before I internalized this. The error log showed the full path it tried; I just wasn’t reading it carefully enough.
client_max_body_size 10M overrides the 1MB default. Flask’s upload handling will still reject oversized files, but Nginx kills the connection earlier with a friendlier error. For file uploads, this sequence matters: Nginx → Gunicorn → Flask, each layer able to enforce limits.
Here’s how the pieces fit together:
flowchart LR
Client[Client Browser] -->|HTTPS| Nginx
Nginx[Nginx Reverse Proxy] -->|HTTP 127.0.0.1:8000| Gunicorn
Gunicorn[Gunicorn Workers] -->|WSGI| Flask[Flask Application]
Nginx -->|sendfile| Static[Static Files /static/]
My take: People overcomplicate this stack. You don’t need Docker, Kubernetes, or twelve microservices to serve a Flask app reliably. Gunicorn plus Nginx on a single VPS handled 10K RPM for my last side project. The architecture isn’t the bottleneck; your database queries are. Focus there first. When you do need scale, this setup migrates to containers cleanly because the boundaries—WSGI entry point, static files, environment variables—are already explicit. I wrote about that migration path in From PR to Production: How Kubernetes Deployments Actually Work, but don’t reach for it until you’ve outgrown a single box.
Running the Stack with Process Management
You’ve got Nginx and Gunicorn talking. Now make them survive reboots.
Testing the Configuration
One typo in your Nginx config and the next restart fails. nginx -t catches this before you break production.
$ sudo nginx -t
nginx: [emerg] unknown directive "prooxy_pass" in /etc/nginx/sites-enabled/myapp:15
nginx: configuration file /etc/nginx/nginx.conf test failed
Fix, retest, breathe. Only then reload.
$ sudo nginx -t && sudo systemctl reload nginx
I skipped this once. Pushed a config with a missing semicolon at 2am. The reload failed silently because I used ; to chain commands and the second one never ran. Always &&. Always test first.
Using Systemd to Run Gunicorn
Running gunicorn in a terminal dies when you disconnect. Systemd keeps it alive, manages logs, and handles restarts.
Create /etc/systemd/system/gunicorn.service:
[Unit]
Description=Gunicorn instance to serve myapp
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/myapp
Environment="PATH=/var/www/myapp/venv/bin"
Environment="FLASK_ENV=production"
ExecStart=/var/www/myapp/venv/bin/gunicorn --workers 3 --bind unix:myapp.sock -m 007 wsgi:app
[Install]
WantedBy=multi-user.target
I use a Unix socket (unix:myapp.sock) instead of TCP. No port conflicts, no firewall rules, slightly lower latency. The -m 007 sets socket permissions so Nginx (running as www-data) can read it.
My take: Skip the environment variable files and EnvironmentFile= directive. I’ve seen systemd fail to load them with cryptic errors, and debugging that at 3am isn’t fun. Inline Environment= is verbose but explicit. You see exactly what the process gets.
Starting and Enabling the Services
Start Gunicorn, then tell systemd to launch it on boot.
$ sudo systemctl start gunicorn
$ sudo systemctl enable gunicorn
Created symlink /etc/systemd/system/multi-user.target.wants/gunicorn.service → /etc/systemd/system/gunicorn.service.
Check status:
$ sudo systemctl status gunicorn
● gunicorn.service - Gunicorn instance to serve myapp
Loaded: loaded (/etc/systemd/system/gunicorn.service; enabled; vendor preset: enabled)
Active: active (running) since Mon 2024-01-15 09:23:11 UTC; 2min ago
If it’s failed, check logs immediately:
$ sudo journalctl -u gunicorn --no-pager -n 50
Restart both after any config change:
$ sudo systemctl restart gunicorn && sudo systemctl reload nginx
Order matters. Gunicorn must be listening before Nginx tries to connect.
Scaling and Performance Tuning
The default worker count is (2 * $num_cores) + 1. On a 4-core VPS, that’s 9 workers. I ran 20 once because “more workers = more throughput,” right? Wrong. Memory usage ballooned, the OOM killer visited, and I learned that Gunicorn workers are processes, not threads. Each loads your entire Flask app.
Calculating Optimal Gunicorn Workers
Start with the formula. Measure. Adjust.
$ nproc
4
$ gunicorn --workers 9 --bind unix:myapp.sock wsgi:app
For I/O-bound apps (database-heavy, API calls), try fewer workers with gevent:
$ gunicorn --worker-class gevent --workers 3 --bind unix:myapp.sock wsgi:app
I switched a reporting dashboard from sync workers (default) to gevent and cut response times from 800ms to 120ms. The app was waiting on PostgreSQL, not computing. The sync workers blocked, underutilizing CPU. gevent let them multiplex.
Nginx Caching and Compression
Enable gzip for JSON and HTML responses. The gzip_types directive is picky—application/json isn’t in the default set.
http {
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}
Higher gzip_comp_level shrinks files more but uses more CPU. Level 6 is the sweet spot I found in production testing. Level 9 added 40% CPU time for 3% smaller files.
Handling Static Files Efficiently
Don’t route /static/ through Gunicorn. Nginx serves files directly with sendfile, zero-copy kernel transfers.
location /static/ {
alias /var/www/myapp/static/; # Trailing slash matters
expires 1y;
add_header Cache-Control "public, immutable";
}
I once used root here. Nginx looked for /var/www/myapp/static/static/file.css. The 404s were mystifying until I checked error.log and saw the doubled path. alias is correct when the location path (/static/) differs from the filesystem path.
For performance, this separation matters more than you’d think. I profiled a site serving 2MB of CSS/JS. Through Gunicorn: 180ms. Through Nginx alias: 12ms. And that was before browser caching kicked in.
Common Errors and Troubleshooting
You’ll hit these. Here’s how I unkinked them.
Connection Refused Errors
Symptom: curl returns connection refused to 127.0.0.1:8000. Wrong guess: firewall. Real cause: Gunicorn bound to a Unix socket, not TCP, or bound to 127.0.0.1 instead of 0.0.0.0 inside a container.
Check what’s actually listening:
$ sudo ss -tlnp | grep gunicorn
$ sudo ls -la /var/www/myapp/*.sock
Match your Nginx proxy_pass to Gunicorn’s actual bind. http://127.0.0.1:8000 won’t reach a Unix socket. Use proxy_pass http://unix:/var/www/myapp/myapp.sock; instead.
502 Bad Gateway
Nginx can’t reach Gunicorn. The error page doesn’t say why. Check layers.
First, Gunicorn running?
$ sudo systemctl is-active gunicorn
$ sudo journalctl -u gunicorn -n 20 --no-pager
Socket permissions wrong? Nginx runs as www-data; the socket must be readable. I once set User=ubuntu in the systemd file for “simplicity.” Nginx got permission denied. The logs said “502.” Took twenty minutes to trace.
Second, timeout mismatch. Nginx’s proxy_read_timeout defaults to 60s. Gunicorn’s timeout defaults to 30s. If your Flask app takes 45 seconds, Gunicorn kills the worker before Nginx gives up. Nginx then sees a closed connection and 502s. Align these, or better, fix the slow query. I wrote about tracing those in Sequelize Node.js Performance Guide (2024)—the ORM patterns are different but the debugging mindset transfers.
Worker Timeouts and Memory Issues
The default 30-second timeout kills workers mid-request. Symptom: intermittent 502s under load. Wrong guess: database connection pool exhaustion. Real cause: a report generation endpoint hitting 35 seconds.
Bump it:
$ gunicorn --timeout 120 --workers 3 wsgi:app
Or in systemd: ExecStart=/path/to/gunicorn --timeout 120 ...
Memory climbing? Check worker class. sync forks full processes. gevent uses greenlets—lighter, but not magic. I had a memory leak in a C extension that gevent actually made worse by running more concurrent requests. Switched back to sync with fewer workers as a band-aid until I found the leak.
For stuck debugging sessions, journalctl is your friend. Filter by time, follow live logs, export to file. The systemd integration beats managing log files by hand.
Security and SSL Configuration
Running Services as Non-Root Users
Running Gunicorn as root is lazy and dangerous. I learned this the hard way when I left a personal project on a VPS with User=root in systemd. A dependency pulled in a compromised package. Suddenly my server was mining crypto.
Create a dedicated user:
$ sudo useradd --system --no-create-home --shell /bin/false flaskapp
$ sudo mkdir -p /var/www/flaskapp
$ sudo chown -R flaskapp:flaskapp /var/www/flaskapp
Update your systemd service:
[Unit]
Description=Flask application
After=network.target
[Service]
User=flaskapp
Group=flaskapp
WorkingDirectory=/var/www/flaskapp
ExecStart=/var/www/flaskapp/venv/bin/gunicorn --workers 3 --bind unix:/var/www/flaskapp/app.sock wsgi:app
Restart=always
[Install]
WantedBy=multi-user.target
The socket lives in /var/www/flaskapp/ where the user owns it. Nginx reads this via www-data. No root needed.
Obtaining Let’s Encrypt SSL Certificates
Certbot makes this almost too easy. Install the Nginx plugin:
$ sudo apt install certbot python3-certbot-nginx
$ sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
It edits your Nginx config automatically, sets up renewal timers, and redirects HTTP to HTTPS. Check renewal works: sudo certbot renew --dry-run. I’ve had certificates expire because I skipped this step on a forgotten staging server.
Hardening the Nginx Configuration
SSL alone isn’t enough. Add these to your server block:
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
proxy_pass http://unix:/var/www/flaskapp/app.sock;
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;
}
}
My take: HSTS with preload is aggressive—you’re telling browsers to never accept HTTP for your domain. I’ve enabled it prematurely and broken local testing. Start with max-age=300 (5 minutes), verify everything works, then ramp to 63072000 (2 years) and submit to the preload list. CSP will break your app repeatedly. Use Content-Security-Policy-Report-Only first, watch your logs, then enforce.
Frequently asked questions
Do I need Nginx if I’m just using Gunicorn?
For a public-facing production app, yes. Gunicorn handles WSGI but isn’t optimized for serving static files, SSL termination, or slow client protection. Nginx buffers slow uploads and can serve your CSS/JS directly without touching Python. I ran Gunicorn exposed once for an internal tool. Worked fine until someone’s upload saturated the worker pool. Nginx’s client_body_buffer_size would have prevented that.
Can I run Flask in production without Gunicorn?
No. Flask’s built-in server is single-threaded, not secure, and prints that warning for a reason. It handles one request at a time and leaks memory under load. You need a production WSGI server—Gunicorn, uWSGI, or mod_wsgi. I’ve seen flask run in production twice. Both times the app collapsed under ten concurrent requests.
How many Gunicorn workers should I use?
Start with (2 * CPU cores) + 1. On a 4-core machine, that’s 9 workers. Monitor with htop and systemctl status gunicorn. If memory is tight, reduce. For I/O-bound apps—lots of database or API calls—consider gevent or eventlet workers with fewer processes. I ran 20 sync workers on a 2-core box once. The OOM killer taught me arithmetic.
What’s the difference between Gunicorn and uWSGI?
Both are production WSGI servers. Gunicorn is pure Python, simpler to configure, and what I reach for first. uWSGI is written in C, highly configurable, and faster in raw benchmarks—but its config file format is arcane. I’ve spent hours debugging uWSGI’s Emperor mode. For most Flask apps, Gunicorn’s trade-offs are right. uWSGI shines when you need specific optimizations like custom routing or shared memory caching.
If you’re setting this up Monday morning, don’t configure everything at once. Get Flask → Gunicorn → Nginx working with HTTP. Verify with curl -I http://localhost. Then add systemd. Then Certbot. Then the security headers. Each layer is easier to debug in isolation.
This setup stops working when you need zero-downtime deploys or horizontal scaling across multiple machines. Then you’re looking at load balancers, blue-green deployments, or moving to something like Kubernetes. I’ve written about that transition in Effective Kubernetes StatefulSet Deployment Guide.
What broke when you tried this? The comments are open.