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.
How to tell
Section titled “How to tell”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.
Cloudflare Tunnel
Section titled “Cloudflare Tunnel”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-ipOn 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.
Nginx Proxy Manager
Section titled “Nginx Proxy Manager”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 rangesreal_ip_header X-Forwarded-For;real_ip_recursive on;Then reload nginx inside the NPM container:
docker exec <npm-container> sh -c 'nginx -t && nginx -s reload'Traefik
Section titled “Traefik”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 rangesThe 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.
Tailscale-only
Section titled “Tailscale-only”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=falseWhat this is not
Section titled “What this is not”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.
History
Section titled “History”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.
Limits
Section titled “Limits”- 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
/healthand 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=falseget 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.