thecodingmachine/php v5 documentation GitHub

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

Doing nothing

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.
Trusting the header blindly

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, '*'), or CF-Connecting-IP while the server can be reached without Cloudflare.

2Your setup

Reverse proxy
Cloudflare in front
A DNS-only record (grey cloud) does not go through Cloudflare: answer No.
Where the proxy runs
Options (the container stays zero trust either way)

Logs of Apache
Format
Stored
Rotation
A new file every day at midnight UTC, and when a file reaches the max size (100M, 1G...). The previous files are gzipped, and deleted after the days kept.
Example line

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 ip and the other Apache rules keep the IP of the proxy.
    • Their usual configuration trusts too much: TRUSTED_PROXIES=REMOTE_ADDR (Symfony) or trustProxies(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:

    VariableValues
    APACHE_LOG_FORMATcombined (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_OUTPUTstdout (default): the access log on the standard output, the error log on the standard error. file: files of /var/log/apache2
    APACHE_LOG_RETENTION_DAYSWith file: days kept, 14 by default
    APACHE_LOG_MAX_SIZEWith 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:

    ValueTrusts
    APACHE_TRUSTED_PROXIES=traefikA 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/16IPs or CIDR ranges (IPv4 or IPv6), like the network of a load balancer
    APACHE_TRUSTED_CLOUDFLARE=1The ranges of Cloudflare, updated every day, and its CF-Connecting-IP header
    APACHE_TRUSTED_CLOUDFLARE=onlyThe 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).