Behind a reverse proxy: the real IP of your users
Behind Traefik, nginx, a Kubernetes ingress or Cloudflare, Apache and PHP see the address of the proxy,
not the one of your users. List your proxies in APACHE_TRUSTED_PROXIES, and set
APACHE_TRUSTED_CLOUDFLARE=1 behind Cloudflare: the container finds the real IP by itself, and checks
every hop instead of trusting the configuration of the proxies. Describe your setup below to
get the recommended configuration.
1Why it matters
All your users have the IP of the proxy
- The logs, the statistics and the audit trails show a single address.
- A rate limiter or a login throttling blocks all your users at once, or nobody.
- An IP allowlist of a back-office (
Require ip, firewall of the application) lets everybody in: every request comes from the proxy, often from a private address.
Anyone chooses their IP
X-Forwarded-For and CF-Connecting-IP are request headers: any client writes the
value it wants.
curl -H 'X-Forwarded-For: 10.0.0.1' https://example.com/admin/
- The request passes an allowlist of private addresses, or bypasses a rate limiter with a new address each time, or writes the address of somebody else in your logs.
- Common causes: trusting every proxy (
TRUSTED_PROXIES=REMOTE_ADDR,'*'), orCF-Connecting-IPwhile the server can be reached without Cloudflare.
2Your setup
3How a request goes through
4Configuration
5Check it
Add a temporary ip.php file to your application, then call it from your computer:
<?php echo $_SERVER['REMOTE_ADDR'], ' ', $_SERVER['HTTPS'] ?? 'off';
The access log of Apache (docker compose logs app, see Logs) shows
the same addresses. Remove ip.php afterwards.
6What the container does
It checks every hop
Each proxy adds the address it received the request from at the right of X-Forwarded-For.
Apache (mod_remoteip) reads the header from the right, from the address of the connection: it
skips the hops added by a trusted proxy, and stops at the first address that is not a trusted proxy: the client.
What a client writes at the left of the header is never reached.
Only the proxies you list are trusted: not the private networks, not the other containers of an ingress network shared by several projects.
It resolves the names of the proxies
A name (traefik) is resolved by the container at startup, then every 30 seconds: a restarted proxy
with a new IP is trusted again within 30 seconds. A name that does not resolve is reported in the logs, and its
requests keep the IP of the proxy in the meantime.
It keeps the Cloudflare ranges up to date
APACHE_TRUSTED_CLOUDFLARE=1 trusts the ranges
published by Cloudflare: downloaded at startup and every day, Apache being reloaded gracefully when they
change. Without internet access, the copy shipped in the image is used.
CF-Connecting-IP is only used when the request reached the proxy from a Cloudflare address, even
when the proxy replaced X-Forwarded-For (default of Traefik and ingress-nginx): a request sent
to the server without Cloudflare cannot use it.
It fixes HTTPS too
A request of a trusted proxy with X-Forwarded-Proto: https gets HTTPS=on: PHP
generates https:// URLs and secure cookies, as if the container served HTTPS itself.
The access log of Apache, Require ip in a .htaccess and REMOTE_ADDR in
PHP all see the client IP.
7Why not the trusted proxies of Symfony or Laravel
- They only fix PHP: the access log of Apache,
Require ipand the other Apache rules keep the IP of the proxy. - Their usual configuration trusts too much:
TRUSTED_PROXIES=REMOTE_ADDR(Symfony) ortrustProxies(at: '*')(Laravel) trusts whoever connects. It is safe only as long as the container can never be reached without the proxy: one published port or one shared network, and anyone chooses their IP. Listing the real proxies instead means following the IPs of the containers and the ranges of Cloudflare, in each application. - Each application must do it, and WordPress or an older application has no such setting.
With APACHE_TRUSTED_PROXIES, REMOTE_ADDR and HTTPS are already right:
leave the trusted proxies of the framework empty. Keeping both would be worse than either one: the framework
would take the client for a proxy and read the rest of X-Forwarded-For, written by the client.
8Logs
The access log and the error log of Apache show the client IP found by the container. Four variables set them, chosen in Your setup:
| Variable | Values |
|---|---|
APACHE_LOG_FORMAT | combined (default), detailed (combined +
response time in µs + address of the proxy), json, or your own
format, written as is:
%a %t "%r" %>s %Dus |
APACHE_LOG_OUTPUT | stdout (default): the access log on the standard output,
the error log on the standard error. file: files of /var/log/apache2 |
APACHE_LOG_RETENTION_DAYS | With file: days kept, 14 by
default |
APACHE_LOG_MAX_SIZE | With file: a new file when it reaches this size
(100M, 1G), no limit by default |
stdout: Docker keeps them
docker compose logs -f app # everything docker logs app 2>/dev/null # the access log only kubectl logs -f deploy/app # on Kubernetes
A log collector (Loki, Elastic, Datadog...) reads them like the logs of any container. Docker rotates them, but
its default driver, json-file, keeps everything: limit it per service, or for every container in
/etc/docker/daemon.json ("log-opts"):
logging:
driver: local # compressed, 5 files of 20 MB by default
options:
max-size: 50m
max-file: "5"
On Kubernetes, the kubelet rotates them (containerLogMaxSize, 10 MB by default).
file: in a directory of the server or a volume
/var/log/apache2/access.2026-11-13.log /var/log/apache2/access.2026-11-12.log.gz /var/log/apache2/error.2026-11-13.log
One file per day, rotated at midnight UTC by rotatelogs without restarting Apache, the previous
files gzipped, deleted after APACHE_LOG_RETENTION_DAYS days. With
APACHE_LOG_MAX_SIZE, a new file is also started at this size, and the names get the time
(access.2026-11-13.143002.log). Mount a directory of the server (./logs:/var/log/apache2)
or a volume on /var/log/apache2: without it, the logs are lost with the container.
docker compose exec app tail -f /var/log/apache2/access.$(date -u +%F).log docker compose exec app zgrep ' 500 ' /var/log/apache2/access.*.log.gz
With Cloudflare, the logs of Traefik or nginx show the addresses of Cloudflare: the logs of the container are the reference.
9Fine-tuning and custom setups
APACHE_TRUSTED_PROXIES lists the proxies whose X-Forwarded-For hop is trusted, separated
by spaces or commas. APACHE_TRUSTED_CLOUDFLARE=1 adds Cloudflare:
| Value | Trusts |
|---|---|
APACHE_TRUSTED_PROXIES=traefik | A host name: all the addresses it resolves to (all the replicas of a service), resolved again every 30 seconds |
APACHE_TRUSTED_PROXIES=172.18.0.5 10.0.0.0/16 | IPs or CIDR ranges (IPv4 or IPv6), like the network of a load balancer |
APACHE_TRUSTED_CLOUDFLARE=1 | The ranges of Cloudflare, updated every day, and its
CF-Connecting-IP header |
APACHE_TRUSTED_CLOUDFLARE=only | The same, and a 403 for the requests of the proxies
that did not go through Cloudflare (every request but localhost without proxy) |
The configuration they generate is a good starting point:
docker compose exec app cat /etc/apache2/conf-enabled/docker-trusted-proxies.conf
Your own mod_remoteip configuration
These variables are a shortcut, not a replacement for mod_remoteip. For another header, the PROXY protocol or a list of proxies managed elsewhere, leave them empty, enable the modules and mount your own file:
services:
app:
environment:
APACHE_EXTENSIONS: remoteip headers
volumes:
- ./remoteip.conf:/etc/apache2/conf-enabled/remoteip.conf:ro
# remoteip.conf # # The header that carries the client IP. X-Forwarded-For is a list ("client, proxy 1, proxy 2"): Apache reads it from # the right and stops at the first address that is not a trusted proxy. A header with a single address (X-Real-IP, # True-Client-IP) is only used when the request comes from a trusted proxy. RemoteIPHeader X-Real-IP # Proxies of your network: IPs, CIDR ranges, or host names (resolved once, when Apache starts: prefer the variables # above for containers whose IP changes) RemoteIPInternalProxy 10.0.0.0/16 # Proxies on the internet (a CDN): same, but a private address they forward is not accepted as the client IP RemoteIPTrustedProxy 203.0.113.0/24 # Or a file of addresses, one per line, that you keep up to date (reloaded with apache2ctl graceful) #RemoteIPTrustedProxyList /etc/apache2/cdn-ips.txt # PROXY protocol (HAProxy, AWS NLB): the address comes from the connection itself, no header is used. Every # connection must then use it, except the ones of the listed addresses (the health checks of Docker for instance). #RemoteIPProxyProtocol On #RemoteIPProxyProtocolExceptions 127.0.0.1 # HTTPS=on for PHP, only when the request comes from the proxy: a SetEnvIf on X-Forwarded-Proto alone would let any # client set it <If "%{HTTP:X-Forwarded-Proto} == 'https' && %{CONN_REMOTE_ADDR} -ipmatch '10.0.0.0/16'"> SetEnv HTTPS on </If>
Check it with docker compose exec app apache2ctl configtest, then with ip.php
(Check it). headers is only needed for RequestHeader directives.
The fpm variant behind your own nginx
Without its built-in Apache, PHP-FPM receives the client IP from nginx: use its
real IP module
(set_real_ip_from with the address of the proxy in front of it, real_ip_header
X-Forwarded-For, real_ip_recursive on).