Skip to main content
How to Set Up an Nginx Reverse Proxy (2026)

How to Set Up an Nginx Reverse Proxy (2026)

KU
• 8 min read•Updated •59 views

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.

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:

Nginx
# /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:

code
$ 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

Nginx
# /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:

Nginx
# /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

Nginx
# /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

Explore topics:Infrastructure
React
Share this post
Cite this article
Plain text

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

Markdown

[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)

KU

Kiril Urbonas

AI Engineer

585 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.