How to Set Up an Nginx Reverse Proxy (2026)
A practical, step-by-step guide to putting Nginx in front of your app for TLS, routing, and load balancing.
Key takeaways
- To set up an Nginx reverse proxy, add a server block with proxy_pass pointing at your app and forward the Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto headers.
- Then terminate TLS at Nginx, use an upstream block for load balancing, and add Upgrade and Connection headers so WebSockets work.
On this page
To set up an Nginx reverse proxy, add a server block whose location / runs proxy_pass http://127.0.0.1:3000; and forwards the Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto headers. Run nginx -t, reload, then add TLS, an upstream block for load balancing, and WebSocket upgrade headers.
Verified against
nginx 1.30 (stable) and 1.31 (mainline), against the nginx.org proxy, upstream, real IP and WebSocket proxying docs. Checked October 2026.
A reverse proxy takes client requests on behalf of one or more backends. The client talks to Nginx; Nginx talks to your app. If the difference from a forward proxy or a dedicated load balancer is fuzzy, read proxy vs reverse proxy vs load balancer first. This is the hands-on version: config you can paste, and the errors you will see.
Why bother with a reverse proxy
TLS termination: your Node or Python app should not manage certificates. Terminate HTTPS at Nginx and speak plain HTTP to the backend on localhost.
Routing: one hostname fans out to many services by path or subdomain.
Buffering: Nginx absorbs slow clients so app workers are not tied up trickling bytes to a phone on a bad connection.
One entry point: rate limiting, access logs, gzip and caching live in one place instead of in every app.
Step 1: a basic reverse proxy server block
The minimum that gets traffic from port 80 to an app on 127.0.0.1:3000:
# /etc/nginx/conf.d/app.conf
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;
}
}
Test and reload, then confirm the app answers through Nginx:
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ sudo systemctl reload nginx
$ curl -sI -H 'Host: app.example.com' http://127.0.0.1/ | head -1
HTTP/1.1 200 OK
Those four headers are not optional in practice. Nginx's default is Host $proxy_host, so without the override the app sees 127.0.0.1:3000 as the host and generated URLs break. X-Real-IP and X-Forwarded-For carry the client address, otherwise every request appears to come from 127.0.0.1. X-Forwarded-Proto tells the backend the original request was HTTPS, which frameworks use for secure cookies and redirects.
One inheritance trap: proxy_set_header directives are inherited from the enclosing level only if the current level defines none. Add a single proxy_set_header inside a location and every header from server silently disappears there.
Step 2: multiple backends with an upstream block
# /etc/nginx/conf.d/app.conf (http context)
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;
}
least_conn sends each request to the server with the fewest active connections, which beats round-robin when request durations are uneven. max_fails defaults to 1 and fail_timeout to 10 seconds; the values above make ejection less twitchy. backup only receives traffic when the primaries are down.
Upstream keepalive changed recently. Since nginx 1.29.7, proxy_http_version defaults to 1.1 and keepalive is on by default with 32 cached connections per worker. Before 1.29.7 the default was HTTP/1.0, and you had to set proxy_http_version 1.1; and proxy_set_header Connection ""; yourself. Distro packages often lag mainline, so we keep both lines; they are harmless on new versions.
Step 3: WebSockets
WebSocket upgrades need the hop-by-hop Upgrade and Connection headers passed explicitly. Define a map in the http context:
# /etc/nginx/conf.d/app.conf (http context)
map $http_upgrade $connection_upgrade {
default upgrade;
'' "";
}
nginx's own example maps the empty case to close; we map it to an empty value so ordinary requests keep their upstream keepalive connection. Then, in the location, add proxy_set_header Upgrade $http_upgrade; and proxy_set_header Connection $connection_upgrade;. Idle WebSockets are closed after 60 seconds without data from the backend, so raise proxy_read_timeout for that location or have the server send ping frames.
Step 4: TLS, redirect, and the full config
# /etc/nginx/conf.d/app.conf
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;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
location / {
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;
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 defaults to 60 seconds; 5 seconds fails over to the next upstream faster when a backend is dead. Buffering is on by default and usually what you want, but set proxy_buffering off; on streaming or server-sent-event locations, because buffering defeats a stream.
The errors you will actually see
Every one of these appears in /var/log/nginx/error.log, which is the first place to look.
connect() failed (111: Connection refused) while connecting to upstream with a 502 to the client. Nothing listens on that port. Check the app is up and bound to the address in proxy_pass (127.0.0.1 versus 0.0.0.0 versus a container IP).
upstream timed out (110: Connection timed out) while reading response header from upstream with a 504. The app took longer than proxy_read_timeout. Raise it for that location only, or fix the slow endpoint. The same 502/504 split inside Kubernetes is covered in fixing Ingress 502 and 504 errors.
upstream sent too big header while reading response header from upstream. Large cookies or auth headers overflow proxy_buffer_size (one memory page, 4K or 8K). Set proxy_buffer_size 16k;.
(13: Permission denied) while connecting to upstream on RHEL-family hosts. SELinux blocks Nginx from making network connections: sudo setsebool -P httpd_can_network_connect 1.
nginx: [emerg] unknown "connection_upgrade" variable. The map block is missing or sits outside the http context.
the "listen ... http2" directive is deprecated. Old configs use listen 443 ssl http2;. Use http2 on; on its own line.
Common mistakes that do not log an error
Trailing slash in proxy_pass. With a URI (proxy_pass http://backend/;), the part of the request matching the location is replaced, so location /api/ plus proxy_pass http://backend/; sends /api/users as /users. Without a URI, the path passes through unchanged. Pick deliberately and test the paths your app receives.
Trusting X-Forwarded-For blindly. Clients can set it. Behind another proxy or load balancer, use set_real_ip_from with that proxy's range and real_ip_header X-Forwarded-For; so the client IP is only replaced from trusted hops.
TLS at Nginx but no X-Forwarded-Proto. The app thinks it is on HTTP, redirects to HTTPS, and users loop forever.
Where this breaks
This setup is one Nginx host in front of processes it can reach directly. Inside Kubernetes, an Ingress or Gateway API controller manages this config for you, and hand-edited files get overwritten. Behind a cloud load balancer that already terminates TLS, drop the 443 block and trust only that balancer's addresses. For the TCP and DNS layer under all of this, see our networking fundamentals guide.
Questions people ask
What is the difference between proxy_pass with and without a trailing slash?
When proxy_pass includes a URI, even just /, Nginx replaces the part of the request path that matched the location with that URI. Without a URI, Nginx forwards the original request path untouched. So location /api/ with proxy_pass http://app/; turns /api/users into /users, while proxy_pass http://app; forwards /api/users as is.
Why does my Nginx reverse proxy return 502 Bad Gateway?
A 502 means Nginx could not get a valid response from the backend. Check /var/log/nginx/error.log: Connection refused means nothing is listening on the upstream address, Permission denied on RHEL usually means SELinux, and no live upstreams means every server in the upstream block was marked failed. Fix the backend first, then reload Nginx.
Do I still need proxy_http_version 1.1 for WebSockets?
Not on nginx 1.29.7 or later, where HTTP/1.1 to upstreams became the default. You still need the Upgrade and Connection headers, because they are hop-by-hop and Nginx does not forward them on its own. On older packages, set proxy_http_version 1.1; explicitly. Keeping the line costs nothing and protects you on hosts running older builds.
The call we'd make
Put Nginx in front of every app, even a single instance behind one hostname. The config is small and 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 so scaling out is a one-line change. Always set the four forwarding headers, keep proxy_http_version 1.1 for older builds, and read the error log before guessing.
Sources
Cite this article
Kiril Urbonas (2026). How to Set Up an Nginx Reverse Proxy (2026). DevOpsNess. Updated 2026-10-31. https://www.devopsness.com/blog/nginx-reverse-proxy-guide
[How to Set Up an Nginx Reverse Proxy (2026)](https://www.devopsness.com/blog/nginx-reverse-proxy-guide) (Kiril Urbonas, DevOpsNess, updated 2026-10-31)
More from Infrastructure
Explore more articles in this category
Terraform Plan Wants to Recreate Your Production Database? Read This First
The plan says -/+ next to your RDS instance and one attribute ends in "forces replacement". Why it happens, how to undo it without data loss, and the CI gate that stops it reaching apply.
How to Stop Anyone From Deleting Your Production Database
GitLab lost six hours of data to one command on the wrong host. Linear lost five hours to one TRUNCATE CASCADE in a reviewed migration. The six guardrails that make that kind of mistake hard to make and cheap to recover from.
Kubelet Won't Start After Upgrading to Kubernetes 1.35? It's cgroup v1
Since Kubernetes 1.35 the kubelet refuses to start on cgroup v1 nodes by default. How to check every node in one command, the safe migration, and the temporary override with an expiry date.
You might have missed
Evergreen posts worth revisiting.