Symptom: New statistics rows are written, but country, country_code
and city are NULL even though the GeoLite2 database resolves correctly when
tested directly:
docker exec marketix-app-1 php artisan marketix:geoip:test 94.114.71.204
# -> Germany / Bocholt / DE (DB and lookup code are fine)Root cause: This is not an application bug. The stack runs
client → Traefik → Caddy/FrankenPHP → PHP, and bootstrap/app.php already
trusts the reverse proxy (trustProxies(at: '*')). The real client IP is lost
one layer lower, in Docker's networking:
With Docker's userland-proxy enabled (the default when /etc/docker/daemon.json
is empty/{}), the docker-proxy relay handles inbound :80/:443 and rewrites
the source IP to the bridge gateway (e.g. 172.20.0.1). Traefik therefore never
sees the real client and sets X-Forwarded-For: 172.20.0.1. Laravel reads that
correctly → a private IP → GeoIP returns null. This tends to surface after the
containers are recreated on a newer Docker engine (28/29).
Diagnose: drop a temporary probe into public/, hit it from a real external
IP, and check what PHP actually receives (remember to remove it afterwards):
// public/ipdebug.php — TEMPORARY, delete after use
<?php
header('Content-Type: text/plain');
echo 'REMOTE_ADDR='.($_SERVER['REMOTE_ADDR'] ?? '')."\n";
echo 'X-Forwarded-For='.($_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')."\n";If X-Forwarded-For shows a 172.20.0.x gateway address for an external
request, it is this issue.
Fix: disable the Docker userland proxy so the kernel uses pure DNAT and preserves the source IP, then restart the daemon:
echo '{"userland-proxy": false}' > /etc/docker/daemon.json && systemctl restart docker
⚠️ systemctl restart dockerrestarts all containers (a few seconds of downtime). Thedaemon.jsonchange is persistent and survives reboots.
Verify: after the restart, an external request must arrive with the real
X-Forwarded-For, and a real click must record geo data:
docker exec marketix-app-1 php artisan tinker --execute="\
use App\Models\Statistic; \$s = Statistic::latest()->first(); \
echo \$s->country.' / '.\$s->country_code.' / '.\$s->city.PHP_EOL;"Existing NULL rows cannot be backfilled — only the visitor hash is stored, not
the raw IP.
When the app runs behind Cloudflare, the visitor's address arrives in the
CF-Connecting-IP header. TrustCloudflareClientIp uses it only when the
request came from one of Cloudflare's edge servers, so the header cannot be
forged by requests that bypass Cloudflare.
Cloudflare's IP ranges are refreshed daily by the scheduler and stored in
storage/app/cloudflare/ip-ranges.json. Until the first successful update the
list bundled in config/cloudflare.php is used. To refresh them by hand:
docker exec marketix-app-1 php artisan marketix:cloudflare:update
# -> Saved 15 IPv4 and 7 IPv6 ranges.A failed download or an unexpected response keeps the previous list.