Tutorials

Setting Up Nginx as a Reverse Proxy

A complete Nginx configuration example for a reverse proxy, including X-Forwarded headers, timeout settings, response buffering, and the errors you actually see in production.

Tutorials

The site comes up, but all users are logged with a single IP in the application log, or heavy pages fail after 60 seconds with 504 Gateway Timeout. If a Node.js application, a Python backend, or another server sits behind Nginx, these two symptoms almost always share the same root cause: an incompletely configured reverse proxy. In this article, we'll write the block that works in production, and mark the places where people make mistakes.

The basic reverse proxy block that actually works

Suppose the application listens on 127.0.0.1:3000 and Nginx needs to pass traffic for example.com to it. The minimal configuration that is both correct and maintainable:

upstream app_backend {
    server 127.0.0.1:3000;
    keepalive 32;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    location / {
        proxy_pass http://app_backend;
        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_set_header Connection        "";
    }
}

If you remove three lines from this block, the problem comes back. proxy_http_version 1.1 — without it, Nginx connects to the backend with HTTP/1.0 and keepalive in the upstream becomes effectively useless. The Connection "" line prevents sending the Connection: close header; if you leave it, every request creates a fresh TCP connection and under high load, TIME_WAIT ports accumulate. And X-Forwarded-Proto is what the application uses to understand whether the user was on HTTPS or not; without it, internal redirects go back to http:// and create a redirect loop.

Why X-Forwarded-For isn't enough

The X-Forwarded-For header is a list, not a single IP. When Cloudflare or another load balancer sits in front of Nginx, the value of this header becomes something like 203.0.113.5, 198.51.100.7, and the application needs to know which one is valid. If your code just takes split(",")[0], anyone can spoof their IP by sending a fake header. The correct way is to define the range of trusted IPs:

set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
real_ip_header CF-Connecting-IP;

After this, $remote_addr is itself the user's real IP and the application doesn't need to parse anything. If you don't add this block and at the same time trust the header, your security logs become worthless and rate limiting is applied to the wrong IP.

Timeouts: the number that must be chosen deliberately

The default value of proxy_read_timeout in Nginx is sixty seconds. For a typical API, this number is generous; for a report generator working on a heavy database, it's too low. For an endpoint that responds with stream, it's meaningless. There are three separate timeouts, and people usually only see the first one:

DirectiveDefaultWhere it hurts
proxy_connect_timeout60sBackend isn't up or the port is wrong
proxy_send_timeout60sLarge upload or slow backend reading the body
proxy_read_timeout60sLong response, heavy report, job queue

For a typical web application, I set proxy_connect_timeout 5s and proxy_read_timeout to 120 seconds. Keep the connection number short because if the backend is dead, the user shouldn't wait five minutes to find out the site is broken. But make the read timeout long, because cutting off a half-finished request is usually worse than waiting.

One practical note: if you increased the timeout in Nginx but not in the front load balancer, the same 504 comes back and you go hunting for a bug in the application. Arrange timeouts in descending order from outside to inside.

Response buffering and where people get it wrong

By default, Nginx buffers the backend response in memory or on disk. This is great for static files and small responses: the backend is freed up quickly and a slow client doesn't occupy your server. But for Server-Sent Events, WebSocket, or any streaming response, buffering means the user sees nothing until the entire response is finished.

Here's where they get it wrong: for WebSocket, they only add proxy_set_header Upgrade $http_upgrade and forget proxy_buffering off. The result is that the connection is established, the logs are healthy, but messages arrive with strange delays or don't arrive at all. The complete WebSocket configuration is:

location /ws/ {
    proxy_pass http://app_backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_buffering off;
    proxy_read_timeout 3600s;
}

Note that Connection here is "upgrade", not an empty string. If you copy both blocks into one file and put Connection "" for the /ws/ path, the handshake fails and you see the WebSocket connection failed error in the browser console.

Disk buffering and filling up /var

When the response is larger than the buffer memory, Nginx writes it to disk at the proxy_temp_path location. The default is /var/lib/nginx/proxy. If someone downloads large files through the proxy and you haven't limited proxy_max_temp_file_size, the /var partition fills up, and after that you can't write logs or sessions. The sign is that everything is fine up to a point and then suddenly you get 500 with no application error at all.

The Host header and wrong redirects

If you don't set proxy_set_header Host $host, Nginx keeps the Host value from the original request, which is correct in most cases, but when the upstream has an internal name, the backend may redirect to that internal name and the user is sent to an unreachable address. If the application is served on multiple domains, $host is correct; if a specific domain must always be seen, set a fixed value.

For domains whose DNS hasn't fully propagated yet, first of all make sure the correct record is chosen; the difference between CNAME and A record shows itself right here, and if it's wrong, even a healthy Nginx serves the wrong site.

Testing before the user tells you

Before reloading, always run nginx -t. Then with curl -I -H "Host: example.com" https://127.0.0.1/ check the response from the server itself to make sure the problem isn't DNS or CDN. To see the headers that actually go to the backend, create a temporary endpoint in the backend that prints the headers; this is faster than guessing.

If you get a 502 Bad Gateway error, first check with ss -ltnp | grep 3000 whether the backend is listening on that port. If it's 504, check the timeouts. If you see 499 in the Nginx log, it means the user themselves closed the connection and the problem is elsewhere. We've covered the exact meaning of these codes in the HTTP status code guide.

One trade-off I should state explicitly: buffering the response increases throughput and reduces backend memory usage, but increases time-to-first-byte for small responses. If your application is an API with responses of a few kilobytes, leave buffering on. If you have streaming or SSE, turn it off and accept the cost.

For WordPress projects, you usually don't need to write this block from scratch; a managed WordPress hosting has already configured this layer and you just need to make sure the cache and timeouts don't conflict with your plugins. If you're working on your own dedicated server, there are more ready-made examples in the documentation and knowledge base.

Frequently asked questions

Why are all users logged with the server IP in the application log?

Because the application reads the IP from the TCP connection, and that connection comes from Nginx. You need to set the X-Forwarded-For header in Nginx and configure the application to trust it. If there's another proxy in front of Nginx too, use set_real_ip_from so that $remote_addr itself is correct.

What's the difference between proxy_pass with and without a trailing slash?

In proxy_pass http://backend/, the location path is replaced with a slash, but in proxy_pass http://backend, the entire original URI goes to the backend untouched. If your application expects a subpath and you set this wrong, all requests hit 404 without anything appearing in the Nginx log.

Should I turn off proxy_buffering for everything?

No. Turning it off globally makes the backend vulnerable to slow clients and increases memory usage. Only turn it off for paths that are truly streaming and leave the rest with default buffering.

What needs to be done after changing the config?

First nginx -t, then systemctl reload nginx. Don't use restart unless necessary, because it closes active connections. If the site is on multiple servers behind a load balancer, do the reload one by one so the service isn't interrupted.

ServerNet Support

ServerNet engineering & editorial team — specialists in infrastructure, networking and web hosting.

WordPress Hosting
Share:

Comments 0

No comments yet — be the first!

Leave a comment

Related service

WordPress Hosting

A purpose-built WordPress stack on LiteSpeed Enterprise and NVMe — auto-install, secure updates, staging and caching that keeps you on top of Google.