DEV Community

Kazu
Kazu

Posted on

You've Configured a Reverse Proxy, But Can You Explain Why It Works? (Part 1)

You wrote proxy_pass http://localhost:3000;, checked it in the browser, saw it working, and called it done. But you couldn't actually explain why it works. What's happening between nginx and the app? Where is the request going? You probably couldn't answer that with any confidence.

Getting something working and understanding how it works are two different things. This article (Part 1) is about the how. Once you have that, the next question becomes which tool to use. In Part 2, I'll break down the design philosophies behind nginx, Caddy, Traefik, and HAProxy, and give you a framework for choosing the one that fits your stack.

Where the browser is actually connecting

When a browser sends a GET to https://api.example.com/users, it resolves api.example.com via DNS and opens a TCP connection to port 443 at that IP. Who's on the other end of that connection?

In a setup with a reverse proxy, it's not the app server — it's the proxy.

Browser  ──[TCP connection]──→  nginx (443)  ──[separate TCP connection]──→  App (3000)
         ←───────────────────                ←──────────────────────────────
Enter fullscreen mode Exit fullscreen mode

This is the key point. The browser and the app server are not talking directly. The proxy holds two separate connections and shuttles requests between them. That's what "split communication" means: the browser has no idea what the app server's IP address is, what port it's on, or that it even exists.

This split is also the root cause of several problems I'll get to later. The reason the browser's IP disappears from the app's perspective, and the reason the Host header gets rewritten — both trace back to this structure.

How it differs from a forward proxy

"Proxy" is an overloaded term. Forward proxy and reverse proxy are easy to mix up. Both relay traffic, but they stand in for opposite sides, and they hide opposite things.

A forward proxy acts on behalf of the client. You see it in corporate networks, where internal machines can't go directly to the internet — they route through a proxy server. The destination server sees the proxy, not the individual machines behind it. What's hidden is the client.

Internal PC  ──→  Forward proxy  ──→  External server
                                       ↑
                             Internal PC is not visible
Enter fullscreen mode Exit fullscreen mode

A reverse proxy acts on behalf of the server. The client (browser) connects to the proxy without knowing how many app servers are behind it or how they're arranged. What's hidden is the server infrastructure.

Browser  ──→  Reverse proxy  ──→  App server
                                   ↑
                         Server is not visible
Enter fullscreen mode Exit fullscreen mode

Forward proxies hide the client; reverse proxies hide the server. That distinction comes back when we look at what disappears when you add a proxy.

What happens before a request reaches the app

Between the browser sending a request and the app server receiving it, the proxy does several things. Let's trace through a concrete nginx config.

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

    location /users {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
Enter fullscreen mode Exit fullscreen mode

You've probably written something like this. Let's go through each line and why it's there.

proxy_set_header Host $host; — The browser's HTTP request includes a header like Host: api.example.com. When the proxy forwards that request to the app server, the Host header gets rewritten to localhost:3000 by default, because that's the host of the connection the proxy opened. If your app needs to know the original domain — for virtual host routing, or to construct a redirect URL — it needs api.example.com, not localhost:3000. This line passes the original value through explicitly.

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; — X-Forwarded-For (XFF) is a header that records the list of IP addresses a request has passed through. When the proxy forwards a request to the app, the connection comes from the proxy, not the browser. From the app's perspective, the remote IP is the proxy's address. To tell the app what the original client IP was, the proxy adds this header. $proxy_add_x_forwarded_for is an nginx variable that appends to an existing XFF header if one is present, or creates a new one with the connecting IP if not.

proxy_set_header X-Forwarded-Proto $scheme; — When the proxy receives HTTPS on port 443 and forwards it to the app as HTTP, the app has no way of knowing the original request came over HTTPS. X-Forwarded-Proto carries that information.

Each of those settings exists because of the connection split from the first section.

What problems a reverse proxy solves

Consider what happens if there's no proxy and the app server has to handle everything itself.

To serve over HTTPS, you need to manage a server certificate and handle TLS handshakes. A Node.js app doing that on its own looks like this:

const https = require('https');
const fs = require('fs');

const options = {
  key: fs.readFileSync('/etc/ssl/private/server.key'),
  cert: fs.readFileSync('/etc/ssl/certs/server.crt'),
};

https.createServer(options, app).listen(443);
Enter fullscreen mode Exit fullscreen mode

The app needs to know the certificate file paths. Every time you renew the certificate, you restart the app. If you're using Let's Encrypt for automatic 90-day renewals, that means the app restarts on every renewal cycle. Cipher suite selection and protocol version management live in your app code too — every time TLS 1.0/1.1 gets deprecated somewhere, you touch the app.

When nginx handles TLS termination (ending the encrypted connection with the client at the proxy), your app code becomes:

// Only needs to speak HTTP
const http = require('http');
http.createServer(app).listen(3000);
Enter fullscreen mode Exit fullscreen mode

Certificate management and TLS configuration stay in the nginx config file. The app server speaks plain HTTP and never needs to restart because a cert was renewed.

When traffic grows and a single app server can't keep up, you add more. But if browsers connect to app servers directly, there's no way to spread requests across the new instances. With a proxy, you get load balancing across multiple upstreams:

upstream api_servers {
    least_conn;  # send to the server with fewest active connections
    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
    server 10.0.0.3:3000;
}
Enter fullscreen mode Exit fullscreen mode

least_conn sends each request to whichever server currently has the fewest active connections. It distributes load more evenly than the default round-robin behavior when response times vary — slow requests don't pile up on one server.

Routing /api/ requests to the backend cluster and /static/ requests to a file server is also more naturally expressed at the proxy layer:

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

    location /api/ {
        proxy_pass http://api_servers;
    }

    location /static/ {
        proxy_pass http://file_server;
    }
}
Enter fullscreen mode Exit fullscreen mode

The routing logic stays out of your app code. Where each request goes is visible in the nginx config.

These are usually described as "reverse proxy features," but what they really are is work that doesn't belong in the app server. The proxy takes on what would otherwise be scattered across every instance, and the app gets to stay focused on what it's actually for.

What disappears when you add a proxy

After adding a reverse proxy, you look at your app's access logs and every IP is 127.0.0.1. This trips people up. I was one of them.

Before the proxy:

# Before proxy
203.0.113.45 - - [12/Aug/2026:10:00:01 +0000] "GET /users HTTP/1.1" 200 512
Enter fullscreen mode Exit fullscreen mode

After adding the proxy, without XFF headers configured:

# After proxy (no XFF config)
127.0.0.1 - - [12/Aug/2026:10:00:01 +0000] "GET /users HTTP/1.1" 200 512
Enter fullscreen mode Exit fullscreen mode

The reason is straightforward: the app's connection comes from the proxy, and if the proxy and app are on the same server, that's the loopback address. What the app sees is "the IP of whatever connected to me," not "the browser's IP."

The fix is to read the X-Forwarded-For header that nginx is attaching:

# Value available when the app reads XFF
X-Forwarded-For: 203.0.113.45
Enter fullscreen mode Exit fullscreen mode

But XFF is a header the client can freely manipulate, so trusting the wrong source means your IP can be spoofed.

If you don't notice that the log format changed after setting up the proxy, debugging gets confusing fast. If your app does any IP-based access control and isn't reading XFF, every request looks like it's coming from the same address.

Wrapping up

Everything in this article comes back to one thing: the communication is split into two separate TCP connections. Because of that split, the Host header has to be passed through explicitly, the client IP disappears, and TLS termination at the proxy becomes a viable pattern. Every line in that config exists because of this split. Which tool to use for the proxy is Part 2.

If what's in this article has stuck, you should be able to answer something like: "The proxy holds two TCP connections and splits the communication. That's why the Host header gets rewritten and the client IP disappears. TLS ends at the proxy." You can now explain what's happening between nginx and your app.

Top comments (0)