What is it? #
This lesson is the practical setup of the reverse proxy concept: getting an application safely behind Nginx on a real server.
The pieces are: the application listens on localhost only, Nginx listens publicly, TLS terminates at Nginx, static files are served directly, and everything else is forwarded.
Three details cause most of the problems: forwarded headers, timeouts, and WebSocket upgrades.
Getting this right once gives you a template you will reuse for every application you deploy.
Think of it like this #
A shopfront with a workshop behind it. Customers only ever see the counter; the workshop has no street door at all.
Everything the workshop needs to know about the customer — who they are, how they arrived — has to be passed through by the person at the counter.
Simple example #
A Python application runs on port 8000 bound to localhost. Nginx serves it publicly on HTTPS, handles static files, allows 20 MB uploads and supports a WebSocket endpoint.
Code #
# 1. The application binds to localhost only
gunicorn app.main:app --bind 127.0.0.1:8000 --workers 4
# Not 0.0.0.0 — that would expose it directly, bypassing the proxy.
# Confirm
sudo ss -tulpn | grep 8000
# tcp LISTEN 0 511 127.0.0.1:8000 users:(("gunicorn",...))
# 2. /etc/nginx/sites-available/app
upstream app_backend {
server 127.0.0.1:8000 fail_timeout=10s;
keepalive 32; # reuse upstream connections
}
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
client_max_body_size 20M;
location /static/ {
alias /srv/app/static/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
location /ws/ {
proxy_pass http://app_backend;
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; # long-lived connection
}
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection ""; # required for upstream keepalive
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_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering on; # shield the app from slow clients
}
}
# 3. The application must trust the forwarded headers — but only from the proxy
# FastAPI / Starlette
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="127.0.0.1")
# Django
# settings.py
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
4. Verify
curl -I https://app.example.com/ → 200, correct headers
curl -I http://app.example.com/ → 301 to https
curl https://app.example.com/whoami → shows the real client IP, not 127.0.0.1
from another machine: nc -zv server 8000 → must be refused
How it works #
Binding the application to 127.0.0.1 is the single most important step. It means there is no path to the application except through Nginx, regardless of firewall configuration.
The upstream block with keepalive reuses connections to the application rather than opening a new one per request, which reduces latency noticeably under load. It requires proxy_http_version 1.1 and clearing the Connection header.
The forwarded headers carry information Nginx has and the application does not: the real client IP and the original protocol. Without X-Forwarded-Proto, an application behind TLS termination sees plain HTTP and may redirect users into a loop.
The application must be told to trust those headers, and only from the proxy address. Trusting them unconditionally would let any client spoof its IP by setting the header itself.
proxy_buffering on lets Nginx accept the response quickly and trickle it to a slow client, freeing the application worker immediately. That is a real capacity gain when clients are on mobile networks.
The WebSocket location needs the upgrade headers and a long read timeout, since the connection stays open far longer than a normal request.
The verification steps at the end are worth running every time. Confirming that port 8000 is refused from another machine is what proves the application is genuinely not exposed.
Real-world use #
This configuration is close to what a production deployment uses, and it is the template referenced throughout the VPS track.
The commonest incident is a redirect loop: the application redirects HTTP to HTTPS, does not know TLS was terminated upstream, and redirects endlessly. X-Forwarded-Proto plus the application-side setting fixes it.
Rate limiting by IP breaks silently without X-Forwarded-For, because every request appears to come from the proxy. The same applies to geolocation and audit logs.
Timeouts need thought. A 60-second read timeout returns 504 for anything slower, which is usually correct — long operations belong in a background job rather than a request.
Running several applications behind one proxy is the same pattern repeated: a server block per domain, each pointing at a different local port.
Common mistakes #
- Binding the application to 0.0.0.0, leaving a direct public path.
- Omitting X-Forwarded-Proto and causing an HTTPS redirect loop.
- Trusting forwarded headers from any source, allowing IP spoofing.
- Missing WebSocket upgrade headers or leaving the default short read timeout.
- Never verifying that the application port is unreachable from outside.
Practice #
Deploy a small application bound to localhost, put Nginx in front with TLS, static file serving and a 20 MB upload limit. Confirm the application sees the real client IP and the HTTPS scheme, then verify from another machine that the application port itself is refused.