How to Set Up an Nginx Reverse Proxy (2026)

A reverse proxy sits in front of one or more backend services and takes client requests on their behalf. The client talks to Nginx; Nginx talks to your app. The client never sees the backend directly. That single indirection buys you a lot.

If you want the theory behind what "in front of" actually means on the wire, start with our networking fundamentals guide. If you're fuzzy on how a reverse proxy differs from a forward proxy or a dedicated load balancer, read proxy vs reverse proxy vs load balancer first. This post is the hands-on version: config blocks you can copy and adapt.

Why bother with a reverse proxy #

TLS termination: Your Node or Python app shouldn't manage certificates. Terminate HTTPS at Nginx and speak plain HTTP to the backend on localhost.

Routing: One hostname can fan out to many services by path or subdomain. /api goes to one process, / to another.

Buffering: Nginx absorbs slow clients so your app worker isn't tied up trickling bytes to a phone on a bad connection.

Hiding backends: Clients see one endpoint. You can move, restart, or scale services behind it without anyone noticing.

One entry point: Rate limiting, access logs, gzip, and caching all live in one place instead of being reimplemented in every app.

Step 1: A basic reverse proxy server block #

Here's the minimum that gets traffic from port 80 to a Node app listening on 127.0.0.1:3000.

nginx.nginx

server {
listen 80;
server_name app.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;
}
}

Those four headers are not optional in practice.

Host: Without it, Nginx sends the upstream's IP as the Host header. Framework routing, virtual hosts, and generated absolute URLs all break.

X-Real-IP and X-Forwarded-For: Your app sees 127.0.0.1 as the client IP otherwise, because that's who actually connected. These headers carry the original client address forward.

X-Forwarded-Proto: Tells the backend the original request was HTTPS even though Nginx forwards it as HTTP. Frameworks use this to decide whether to issue secure cookies or redirect to HTTPS.

Step 2: Keepalive and WebSockets #

By default Nginx proxies with HTTP/1.0 and no connection reuse. For keepalive to upstreams and for WebSocket upgrades to work, you need HTTP/1.1 and explicit connection handling.

nginx.nginx

location / {
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;

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;
}

The $connection_upgrade variable comes from a map in the http block so that normal requests keep the connection alive while upgrade requests get Connection: upgrade:

nginx.nginx

map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

Skip this and your wss:// connections will 400 or hang, while plain HTTP keeps working. That mismatch is why WebSocket bugs are so confusing.

Step 3: Multiple backends with an upstream block #

To load balance across several app instances, define an upstream and point proxy_pass at its name.

nginx.nginx

upstream backend_app {
least_conn;
server 127.0.0.1:3000 max_fails=3 fail_timeout=15s;
server 127.0.0.1:3001 max_fails=3 fail_timeout=15s;
server 127.0.0.1:3002 backup;

keepalive 32;
}

server {
listen 80;
server_name app.example.com;

location / {
proxy_pass http://backend_app;
proxy_http_version 1.1;
proxy_set_header Connection "";

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;
}
}

least_conn sends each request to the instance with the fewest active connections, which behaves better than round-robin under uneven request durations. max_fails and fail_timeout pull a dead instance out of rotation automatically. The keepalive 32 line reuses upstream connections, and it requires proxy_set_header Connection "" in the location so Nginx doesn't close them.

Step 4: Add TLS and redirect HTTP to HTTPS #

Now terminate HTTPS at Nginx. Keep one server block for the redirect and one for the real traffic.

nginx.nginx

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

server {
listen 443 ssl;
http2 on;
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;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;

# Timeouts and buffering
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering on;
proxy_buffers 16 16k;
proxy_buffer_size 16k;

# Compression
gzip on;
gzip_types text/plain text/css application/json application/javascript;
gzip_min_length 1024;

location / {
proxy_pass http://backend_app;
proxy_http_version 1.1;
proxy_set_header Connection "";

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;
}
}

A note on the timeouts: proxy_read_timeout is the one people hit most. If your app streams a long response or holds a slow query, the default 60 seconds may cut it off with a 504. Raise it for those specific locations rather than globally. Buffering is on by default and usually what you want, but turn it off per-location for streaming endpoints or server-sent events, since buffering defeats the point of a stream.

Common mistakes #

Forgetting the Host header: The single most common cause of broken redirects and wrong virtual-host routing. Set it explicitly, every time.

Trailing slash in proxy_pass: proxy_pass http://backend; and proxy_pass http://backend/; behave differently once you have a path in the location. With a trailing slash Nginx strips the matched location prefix; without it, the prefix is passed through. Pick deliberately and test the actual paths your app receives.

Trusting X-Forwarded-For blindly: Anything downstream can set this header, so a client can spoof its own IP. Only trust the value when the request came through your own proxy. Use set_real_ip_from with your proxy's address range and real_ip_header X-Forwarded-For so Nginx replaces the client IP only from trusted hops.

Missing WebSocket upgrade headers: Covered above. If real-time features fail but page loads work, this is almost always why.

Terminating TLS but forgetting X-Forwarded-Proto: The app thinks it's on plain HTTP, issues a redirect loop trying to "upgrade" to HTTPS, and users get stuck bouncing.

The call we'd make #

Put Nginx in front of every app, even a single instance behind one hostname. The upfront config is small and the payoff is real: you get TLS termination, sane logging, and a place to add caching or rate limiting later without touching application code. Use an upstream block from day one even with one server, so scaling out is a one-line change instead of a rewrite. Terminate TLS at the proxy, speak HTTP on localhost to the backend, and always set the four forwarding headers. Get those fundamentals right and most of the operational headaches never show up.