Skip to content

Setting up a Reverse Proxy with Nginx

The reverse proxy article described the entire arrangement from the client's point of view: connect to one address, get an answer, never learn which backend actually produced it. This lab builds the arrangement itself — a real backend, a real Nginx reverse proxy in front of it, and a direct comparison of what the backend sees with and without the header that article covered.

Goal and end result

By the end of this lab, you'll have a backend application reachable only through an Nginx reverse proxy, and you'll have confirmed two things directly: that the client genuinely never talks to the backend, and exactly what the backend sees arrive in its request headers once X-Forwarded-For is added.

Topology

Client ──▶ Nginx reverse proxy (port 80) ──▶ Backend application (port 8080, not exposed to the client)

Requirements

  • Two hosts or containers that can reach each other — the virtual home network lab's Docker setup works directly.
  • Nginx on one host; any small HTTP application that echoes back its own request headers on the other — this lab uses a minimal Flask app for exactly that reason, since it needs to show what it actually received, not just answer with a fixed page.

Safety note

This lab's backend deliberately binds only to the internal network between the two containers, never to a public interface — the entire point of the exercise is that the client only ever reaches the proxy, so the backend being genuinely unreachable any other way is the setup working correctly, not an oversight to fix.

Step 1: a backend that shows its own request headers

# app.py
from flask import Flask, request

app = Flask(__name__)

@app.route("/")
def index():
    return (
        f"X-Forwarded-For: {request.headers.get('X-Forwarded-For')}\n"
        f"X-Real-IP: {request.headers.get('X-Real-IP')}\n"
        f"Host: {request.headers.get('Host')}\n"
    )

app.run(host="0.0.0.0", port=8080)
pip install flask
python3 app.py

Deliberately binding to 0.0.0.0 here is safe specifically because this backend only exists on an internal network with no public interface at all in this lab's setup — the same binding warning this course already raised still applies in full on any host that does have a public interface.

Step 2: confirm the backend works directly, before adding the proxy

curl http://<backend-host>:8080/
X-Forwarded-For: None
X-Real-IP: None
Host: <backend-host>:8080

Both forwarded-header fields read None — nothing has set them yet, since this request went straight to the backend with nothing in between. Keep this output for comparison; it's the baseline the rest of this lab measures against.

Step 3: configure Nginx as a reverse proxy

# /etc/nginx/conf.d/default.conf
server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://<backend-host>:8080;
        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;
    }
}
nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

nginx -t validates the configuration syntax without applying it — worth running before every reload, since a syntax error in a config already in production would otherwise only surface once the reload itself fails. proxy_pass is the directive doing the actual work: every request Nginx receives on / gets forwarded to the backend address given, with Nginx opening its own separate connection to make that hop — precisely the two-hop relay the reverse proxy article already described. The three proxy_set_header lines are what actually attach the forwarding information to the outgoing request; without them, proxy_pass alone would forward the request but leave the backend with no way to recover the original client's address or the hostname it was actually asked for.

nginx -s reload

Step 4: confirm the client never reaches the backend directly

curl -v http://<proxy-host>/ 2>&1 | grep -i "^< server"
< Server: nginx/1.31.3

The Server header identifies Nginx, not Flask's own development server — confirmation that whatever answered this request is the proxy, even though the response body itself, checked next, comes from the backend:

curl http://<proxy-host>/
X-Forwarded-For: None
X-Real-IP: None
Host: <proxy-host>

Running this from the proxy host's own machine shows None again, for a subtler reason than step 2's: curl run from localhost against the proxy has a source address of 127.0.0.1, and depending on the exact test setup that can look identical to "nothing set the header." Repeat the request from a genuinely separate third machine or container to see the real behavior:

curl http://<proxy-host>/
X-Forwarded-For: 192.168.100.4
X-Real-IP: 192.168.100.4
Host: 192.168.100.3

Now both forwarding headers carry the actual client's address — 192.168.100.4 here, the genuinely separate machine that sent this request — while Host shows 192.168.100.3, the proxy's own address the client actually connected to, not the backend's. This is the entire mechanism the reverse proxy article already explained, now visible as two different, concrete dig-free before-and-after outputs from the same backend code.

Practical scenario: a health check that always reports the proxy, never the client

A team adds logging to the backend for every incoming request, intending to track which client IPs are hitting a specific expensive endpoint most often — and every single log line shows the same address: the reverse proxy's own.

@app.before_request
def log_request():
    app.logger.info(f"Request from {request.remote_addr}")
INFO:app:Request from 192.168.100.3
INFO:app:Request from 192.168.100.3
INFO:app:Request from 192.168.100.3

Every line shows 192.168.100.3 — the proxy — because request.remote_addr reads the actual TCP connection's source address, and the actual TCP connection reaching this backend is the proxy's, not any real client's, exactly as the reverse proxy article already explained. The fix is reading X-Forwarded-For instead of remote_addr for logging purposes specifically — but only because this lab's proxy is trusted infrastructure the team controls; an application that blindly trusts an X-Forwarded-For header from any client reaching it directly (rather than only from a known, trusted proxy in front of it) opens itself to a client simply lying about its own address in that header, since nothing about the header itself is cryptographically verified.

Troubleshooting

  • nginx -t fails with a syntax error. Read the specific line number in the error message — a missing semicolon or unmatched brace is by far the most common cause, and the error message names the exact file and line.
  • The proxy returns 502 Bad Gateway. This means Nginx itself is working, but couldn't reach the backend at all — confirm the backend is actually running and listening on the address and port proxy_pass names, using curl directly against the backend as step 2 did.
  • X-Forwarded-For still shows None even from a genuinely separate client. Confirm the proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; line is actually present and that nginx -s reload ran after adding it — a config file edited but never reloaded has no effect on already-running Nginx.

Cleanup

# on the backend host
# stop the Flask process with Ctrl-C
rm /etc/nginx/conf.d/default.conf
nginx -s reload

Evaluation criterion

You've completed this lab correctly if you can show, from your own terminal output, all three: the backend's direct response with no forwarding headers set, the proxy's response showing the nginx Server header rather than the backend's, and a request from a genuinely separate client machine showing that client's real address in X-Forwarded-For.

Every lab in this module so far tested one connection, one request, or one small exchange at a time. The next lab measures something this course has discussed only in numbers so far — actual throughput and how TCP and UDP behave differently under real, sustained load.

Sources