Skip to content

Reverse proxying

Scope: proxy_pass — the directive most nginx configs exist for — the headers that make it honest, and what failure looks like.

One directive

location /app/ {
    proxy_pass http://127.0.0.1:9001/;
}

Whatever arrives under /app/ is forwarded to the program on port 9001, and the response relayed back. The trailing slash on both sides matters more than it looks: with it, /app/users reaches the backend as /users (the location prefix is replaced); without matching slashes the URI passes through whole, and your backend sees paths you didn't intend.

What the backend receives

The lab's backend echoes exactly what arrives — method, path, headers — so nothing here is assumed. First, the control: talking to the backend directly, with no proxy at all:

$ curl -s http://127.0.0.1:9001/
backend port: 9001
request: GET /
client address: 127.0.0.1:44326
headers received:
  Host: 127.0.0.1:9001
  User-Agent: curl/8.5.0
  Accept: */*

Now through nginx, first with proxy_pass alone:

$ curl -s http://127.0.0.1:8080/app-raw/
headers received:
  Host: 127.0.0.1:9001
  Connection: close
  User-Agent: curl/8.5.0
  Accept: */*

The backend has no idea the visitor existed: the connection came from nginx (127.0.0.1), and the Host header is the upstream's address — not the name the visitor asked for. Its own log is honest about the only address it saw:

127.0.0.1 - "GET /" xff=-

Every request the app serves looks like one very busy localhost. Logs, rate limits, IP-based rules — all see nginx. The fix is a header block inside the location (set it in the location itself; remember chapter 3's wholesale-replacement rule):

location /app/ {
    proxy_pass http://127.0.0.1:9001/;
    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;
}
$ curl -s http://127.0.0.1:8080/app/
headers received:
  Host: 127.0.0.1
  X-Real-IP: 127.0.0.1
  X-Forwarded-For: 127.0.0.1
  X-Forwarded-Proto: http
  ...
127.0.0.1 - "GET /" xff=127.0.0.1
  • Host — the name the visitor used ($host; note nginx drops the port here — 127.0.0.1, not 127.0.0.1:8080). Without it, the app builds wrong links and redirects.
  • X-Forwarded-For — the visitor's IP, appended through each proxy hop ($proxy_add_x_forwarded_for keeps any existing chain instead of overwriting it).
  • X-Real-IP — the same address, one step, for simple apps.
  • X-Forwarded-Proto — http or https as the visitor saw it, so the app can answer "you arrived at https://…" even though the nginx→app hop is plain http on localhost.

This is a convention, not a standard: the app must trust these headers. Most frameworks have an explicit setting for it ("trust proxy" in most of them), and it is off by default — deliberately, because a header is just text, and trusting text from strangers is how apps get fooled. Point nginx at an app, see wrong URLs or useless IPs in its logs, and you know exactly which setting to look for.

When the app is down: 502

Stop the backend — or point at a port where nothing listens — and the visitor gets:

$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/dead/
502

502 is nginx saying "I'm fine; the app isn't answering." It assigns blame correctly, and the error log names the cause in full:

[error] ... connect() failed (111: Connection refused) while connecting to upstream, client: 127.0.0.1, server: localhost, request: "GET /dead/ HTTP/1.1", upstream: "http://127.0.0.1:9999/", host: "127.0.0.1:8080"

connect() failed (111: Connection refused) while connecting to upstream, naming the upstream address. A 502 is never "your nginx config is wrong" — it means the config is right and the thing behind it needs attention. (A 404 or a garbled page means the app answered — then nginx is innocent.) Chapter 13 walks the common failures one by one.

Check: proxy an app you run, hit it, and read one access line for the proxy and one line from the app's own log. The two lines should be telling the same story — and now you know why they don't unless the headers are set.