Docker & ContainersXXIV Β· Reverse Proxies & TLSClient identity
Client IP through a proxy β X-Forwarded-For and the trust boundary
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
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βhttporhttps. Without it, an application behind a TLS-terminating proxy generateshttp://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:
$ curl -s -H 'X-Forwarded-For: 10.0.0.5' https://shop.example.com/admin/metrics -o /dev/null -w '%{http_code}\n'200Illustrative 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
- Log both values at the application: the connection source and the raw forwarded header. You cannot debug this with only one.
- Make a request from a machine whose public address you know, and confirm the application logs that address.
- Make the same request with a forged
X-Forwarded-Forheader 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. - 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
Q1. An application behind one reverse proxy reads the leftmost value of `X-Forwarded-For` as the client address. What is the consequence?
Q2. Which proxy should overwrite `X-Forwarded-For` rather than append to it?
Q3. On a Docker host, in which situations does the application NOT see the real external client address? Select all that apply.
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.