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:

  1. Server-side GeoIP enrichment (what I did): Override $geoip_* properties before sending events
  2. Self-host PostHog: Configure trusted proxies properly
  3. 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:

  1. Confirm headers reach your application
    Check that X-Forwarded-For appears in your server logs

  2. Test PostHog Cloud’s IP detection
    Don’t assume it honors proxy headers—verify what IP PostHog actually sees by examining event details

  3. Disable PostHog’s GeoIP transformation
    If you’re enriching geo data yourself, turn off PostHog’s built-in lookup in project settings

  4. Test 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.