Skip to main content
RunBook Academy

Docker & ContainersXXIV Β· Reverse Proxies & TLSClient identity

Client IP through a proxy β€” X-Forwarded-For and the trust boundary

Advanced⏱ ~20 min

What you'll learn

  • Explain why a containerised application sees the proxy address instead of the client
  • Parse an X-Forwarded-For chain from the right, not the left
  • Configure a trusted-proxy set in nginx, Traefik, HAProxy and Caddy
  • Choose between forwarded headers and the PROXY protocol

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11

Not yet marked complete on this device.

Put a reverse proxy in front of a container and you have solved TLS, routing and load balancing. You have also, quietly, broken every feature that depends on knowing who the client is.

The symptom

The application’s access log stops being useful:

172.18.0.4 - - [11/Aug/2026:09:14:02 +0000] "GET /api/orders HTTP/1.1" 200 1841
172.18.0.4 - - [11/Aug/2026:09:14:02 +0000] "POST /api/login HTTP/1.1" 401 62
172.18.0.4 - - [11/Aug/2026:09:14:03 +0000] "POST /api/login HTTP/1.1" 401 62

Every request in the world now comes from 172.18.0.4, which is the proxy container’s address on the Docker bridge network. This is not a bug. TCP has one source address, the application’s connection came from the proxy, and so 172.18.0.4 is the honest answer to the question the application asked.

What breaks as a result:

  • Rate limiting counts all clients as one, so the first busy user locks out everybody.
  • IP allowlists admit everyone or no one.
  • Audit logs record the proxy for every action, which makes them useless in exactly the investigation they exist for.
  • Geolocation and fraud scoring see a datacentre address.
  • Abuse blocking cannot block anything without blocking all of it.

The forwarded headers

The proxy knows the client address, so it records it in a header before passing the request on.

X-Forwarded-For is the de-facto standard: a comma-separated list to which each proxy appends the address it received the request from.

X-Forwarded-For: 203.0.113.9, 198.51.100.7, 192.0.2.30
                 ^ claimed     ^ first       ^ second
                   client        proxy         proxy

It usually travels with two companions, because the application needs to know how the client reached the edge as well as from where:

  • X-Forwarded-Proto β€” http or https. Without it, an application behind a TLS-terminating proxy generates http:// redirect URLs and users bounce out of HTTPS.
  • X-Forwarded-Host β€” the hostname the client asked for, which is not the upstream hostname the proxy dialled.

RFC 7239 standardised all three into one header:

Forwarded: for=203.0.113.9;proto=https;host=shop.example.com

It is correct, it is specified, and adoption is patchy enough that most stacks still emit and consume the X- forms. Support both when you can; do not assume Forwarded is present.

The security hole

The consequences are not subtle:

Read-only / Safeclaiming to be an internal address
$ curl -s -H 'X-Forwarded-For: 10.0.0.5' https://shop.example.com/admin/metrics -o /dev/null -w '%{http_code}\n'
200

Illustrative output

If that returns 200 and the same request without the header returns 403, the IP allowlist is decorative. The same trick defeats rate limiting β€” send a different forged address on every request and each one gets its own bucket β€” and poisons every audit record the application writes.

Reading the chain correctly

The rule is short and almost nobody follows it: count from the right, and only trust the hops you control.

The rightmost entry was appended by your own proxy and is therefore the only value in the header you have any reason to believe. Each entry to its left was appended by whatever came before, and beyond your own infrastructure that is the client.

X-Forwarded-For: 203.0.113.9, 198.51.100.7, 192.0.2.30
                                            ^^^^^^^^^^ appended by your edge:
                                                       trustworthy
                              ^^^^^^^^^^^^ appended by your CDN, if you have one
                 ^^^^^^^^^^^^ appended by nothing you control: the client's claim

So the algorithm is: starting from the right, discard entries while they belong to your set of trusted proxies. The first entry that is not a trusted proxy is the real client address. If you have one proxy and no CDN, that is simply β€œtake the rightmost entry” β€” and the leftmost value in the example above is a forgery you must ignore.

Configuring the trusted set

Every proxy expresses the same idea: here is the list of addresses whose forwarded headers I believe.

nginx, via the realip module, which rewrites $remote_addr so that the rest of your configuration β€” logging, allow/deny, limit_req β€” works on the true client without further changes:

# The addresses of the hops in front of this nginx.
set_real_ip_from 192.0.2.30;        # our own edge
set_real_ip_from 198.51.100.0/24;   # our CDN egress range

real_ip_header    X-Forwarded-For;
# Walk right-to-left, skipping trusted addresses, instead of taking
# the last entry unconditionally.
real_ip_recursive on;

Traefik, on the entry point:

entryPoints:
  websecure:
    address: ':443'
    forwardedHeaders:
      trustedIPs:
        - 192.0.2.30/32
        - 198.51.100.0/24

Caddy, as a global server option:

{
  servers {
    trusted_proxies static 192.0.2.30/32 198.51.100.0/24
  }
}

HAProxy, where option forwardfor appends the source address. At an edge that faces untrusted clients, overwrite rather than append so a forged value cannot survive:

frontend https-in
    bind :443 ssl crt /etc/haproxy/certs/example.com.pem
    http-request set-header X-Forwarded-For %[src]
    http-request set-header X-Forwarded-Proto https
    default_backend app

Two things Docker adds to this

The second Docker-specific point is that your trusted set contains container addresses, and container addresses move. A proxy on a user-defined bridge gets an address from the subnet Docker allocated, and recreating the network can allocate a different one. Trust the subnet, not the address:

set_real_ip_from 172.18.0.0/16;

…but only if that network carries nothing except your own proxy. Trusting a subnet that any container can join means any container can forge a client address.

Verifying it works

  1. Log both values at the application: the connection source and the raw forwarded header. You cannot debug this with only one.
  2. Make a request from a machine whose public address you know, and confirm the application logs that address.
  3. Make the same request with a forged X-Forwarded-For header and confirm the application logs the real address, not the forged one. This is the test that matters and it is the one people skip.
  4. Repeat the forged request against any IP allowlist or rate limit you rely on.

When to use the PROXY protocol instead

The PROXY protocol takes a different approach: instead of an HTTP header, the upstream sends a short preamble on the TCP connection itself, before any application bytes, stating the original source and destination.

Its advantages are real. It works for any protocol, not just HTTP β€” which matters for a TLS connection you want to pass through un-terminated, or for a database proxy. And a normal HTTP client cannot forge it, because it is not part of HTTP.

Its trap is the exact mirror of the header problem:

Use forwarded headers when the proxy terminates HTTP, which is most of the time. Use the PROXY protocol when the connection is passed through at L4 and there is no HTTP layer at which to add a header β€” and lock the listener down when you do.

Knowledge check

Knowledge check Β· 4 questions

  1. Q1. An application behind one reverse proxy reads the leftmost value of `X-Forwarded-For` as the client address. What is the consequence?

  2. Q2. Which proxy should overwrite `X-Forwarded-For` rather than append to it?

  3. Q3. On a Docker host, in which situations does the application NOT see the real external client address? Select all that apply.

  4. Q4. Because the PROXY protocol cannot be forged by an HTTP client, a listener that accepts it is safe to expose publicly.

Passing score: 75%. Answers are checked in this browser.

Where next

The final lesson in this part covers the other thing a proxy changes about a request: the protocol version it speaks on each side, and what happens to a WebSocket in between.