How to Set Up Nginx for a Python Application

How to Set Up Nginx for a Python Application

Unlike PHP, Python doesn’t have a native “hand the request off and let it run” model built into Nginx — there’s no fastcgi_pass-equivalent shortcut that just works out of the box. Instead, the standard, production-proven pattern is to run your Python application behind an application server (Gunicorn or uWSGI for WSGI apps like Django and Flask, or Uvicorn/Hypercorn for ASGI apps like FastAPI), and have Nginx sit in front of that as a reverse proxy. Nginx handles TLS, static files, and connection management; the application server handles actually running your Python code.

This guide covers both the WSGI path (Django, Flask, most traditional Python web frameworks) and the ASGI path (FastAPI, Django with async support, other async frameworks), since the two have real configuration differences worth understanding rather than just following one and hoping it applies to the other.

Understanding WSGI vs ASGI (and Why It Matters Here)

WSGI (Web Server Gateway Interface) is the traditional, synchronous interface Python web apps have used for years — Flask, and Django until relatively recently, are built on it. Gunicorn is the most common WSGI server used in production.

ASGI (Asynchronous Server Gateway Interface) is the newer standard supporting async/await, WebSockets, and long-lived connections — FastAPI is built on it natively, and Django added ASGI support for async views and channels. Uvicorn is the standard ASGI server.

The Nginx-facing configuration is nearly identical either way — Nginx proxies HTTP requests to a local port or Unix socket where your application server is listening. The real difference is on the Python side (which server you run, and whether you need WebSocket proxy support in Nginx, which matters much more for ASGI apps handling real-time features).

Requirements

  • A Linux server with Python 3.9+ installed.
  • Your application code deployed, with dependencies installed in a virtual environment.
  • Nginx installed.
  • Gunicorn or Uvicorn installed in your project’s virtual environment.
  • A domain name if deploying publicly.

Step 1: Set Up Your Python Application and Virtual Environment

sudo mkdir -p /var/www/myapp
cd /var/www/myapp
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

For a Django project:

pip install gunicorn

For a FastAPI project:

pip install uvicorn[standard] fastapi

Step 2: Test the Application Server Directly First

Before touching Nginx, confirm the app runs correctly on its own.

Django/Flask with Gunicorn:

gunicorn --bind 127.0.0.1:8000 myproject.wsgi:application

FastAPI with Uvicorn:

uvicorn main:app --host 127.0.0.1 --port 8000

Visit http://127.0.0.1:8000 from the server itself (curl http://127.0.0.1:8000) to confirm it responds before adding Nginx into the mix — isolating problems to either “the app” or “the proxy” is much easier this way than debugging both layers at once.

Step 3: Run the Application Server as a Persistent Service

Don’t rely on a manually run foreground process in production — create a systemd service so it survives reboots and crashes.

For Gunicorn, create /etc/systemd/system/myapp.service:

[Unit]
Description=Gunicorn instance for myapp
After=network.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/myapp
Environment="PATH=/var/www/myapp/venv/bin"
ExecStart=/var/www/myapp/venv/bin/gunicorn \
    --workers 4 \
    --bind unix:/var/www/myapp/myapp.sock \
    myproject.wsgi:application

Restart=always

[Install]
WantedBy=multi-user.target

For Uvicorn (FastAPI), the equivalent uses uvicorn directly, or better, gunicorn with the Uvicorn worker class for production process management:

[Unit]
Description=Uvicorn instance for myapp
After=network.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/myapp
Environment="PATH=/var/www/myapp/venv/bin"
ExecStart=/var/www/myapp/venv/bin/gunicorn main:app \
    --workers 4 \
    --worker-class uvicorn.workers.UvicornWorker \
    --bind unix:/var/www/myapp/myapp.sock

Restart=always

[Install]
WantedBy=multi-user.target

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp

A Unix socket (rather than a TCP port) is used here since Nginx and the app server are on the same machine — it’s marginally faster and avoids taking up a TCP port unnecessarily. Use --bind 127.0.0.1:8000 instead if you’d rather use TCP, which is also perfectly fine and sometimes simpler to debug.

Step 4: Configure Nginx as the Reverse Proxy

Create /etc/nginx/sites-available/myapp:

server {
    listen 80;
    server_name myapp.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name myapp.example.com;

    ssl_certificate     /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/myapp.example.com/privkey.pem;

    client_max_body_size 20m;

    access_log /var/log/nginx/myapp_access.log;
    error_log  /var/log/nginx/myapp_error.log;

    location /static/ {
        alias /var/www/myapp/static/;
        expires 30d;
        add_header Cache-Control "public, max-age=2592000";
    }

    location /media/ {
        alias /var/www/myapp/media/;
        expires 7d;
    }

    location / {
        proxy_pass http://unix:/var/www/myapp/myapp.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;
        proxy_read_timeout 60s;
    }
}

Key points:

  • location /static/ and /media/ are served directly by Nginx, not passed to Python at all. This is a significant performance win — Django/Flask serving static files themselves (fine for local development) is genuinely inefficient in production; letting Nginx handle it directly is dramatically faster and offloads work from your Python processes entirely.
  • proxy_pass http://unix:/var/www/myapp/myapp.sock; — the http:// prefix is required even when using a Unix socket; this is a common point of confusion since it looks like it shouldn’t be needed.
  • If using TCP instead of a socket: proxy_pass http://127.0.0.1:8000;

For Django specifically, run collectstatic to gather static files into the directory Nginx is serving from:

python manage.py collectstatic

Step 5: Add WebSocket Support (Important for ASGI/FastAPI/Django Channels)

If your application uses WebSockets (common in FastAPI apps, Django Channels, or any real-time feature), add upgrade headers:

location /ws/ {
    proxy_pass http://unix:/var/www/myapp/myapp.sock;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 3600s;
}

The long proxy_read_timeout here matters — WebSocket connections are meant to stay open far longer than a typical HTTP request, and Nginx’s default timeout will silently kill idle-but-legitimate connections otherwise.

Step 6: Test and Reload

sudo nginx -t
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo systemctl reload nginx

Step 7: Verify Everything Works

curl -I https://myapp.example.com

Check for a 200 response and confirm static assets load correctly by visiting a page with CSS/images in a browser and checking the network tab for any 404s.

If your app has a WebSocket feature, test it directly (browser dev tools’ Network tab shows WebSocket connection status, or test with a tool like wscat):

wscat -c wss://myapp.example.com/ws/

Troubleshooting Common Issues

502 Bad Gateway. The application server isn’t running, crashed, or the socket/port doesn’t match between the systemd service and the Nginx config. Check:

sudo systemctl status myapp
sudo journalctl -u myapp -n 50

“Permission denied” connecting to the socket. The socket file’s permissions don’t allow the Nginx user (www-data) to connect. Confirm the Gunicorn/Uvicorn process’s User/Group in the systemd unit matches, or explicitly set socket permissions in Gunicorn’s bind config if needed.

Static files 404. collectstatic wasn’t run (Django), or the alias path in Nginx doesn’t match where static files actually live. Double check with ls /var/www/myapp/static/.

App works via curl 127.0.0.1:8000 directly but not through Nginx. Usually a proxy_pass syntax issue (missing http:// prefix before the socket path is a classic one) or a firewall/SELinux context issue if you’re on a RHEL-based system — check sudo journalctl -u nginx and SELinux audit logs (sudo ausearch -m avc -ts recent) if applicable.

WebSocket connections fail or disconnect immediately. Missing Upgrade/Connection: upgrade headers, or proxy_read_timeout too short.

App restarts don’t pick up code changes. Restart the systemd service after deploys: sudo systemctl restart myapp — a common thing to forget in a deploy script.

Security Considerations

  • Never run the application server (Gunicorn/Uvicorn) bound to 0.0.0.0 on a port directly exposed to the internet — always put it behind Nginx and bind to 127.0.0.1 or a Unix socket instead.
  • Set DEBUG = False in Django (or your framework’s equivalent) in production — debug mode can leak stack traces, environment variables, and source code snippets to anyone who triggers an error.
  • Keep secrets (SECRET_KEY, database credentials, API keys) in environment variables or a secrets manager, not committed to your codebase or exposed via a misconfigured static file path.
  • Set client_max_body_size deliberately based on your actual upload needs, not left unbounded.
  • Restrict the systemd service’s filesystem access using ProtectSystem=strict and ReadWritePaths= directives for defense in depth if your threat model warrants it.
  • Enable rate limiting on login/authentication endpoints specifically:
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

location /login/ {
    limit_req zone=login burst=3 nodelay;
    proxy_pass http://unix:/var/www/myapp/myapp.sock;
}

Performance Tips

  • Always serve static and media files directly through Nginx location/alias blocks — never proxy these to Python, which is measurably slower and wastes application server capacity on work Nginx does natively and efficiently.
  • Tune Gunicorn’s --workers count based on CPU cores — a common starting formula is (2 × CPU cores) + 1, then adjust based on actual load testing.
  • Use gunicorn‘s --worker-class gevent or --worker-class uvicorn.workers.UvicornWorker if your workload is I/O-bound (lots of external API calls, database queries) rather than CPU-bound — sync workers block on I/O, async ones don’t.
  • Enable gzip for API/HTML responses:
gzip on;
gzip_types application/json text/css application/javascript;
gzip_min_length 256;
  • Use proxy_buffering on; (Nginx’s default) for typical request/response cycles, but disable it specifically for streaming or Server-Sent Events endpoints where you want data flushed immediately rather than buffered.

Real-World Use Cases

  • A Django e-commerce app runs behind Nginx with static/media files served directly, Gunicorn handling application logic with 8 workers tuned to the server’s CPU count, and Nginx applying rate limiting on the checkout and login endpoints specifically.
  • A FastAPI-based internal API serving real-time dashboard data uses Uvicorn workers with WebSocket support configured in Nginx for live data push to connected clients.
  • A Flask microservice architecture runs several small services on the same host, each behind its own Unix socket, with a single Nginx instance routing by subdomain or path prefix to the correct backend service.

Best Practices

  • Always run the Python app as a managed systemd service, not a manually launched foreground process — you want automatic restarts on crash and on boot.
  • Serve static and media assets directly through Nginx, never proxied through Python.
  • Match WebSocket support to your actual framework needs — don’t skip it if you’re running FastAPI with real-time features, and don’t add unnecessary complexity if you’re not.
  • Set client_max_body_size and timeout values based on your application’s real upload/response-time characteristics, not arbitrary defaults.
  • Test the application server directly (bypassing Nginx) first when debugging — it isolates whether the problem is in Python or in the proxy layer.

Wrapping Up

The Nginx + Python pattern always comes down to the same core idea: let Nginx handle what it’s excellent at (TLS, static files, connection management, load balancing) and let a dedicated application server (Gunicorn or Uvicorn) handle running your actual Python code, with Nginx proxying requests between the two. Whether you’re running a traditional synchronous Django app or an async FastAPI service, the Nginx-side configuration barely changes — what matters is picking the right application server and worker model for your workload, wiring up static file serving correctly, and adding WebSocket support only where your application actually needs it. Get the systemd service and socket/port wiring right, verify with a direct curl to the app server before adding Nginx, and this setup scales comfortably from a small side project to a production-grade deployment.

Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Nginx for a Ruby Application

How to Set Up Nginx for a Ruby Application

Next Post
How to Set Up Nginx for a PHP Application

How to Set Up Nginx for a PHP Application

Related Posts