Skip to content

Real client IP behind a proxy

GeoMetrikks reads the address your reverse proxy puts in its access log. If that address belongs to a CDN edge, a tunnel process, or another proxy in front of yours, the map and analytics key on the wrong address. CDN traffic pins to the CDN’s datacenters. A private address (10.x, 192.168.x, 127.x and similar ranges) gets no location at all. Every proxy in front of nginx or Traefik needs to be told to log the visitor, not itself. This guide covers the common setups.

Settings > Status raises one of two advisories when a tailed log’s most recent traffic looks wrong:

  • CDN peer. Most requests on a server come from a known CDN’s ASN. The summary names the server, the provider and the share, for example “94% of the last 2,000 requests on server edge-01 came from Cloudflare addresses.” The map still fills in, just with the CDN’s edge locations instead of your visitors’.
  • Private peer. Most requests come from a private address. The summary names the share; the effect is not a wrong location but no location: rows with a private peer are dropped before geolocation, so they never reach the map or Access logs, even though Settings > Status shows the parser’s line count climbing.

Both advisories carry a remedy line naming the proxy directive to add and a link back to this document. APP_PROXY_ADVISORY=false turns the checks off.

nginx needs the ngx_http_realip_module (built in on nearly every packaged nginx, including the images this project’s compose files use). Three directives, all in the server or http block that terminates the connection from the untrusted peer:

# One set_real_ip_from per proxy range in front of this nginx.
# Cloudflare's ranges: https://www.cloudflare.com/ips/
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
# ... the rest of Cloudflare's published IPv4 and IPv6 ranges
real_ip_header CF-Connecting-IP;
real_ip_recursive on;

real_ip_header names the header nginx trusts once the peer matches a set_real_ip_from range: CF-Connecting-IP behind Cloudflare, or X-Forwarded-For behind anything else that sets it (a load balancer, a second internal proxy).

set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

real_ip_recursive defaults to off, and that default is the trap. With it off, nginx takes whatever address sits last in the header, full stop, even if that address is itself another proxy you trust. With two or more hops between the visitor and this nginx, each one appending to X-Forwarded-For, the last entry is the second-to-last proxy, not the visitor. Turning it on makes nginx walk the chain from the right and stop at the first address that is not itself in a set_real_ip_from range, which is the visitor. Set it on whenever more than one hop can occur, which is most setups behind a CDN plus a container network.

A Tunnel changes which peer nginx sees. cloudflared opens the connection to your nginx itself, from localhost or a Docker network address, not from one of Cloudflare’s public ranges. set_real_ip_from has to cover wherever cloudflared connects from, and the visitor’s address only ever shows up in CF-Connecting-IP, never in X-Forwarded-For, because cloudflared is the only hop nginx can see:

# cloudflared runs on the same host, outside Docker:
set_real_ip_from 127.0.0.1;
# cloudflared runs in its own container on the same Docker network:
# set_real_ip_from 172.20.0.0/16;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;

Listing Cloudflare’s public ranges here does nothing: cloudflared is a local process from nginx’s point of view, and those ranges never appear as its peer address.

linuxserver SWAG ships the realip setup behind a mod rather than inline config. Add swag-cloudflare-real-ip to the container:

DOCKER_MODS=linuxserver/mods:swag-cloudflare-real-ip

On container start, the mod fetches Cloudflare’s current ranges and writes them to /config/nginx/cf_real-ip.conf. Reference that file from SWAG’s http block (usually /config/nginx/nginx.conf, in a custom include SWAG loads at startup):

real_ip_header X-Forwarded-For;
real_ip_recursive on;
include /config/nginx/cf_real-ip.conf;

Behind a Cloudflare Tunnel, add set_real_ip_from 127.0.0.1; alongside those three lines. The mod’s ranges file only covers Cloudflare’s edge network, and a Tunnel never connects from it.

Put the realip directives in /data/nginx/custom/http_top.conf, a path inside the NPM container. NPM includes that file at the top of the http block, so the lines apply to every host. It is the same file that holds the geometrikks_json format from the README’s Nginx Proxy Manager section; append to it rather than replacing it.

set_real_ip_from 173.245.48.0/20;
# ... the rest of your proxy's ranges
real_ip_header X-Forwarded-For;
real_ip_recursive on;

Then reload nginx inside the NPM container:

Terminal window
docker exec <npm-container> sh -c 'nginx -t && nginx -s reload'

Traefik strips X-Forwarded-For from anything it does not trust. The chain only shows up in the access log at all once the peer sending it is listed under the entry point:

entryPoints:
websecure:
forwardedHeaders:
trustedIPs:
- "173.245.48.0/20"
- "103.21.244.0/22"
# ... the rest of Cloudflare's published IPv4 and IPv6 ranges

The two ranges shown are examples. Add the rest of Cloudflare’s published IPv4 and IPv6 ranges from https://www.cloudflare.com/ips/. If a different proxy connects to Traefik, list that proxy’s ranges instead.

Never set forwardedHeaders.insecure. It trusts the header from any peer, which means any client can put whatever address it wants in X-Forwarded-For and land it on your map. trustedIPs is the only supported path.

Once the peer is trusted, Traefik logs the chain exactly as it arrived, and GeoMetrikks takes the rightmost entry. With a single trusted hop in front of Traefik, that entry is the visitor. With more than one hop, it is not. The rightmost entry is the hop immediately before Traefik, because Traefik has no equivalent of nginx’s real_ip_recursive and never looks past the immediate peer for an earlier trusted hop. The fix belongs at that earlier hop, not at Traefik. Resolve the real IP there, or have it collapse the chain to a single address before forwarding.

The same applies to the app’s own login log, which reads the chain Traefik forwards. When Cloudflare connects to Traefik directly, Traefik never rewrites the peer, so that chain still ends in the Cloudflare node that connected, and the app stops there unless Cloudflare’s ranges are in APP_TRUSTED_PROXIES next to Traefik’s own network. With nginx or SWAG and real_ip this is not needed: the proxy has already replaced the peer with the visitor. Through a Cloudflare Tunnel none of this helps: the peer is cloudflared, the visitor is only in CF-Connecting-IP, and the app reads X-Forwarded-For alone, so the login log shows the cloudflared address. Only a proxy that rewrites the peer from CF-Connecting-IP, as the Tunnel section shows for nginx, gets the visitor into the chain.

Caddy resolves the visitor into the logged client_ip itself, so no realip module applies. Set trusted_proxies under servers in Caddy’s global options. The global options block must be at the very top of the Caddyfile.

For a site behind Cloudflare, add all of Cloudflare’s published ranges to trusted_proxies. The two ranges below are examples. Add the rest of the IPv4 and IPv6 ranges to the same directive. Put your existing site blocks and reverse_proxy directives after the global options block.

{
servers {
trusted_proxies static 173.245.48.0/20 103.21.244.0/22
# ... the rest of Cloudflare's published IPv4 and IPv6 ranges
trusted_proxies_strict
client_ip_headers CF-Connecting-IP X-Forwarded-For
}
}

Trust only the network that can actually connect to this Caddy instance. Do not trust every private range just because Caddy runs in Docker. If you allow direct traffic from the public internet, trusted_proxies does not block it. Use a firewall or an equivalent access rule as well.

CF-Connecting-IP is first so Caddy uses Cloudflare’s single-value header for normal Cloudflare requests. Keep X-Forwarded-For only if another trusted proxy can be in the chain. Otherwise, omit it from client_ip_headers.

client_ip in the access log is the visitor’s address once the ranges and headers match the real connection path. The realip advice elsewhere in this guide is nginx- and Traefik-specific and does not apply here.

If the only thing that ever reaches GeoMetrikks is a Tailscale tailnet, every peer address is private by design and the private-peer advisory is not telling you anything is wrong. Set:

APP_PROXY_ADVISORY=false

APP_TRUSTED_PROXIES is a separate setting for a separate problem. It controls how the app itself resolves the client address on its own inbound requests, the ones logged for login auditing (lib/client_ip.py). It has no effect on how the log parser reads your proxy’s access log files; that path is governed entirely by what your proxy chooses to write, which is what the rest of this document covers. The same trusted-hop rule applies to it, though: behind a CDN that connects to your proxy directly, list the CDN’s ranges in APP_TRUSTED_PROXIES as well unless the proxy rewrites the peer with real_ip. Behind a Cloudflare Tunnel the ranges do nothing for it, for the same reason they do nothing for set_real_ip_from. The Traefik and Tunnel sections above have the details.

None of this is retroactive. Rows already ingested keep whatever address was in the log line at the time; there is no backfill for a real-IP fix made after the fact. Rows dropped as private peers were never stored in the first place, so there is nothing to recover for them either. Fix the proxy config, and correct addresses start from the next line it writes.

  • The CDN advisory for sources tailed by an agent instance (APP_MODE=agent) appears on the head’s Settings > Status page within about 5 minutes. The head scans the shared database’s last hour of access-log rows every 5 minutes. The scan covers only sources storing access logs (LOGPARSER_SEND_LOGS=true).
  • The private-peer advisory for an agent’s sources cannot come from that scan, since those lines are never stored. The agent reports it in its own /health and logs, and its heartbeat carries it to that agent’s row on the head’s Settings > Agents page.
  • CDN detection needs the GeoLite2 ASN database. Without it, every peer classifies as “other” or “private,” and CDN traffic never raises the advisory even at 100% share.
  • Both detectors need at least 500 lines before they can trigger, and only fire once the CDN or private share crosses 70%. The parser checks that against its rolling 2,000-line window; the head’s database scan applies the same 500-row floor and 70% share over its one-hour window. A source with light or mixed traffic can sit below that floor indefinitely without an advisory, even if some fraction of its traffic is affected.
  • Installs running with LOGPARSER_SEND_LOGS=false get private-peer detection only. CDN classification reads the ASN attached to the parsed access-log row, and that row does not exist in geo-only mode.