PostHog wasn’t tracking client IPs correctly behind my reverse proxy. NextJS logged the right IPs, Caddy sent the right headers, but PostHog’s dashboard showed everyone visiting from my VPS in Frankfurt.
This broke geographic analysis and location-based feature flags. Here’s what I tried and what actually worked.
The Setup
My application stack:
- NextJS application
- Caddy reverse proxy (handles SSL)
- PostHog Cloud for analytics
The proxy setup is standard: Caddy receives requests, sets proper headers, passes them to NextJS.
I verified NextJS was seeing correct client IPs in the logs.
But PostHog? Every analytics event showed my server IP (the Caddy proxy) instead of the actual client IP.
This made the “World map” in their web analytics page only show one visitor location—even though I knew we had users in different countries.
What I Expected vs What Happened
Expected: PostHog would respect the X-Forwarded-For headers that Caddy was setting. This is standard practice for reverse proxies.
Reality: PostHog Cloud ignored the headers entirely. All analytics showed the proxy server IP, not client IPs.
This broke:
- Geographic analysis (all traffic appeared to come from my VPS location)
- Any IP-based filtering or analysis
The Root Cause
PostHog Cloud doesn’t support custom “Trusted Proxies” configuration. It only uses the TCP connection IP for analytics.
The trusted proxies feature exists in self-hosted PostHog—where you can configure which proxy IPs to trust and honor their forwarding headers.
But in PostHog Cloud, this configuration isn’t available. The service can’t distinguish between legitimate reverse proxies (like my Caddy setup) and potentially malicious header spoofing.
The Solution
I initially skipped the proxy for PostHog endpoints—let client-side JavaScript talk directly to PostHog’s API. This worked but had downsides:
- ❌ PostHog endpoint visible in client code (minor privacy/control loss)
- ❌ Can’t customize analytics domain
- ❌ Events could be blocked by tracking blockers
The better solution: Override the GeoIP properties directly.
PostHog lets you override $geoip_country_code and other GeoIP properties before sending events. Since my NextJS application already has access to the correct client IP (from Caddy’s headers), I can extract the GeoIP data server-side and include it in the PostHog event.
This approach:
- ✅ Keeps analytics traffic proxied (privacy/control maintained)
- ✅ Accurate geographic data
- ✅ Works with tracking blockers (analytics go through my domain)
- ⚠️ Requires server-side GeoIP lookup (small added complexity)
I tested the override approach in staging, confirmed it worked, then switched production from the direct connection to the proxied + override setup.
How I Implemented It
After considering the options, I implemented server-side GeoIP enrichment that runs once on page load:
// app/providers.tsx (client component)
posthog.init(key, {
api_host: '/ph', // Proxied through middleware
loaded: (ph) => {
fetch('/api/geoip')
.then(res => res.json())
.then(({ data }) => ph.register(data));
}
});
// app/api/geoip/route.ts (Node.js runtime)
import { NextRequest, NextResponse } from 'next/server';
import Reader from '@maxmind/geoip2-node';
export async function GET(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]
|| request.headers.get('x-real-ip');
const reader = await Reader.open('/path/to/GeoLite2-City.mmdb');
const response = reader.city(ip);
return NextResponse.json({
data: {
$geoip_city_name: response.city?.names?.en,
$geoip_country_code: response.country?.isoCode,
$geoip_country_name: response.country?.names?.en,
$geoip_continent_code: response.continent?.code,
$geoip_continent_name: response.continent?.names?.en,
$geoip_postal_code: response.postal?.code,
$geoip_latitude: response.location?.latitude,
$geoip_longitude: response.location?.longitude,
$geoip_time_zone: response.location?.timeZone,
}
});
}
The middleware proxies PostHog requests (fast Edge runtime), while the Node.js API route handles MaxMind GeoIP lookups with 24-hour caching via unstable_cache.
Critical: Disable PostHog’s built-in GeoIP transformation in your project settings, or it’ll overwrite your client IPs with the proxy server’s location.
Trade-off: One extra request on page load (~10ms) for accurate geo data on all subsequent events.
When This Matters
If you’re using PostHog Cloud behind a reverse proxy and need accurate geo data, you have three options:
- Server-side GeoIP enrichment (what I did): Override
$geoip_*properties before sending events - Self-host PostHog: Configure trusted proxies properly
- Skip the proxy: Let clients connect directly to PostHog Cloud
I went with option 1 to keep analytics proxied (bypasses ad-blockers, maintains privacy) while getting accurate location data.
Debugging Checklist
After debugging this, here’s what I’d verify first when setting up PostHog behind a reverse proxy:
Confirm headers reach your application
Check thatX-Forwarded-Forappears in your server logsTest PostHog Cloud’s IP detection
Don’t assume it honors proxy headers—verify what IP PostHog actually sees by examining event detailsDisable PostHog’s GeoIP transformation
If you’re enriching geo data yourself, turn off PostHog’s built-in lookup in project settingsTest in staging first
Verify geographic data accuracy before deploying to production
The server-side GeoIP enrichment adds 10ms on initial page load but provides accurate location data for all subsequent events.