# How to Set Up an Nginx Reverse Proxy (2026)

URL: https://www.devopsness.com/blog/nginx-reverse-proxy-guide
Author: Kiril Urbonas
Published: 2026-08-07
Updated: 2026-10-31
Categories: Infrastructure
Tags: Nginx, Reverse Proxy, Networking, Web Server, Infrastructure

Summary: A practical, step-by-step guide to putting Nginx in front of your app for TLS, routing, and load balancing.

---
# How to Set Up an Nginx Reverse Proxy (2026)

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]
> 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](/blog/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:

```
$ 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](/blog/fix-ingress-502-504).

**`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](/blog/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

- [nginx docs: ngx_http_proxy_module](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
- [nginx docs: ngx_http_upstream_module](https://nginx.org/en/docs/http/ngx_http_upstream_module.html)
- [nginx docs: WebSocket proxying](https://nginx.org/en/docs/http/websocket.html)
- [nginx docs: ngx_http_realip_module](https://nginx.org/en/docs/http/ngx_http_realip_module.html)

