Almost every application I deploy to production these days — whether it’s a Node.js API, a Python/Django backend, or a Go microservice — ends up sitting behind Nginx acting as a reverse proxy rather than being exposed directly to the internet. It’s one of the most common patterns in modern infrastructure, and once you understand the core proxy_pass directive and a handful of supporting headers, it becomes second nature.
In this guide, I’ll explain what a reverse proxy actually does, walk through a complete Nginx reverse proxy configuration, cover WebSocket support, load balancing across multiple backend instances, and the header and timeout settings that trip people up the most.
What a Reverse Proxy Does
A reverse proxy sits between clients and one or more backend servers, forwarding client requests to the appropriate backend and returning the backend’s response to the client. From the outside, visitors only ever talk to Nginx — they never connect directly to the application server behind it.
This gives me several practical advantages:
- A single entry point for SSL termination, so my backend application doesn’t need to handle certificates at all
- Load balancing across multiple application instances
- Caching and compression handled at the proxy layer instead of duplicated in every backend service
- Isolation — the application server can bind to
127.0.0.1only, never directly exposed to the internet - Unified logging and rate limiting across services that might otherwise handle these inconsistently
Requirements
- Nginx installed and running
- A backend application already running and listening on some local port (I’ll use
127.0.0.1:3000as an example, representing a Node.js app, but this applies to any backend — Django on Gunicorn, Flask, a Java Spring Boot app, etc.) - Root or sudo access to edit Nginx configuration
Step 1: Basic Reverse Proxy Configuration
The core directive is proxy_pass, used inside a location block:
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://127.0.0.1:3000;
}
}
This alone works, but it’s incomplete — by default, the backend application only sees Nginx’s IP as the source of every request, and it doesn’t know the original Host header the client used. I fix that by explicitly forwarding the right headers.
Step 2: Forwarding Essential Headers
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
Here’s what each of these does, since I’ve seen people copy-paste this without knowing why:
Host $host— passes the original hostname the client requested, so the backend application can generate correct absolute URLs, redirects, and know which virtual host it’s serving if it hosts multiple sites itself.X-Real-IP $remote_addr— gives the backend the actual client IP, since without it, every request appears to originate from Nginx’s own IP (usually127.0.0.1).X-Forwarded-For $proxy_add_x_forwarded_for— appends the client IP to any existing forwarding chain, useful when there are multiple proxies (like a CDN in front of Nginx).X-Forwarded-Proto $scheme— tells the backend whether the original request was HTTP or HTTPS, which matters for applications generatinghttps://links or checking whether a request is secure.
Most frameworks (Django, Express, Rails, Laravel) have built-in support for reading these headers to correctly reconstruct the original request context — but they usually need to be explicitly told to trust the proxy (e.g., Django’s SECURE_PROXY_SSL_HEADER setting).
Step 3: Setting Reasonable Timeouts
Backend applications sometimes take longer to respond than Nginx’s defaults allow, especially for long-running requests like file uploads or report generation. I set these explicitly rather than relying on defaults:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
Step 4: WebSocket Support
If the backend application uses WebSockets (real-time chat apps, live dashboards, collaborative editors), the standard reverse proxy config isn’t enough on its own — WebSockets require an HTTP upgrade that Nginx needs to explicitly pass through:
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
The map directive is useful here for handling the Connection header correctly across both regular HTTP and WebSocket requests sharing the same server block:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
Step 5: Load Balancing Across Multiple Backend Instances
When I need to distribute traffic across multiple instances of the same application (for scaling or high availability), I define an upstream block:
upstream backend_app {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://backend_app;
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;
}
}
By default, Nginx uses round-robin load balancing. Other options I use depending on the situation:
upstream backend_app {
least_conn; # send to the instance with fewest active connections
server 127.0.0.1:3000 weight=3; # weighted — this instance gets 3x traffic
server 127.0.0.1:3001;
server 127.0.0.1:3002 backup; # only used if the others are down
}
ip_hash is another useful option when the application needs session persistence (sticky sessions) without a shared session store:
upstream backend_app {
ip_hash;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
Complete Example Configuration
Here’s a full, production-ready reverse proxy setup combining SSL termination, load balancing, and WebSocket support:
upstream backend_app {
least_conn;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://backend_app;
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;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location /ws/ {
proxy_pass http://backend_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
}
}
Testing the Reverse Proxy
sudo nginx -t
sudo systemctl reload nginx
curl -I https://example.com/
I confirm the backend actually receives the correct forwarded headers by temporarily logging them in the application, or using a simple test endpoint that echoes request headers back.
To test load balancing, I add a distinguishing response (like an instance ID) to each backend instance temporarily and send repeated requests to confirm traffic is distributed:
for i in {1..10}; do curl -s https://example.com/ | grep instance; done
Troubleshooting Common Issues
502 Bad Gateway. This means Nginx can’t reach the backend at all. Common causes: the backend application isn’t running, it’s listening on the wrong port or interface (e.g., bound to 127.0.0.1 when Nginx expects a different address, or vice versa), or a firewall is blocking the connection between them.
sudo systemctl status my-app
curl http://127.0.0.1:3000
504 Gateway Timeout. The backend is reachable but too slow to respond within the configured timeout. I either increase proxy_read_timeout for legitimately slow endpoints, or investigate why the backend itself is slow.
WebSocket connections fail or drop immediately. Almost always a missing Upgrade/Connection header pass-through, or proxy_http_version not set to 1.1 (WebSockets require HTTP/1.1, and Nginx defaults to 1.0 for proxied connections unless told otherwise).
Backend receives Nginx’s IP instead of the real client IP. Missing X-Real-IP and X-Forwarded-For headers, or the application isn’t configured to trust and read them from a proxy.
Redirect loops or incorrect URLs generated by the backend. Usually caused by the backend not knowing the original protocol (HTTP vs HTTPS) because X-Forwarded-Proto isn’t set or isn’t trusted by the application framework.
Security Considerations
- Bind backend applications to
127.0.0.1(or a private network interface) only — never expose the application port directly to the public internet alongside the reverse proxy. - Set
proxy_hide_headerto strip internal headers the backend might leak (likeX-Powered-By) before responses reach the client:
proxy_hide_header X-Powered-By;
- Apply rate limiting at the Nginx layer to protect backend applications that may not have their own throttling:
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://backend_app;
}
- Always validate that
X-Forwarded-*headers set by Nginx aren’t overridable by a malicious client sending their own conflicting headers upstream of Nginx —proxy_set_headeroverwrites, rather than appends, in most of these cases, but double-check when chaining multiple proxies.
Performance Tips
- Enable connection keep-alive to the backend to avoid the overhead of establishing a new TCP connection per request:
upstream backend_app {
server 127.0.0.1:3000;
keepalive 32;
}
location / {
proxy_pass http://backend_app;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
- Enable proxy buffering for typical HTTP responses (it’s on by default) — it lets Nginx read the backend’s response quickly and stream it to slow clients without holding the backend connection open the whole time:
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
- Disable buffering only for specific cases like server-sent events or streaming responses, where buffering would introduce unwanted latency:
location /stream/ {
proxy_pass http://backend_app;
proxy_buffering off;
}
Real-World Use Cases
- Running a Node.js, Django, Flask, or Rails application behind Nginx for SSL termination and static file serving.
- Load balancing across multiple instances of a containerized application for horizontal scaling.
- Exposing a real-time chat or notification service that relies on WebSockets.
- Routing different URL paths (
/api/,/admin/,/) to entirely different backend services from a single domain. - Acting as an API gateway in front of several microservices.
Best Practices
- Always forward
Host,X-Real-IP,X-Forwarded-For, andX-Forwarded-Proto— skipping these causes subtle bugs in logging, redirects, and IP-based logic in the backend. - Set explicit timeouts rather than relying on defaults, tuned to your application’s actual response characteristics.
- Use an
upstreamblock even for a single backend server — it makes it trivial to scale to multiple instances later without restructuring the config. - Enable keep-alive connections to the backend for better performance under load.
- Test WebSocket support explicitly if your application uses it — it’s easy to miss until a feature silently breaks in production.
Wrapping Up
Reverse proxying is the backbone of how I deploy almost every application today — it decouples SSL, load balancing, and traffic management from the application itself, which makes the whole system easier to scale and secure. The core proxy_pass directive is deceptively simple, but the headers, timeouts, and WebSocket handling around it are where the real configuration work happens. Get those right once, and adding new backend instances or services later becomes a matter of editing an upstream block, not re-architecting anything.